Skip to content

Commit 2da0281

Browse files
committed
feat(rag): v0.19.0~1 kb 派生索引与学习库问答(REQ-258/260)
1 parent f1deecc commit 2da0281

43 files changed

Lines changed: 3521 additions & 99 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/src-tauri/src/ai_chat_stream.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ pub enum ChatStreamEvent {
2727
Failed { error_kind: String, message: String },
2828
/// 用户取消(content=已生成文本;消息落库标 aborted)
2929
Aborted { content: String },
30+
/// v0.19.1(REQ-260):学习库问答命中片段(本地恒可用——先于/独立于
31+
/// 生成;无命中为空列表——前端按内容文案呈现)
32+
KbHits { hits: Vec<crate::kb_search::KbHit> },
3033
}
3134

3235
impl From<&AiClientError> for ChatStreamEvent {

‎app/src-tauri/src/ai_settings.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ pub struct AiSettings {
5050
pub goal_plan_enabled: bool,
5151
/// v0.18.2(REQ-254):目标规划预算档位(light/standard/deep;默认标准)。
5252
pub goal_plan_tier: String,
53+
/// v0.19.1(REQ-260):学习库问答生成独立闸门——命中片段列表恒可用
54+
/// (本地零成本零上传),生成=最小片段上云 → content_gate + 本开关双闸门,
55+
/// 默认关(ADR-029 决策 4/治理 §9)。
56+
pub kb_qa_enabled: bool,
57+
/// v0.19.1(REQ-260):学习库问答片段预算档位(light/standard/deep;
58+
/// 默认 standard——budget_allocator 档位硬顶复用)。
59+
pub kb_qa_tier: String,
5360
}
5461

5562
impl Default for AiSettings {
@@ -65,6 +72,8 @@ impl Default for AiSettings {
6572
refine_strategy: RefineStrategyPrefs::default(),
6673
goal_plan_enabled: false,
6774
goal_plan_tier: "standard".to_string(),
75+
kb_qa_enabled: false,
76+
kb_qa_tier: "standard".to_string(),
6877
}
6978
}
7079
}
@@ -121,6 +130,19 @@ impl AiSettings {
121130
Ok(())
122131
}
123132

133+
/// 学习库问答生成双闸门(v0.19.1 REQ-260):content_gate + kb_qa 开关。
134+
///
135+
/// @ai-context: 学习库问答检索在本地完成(命中列表恒可用——不受本闸门
136+
/// 约束);生成才上传**最小命中片段** → 在授权红线之上再设
137+
/// 独立开关(默认关)。失败文案引导设置页「学习库」段。
138+
pub fn kb_qa_gate(&self) -> Result<(), String> {
139+
self.content_gate()?;
140+
if !self.kb_qa_enabled {
141+
return Err("学习库问答生成未开启(设置→AI 服务→学习库段打开开关;关闭时命中片段列表照常可用,零影响)".to_string());
142+
}
143+
Ok(())
144+
}
145+
124146
/// 非内容类 AI 调用门控(余额查询/测试连接:仅要求开关开启——避免关闭
125147
/// 状态下误以为功能可用;测试连接为配置验证操作不 gate,见 command 层)。
126148
/// 当前无消费方(预留——若未来余额查询/测试连接要求开启开关时启用),

‎app/src-tauri/src/ai_settings_tests.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ fn save_load_roundtrip() {
6363
// v0.18.2:目标 AI 独立闸门 + 预算档位 roundtrip 保真
6464
goal_plan_enabled: true,
6565
goal_plan_tier: "deep".to_string(),
66+
// v0.19.1:学习库问答生成独立闸门 + 预算档位 roundtrip 保真
67+
kb_qa_enabled: true,
68+
kb_qa_tier: "light".to_string(),
6669
};
6770
s.save(&path).expect("保存成功");
6871
let loaded = AiSettings::load(&path);
@@ -89,3 +92,14 @@ fn enabled_gate_only_checks_switch() {
8992
let s2 = AiSettings { enabled: true, ..AiSettings::default() };
9093
assert!(s2.enabled_gate().is_ok());
9194
}
95+
96+
#[test]
97+
fn kb_qa_gate_requires_content_gate_plus_kb_switch() {
98+
// 双闸门(v0.19.1 REQ-260):默认关(命中列表不受约束——生成才闸)
99+
let s = AiSettings::default();
100+
assert!(s.kb_qa_gate().is_err(), "默认状态生成必须被拦截");
101+
let mut s2 = AiSettings { enabled: true, authorized: true, ..AiSettings::default() };
102+
assert!(s2.kb_qa_gate().is_err(), "content_gate 过但 kb 开关未开仍拦截");
103+
s2.kb_qa_enabled = true;
104+
assert!(s2.kb_qa_gate().is_ok(), "双闸门齐开放行");
105+
}

‎app/src-tauri/src/commands_ai_chat.rs‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,24 @@ const CHAT_SYSTEM_PROMPT: &str =
2828
/// 单条消息最大字符数(防误粘贴巨文——超限明确拒绝而非静默截断)。
2929
const MAX_MESSAGE_CHARS: usize = 16000;
3030

31-
/// 新建会话(标题可空——默认"新对话")。
31+
/// 新建会话(标题可空——默认"新对话";retrieval=true = 学习库问答模式,
32+
/// v0.19.1——创建时定死,已有会话不改语义)。
3233
#[tauri::command]
33-
pub fn chat_create_session(state: State<'_, AppState>, title: Option<String>) -> Result<ChatSession, String> {
34+
pub fn chat_create_session(
35+
state: State<'_, AppState>,
36+
title: Option<String>,
37+
retrieval: Option<bool>,
38+
) -> Result<ChatSession, String> {
3439
let title = title.unwrap_or_default();
3540
if title.chars().count() > 100 {
3641
return Err("会话标题过长(≤100 字符)".to_string());
3742
}
3843
let id = state
3944
.db
40-
.insert_chat_session(if title.trim().is_empty() { None } else { Some(title.trim()) })
45+
.insert_chat_session(
46+
if title.trim().is_empty() { None } else { Some(title.trim()) },
47+
retrieval.unwrap_or(false),
48+
)
4149
.map_err(|e| e.to_string())?;
4250
validate_session(&state, id)
4351
}
@@ -110,13 +118,24 @@ pub async fn chat_send(
110118
if content.chars().count() > MAX_MESSAGE_CHARS {
111119
return Err("消息过长(≤16000 字符)".to_string());
112120
}
121+
let session = validate_session(&state, session_id)?;
122+
// v0.19.1(REQ-260):学习库问答会话——命中列表恒可用(本地能力不受
123+
// content_gate 约束),生成走 kb 双闸门 → 整链分流入 commands_ai_chat_kb
124+
if session.retrieval {
125+
return crate::commands_ai_chat_kb::kb_chat_send(
126+
&state,
127+
session_id,
128+
&content,
129+
resend_message_id,
130+
channel,
131+
);
132+
}
113133
let settings = state
114134
.ai_settings
115135
.lock()
116136
.map_err(|e| format!("AI 设置锁中毒: {}", e))?
117137
.clone();
118138
settings.content_gate()?; // 授权红线:enabled + authorized 双闸门(默认关)
119-
let session = validate_session(&state, session_id)?;
120139
// 先解析客户端(Provider/密钥缺失 → 明确报错,不落无应答的用户消息)
121140
let (client, provider_id) = resolve_chat_client(&state, &session)?;
122141
let model = client.config.model.clone();
@@ -165,13 +184,17 @@ pub async fn chat_regenerate(
165184
session_id: i64,
166185
channel: Channel<ChatStreamEvent>,
167186
) -> Result<(), String> {
187+
let session = validate_session(&state, session_id)?;
188+
// v0.19.1:学习库问答会话重生成——重跑检索 + 生成整链
189+
if session.retrieval {
190+
return crate::commands_ai_chat_kb::kb_chat_regenerate(&state, session_id, channel);
191+
}
168192
let settings = state
169193
.ai_settings
170194
.lock()
171195
.map_err(|e| format!("AI 设置锁中毒: {}", e))?
172196
.clone();
173197
settings.content_gate()?;
174-
let session = validate_session(&state, session_id)?;
175198
// 先解析客户端(Provider/密钥失败 → 不删旧回答,保留可重发状态)
176199
let (client, _provider_id) = resolve_chat_client(&state, &session)?;
177200
let model = client.config.model.clone();

0 commit comments

Comments
 (0)