Skip to content

Commit 6d6039e

Browse files
committed
feat(goal): v0.18.2 AI 目标规划后端——协议/预算/摘要/弱项/规划与确认流(REQ-251~254)
- 纯函数 5 件 TDD:goal_plan_protocol(Proposal 强校验 golden:白名单/门槛/超限丢弃 登记)/ budget_allocator(档位 4K/10K/30K,O(1) 恒量截断+诚实信号)/ goal_summary(L4.5 摘要现算 <1K)/ concept_weakness(90 天低激活,M3 真实化)/ goal_plan_prompt(goal_plan.json 声明式模板+兜底) - 数据层:knowledge_links 白名单 +'goal'(零迁移)+ link_goal_to_system 幂等 + goal_concept_activities(应用记录 Rust 解析——防坏 JSON 中断 json_each) + goal_plan_context(库即记忆最小面)+ apply_plan_core 事务(替换/增量两语义, 周契约落主组) - AI 层:ai_goal_plan_estimate/ai_goal_plan(双闸门 content_gate+goal_plan_enabled 默认关、授权确认、余额硬拦、预算截断诚实提示、ai_tasks 审计轨迹+成本) - 确认流:goal_apply_plan(存在性校验/体系骨架 create 逐条独立事务——部分失败 不污染目标层)+ goal_chat_context(L4.5 注入)/ goal_concept_weakness 命令 - 设置:AiSettings +goal_plan_enabled/goal_plan_tier(serde default 零迁移) + ai_set_goal_plan(read-modify-write 最小面) - 修复:link_goal_to_system 幂等(查重先行——knowledge_links 无 UNIQUE) - 目标组测试 80 通过;clippy 零警告(预存 note_filter 2 例除外)
1 parent d8313ec commit 6d6039e

20 files changed

Lines changed: 1725 additions & 3 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"version": 1,
3+
"system": "你是「熵减」学习目标规划师。用户已通过四步访谈澄清目标(用途/现状/判据/素材),下面给出目标上下文(摘要、信号、素材清单)。请产出务实的规划建议:只建议明确可落地的内容,不空谈;里程碑按周期节奏拆分(基础→应用→项目,周数反应投入);组绑定建议仅选已有素材组;体系建议可选「挂接现有体系」或「新建体系骨架」(骨架必须给出名称、核心问题、至少一个领域入口与 3-5 个初始概念——克制,宁缺勿滥);周契约参考里程碑紧迫度给出建议值;所有内容都必须服务于「学会它并会用」这个意图,不回答与目标无关的问题。",
4+
"output_format": "只输出一个 JSON 对象(不要代码围栏):{\"milestones\":[{\"title\":\"标题\",\"dueWeeks\":4,\"criteriaType\":\"manual|group_settled\",\"refGroupId\":12|null,\"note\":\"一句为什么\"}],\"groups\":[{\"groupId\":12,\"reason\":\"为什么绑定\"}],\"systems\":[{\"action\":\"create|link\",\"systemId\":null,\"name\":\"体系名\",\"coreQuestion\":\"核心问题\",\"domainEntries\":[\"领域入口1\"],\"concepts\":[{\"name\":\"概念名\",\"essence\":\"它是什么\",\"boundary\":\"它不是\",\"relation\":\"与什么相关\"}],\"reason\":\"为什么\"}],\"weeklyContract\":{\"targetDays\":3,\"targetCards\":20},\"summary\":\"一句话规划说明\"}。字段约束:milestones 标题 ≤80 字且 ≤12 条;dueWeeks ≤52;criteriaType 只能 manual/group_settled(group_settled 必须给 refGroupId);systems action=create 时 systemId 必须为 null 且 name/coreQuestion/domainEntries 必填;concepts ≤8 个。",
5+
"few_shot": "输入:目标「学会 Python」(工作自动化;零基础;判据=能独立完成爬虫实例;每周 5h+;已有组:Python 组(12))\n输出样例(仅形状示范):{\"milestones\":[{\"title\":\"基础入门:语法+小练习\",\"dueWeeks\":4,\"criteriaType\":\"manual\",\"refGroupId\":null,\"note\":\"先建立语法语感\"},{\"title\":\"爬虫实例:完成并记录应用\",\"dueWeeks\":8,\"criteriaType\":\"group_settled\",\"refGroupId\":12,\"note\":\"随组结算自动通过\"}],\"groups\":[{\"groupId\":12,\"reason\":\"素材命中\"}],\"systems\":[{\"action\":\"create\",\"systemId\":null,\"name\":\"Python 应用能力\",\"coreQuestion\":\"怎么把语法变成工具?\",\"domainEntries\":[\"语言基础\",\"数据抓取\"],\"concepts\":[{\"name\":\"闭包\",\"essence\":\"捕获环境变量的函数\",\"boundary\":\"不是类\",\"relation\":\"装饰器\"}],\"reason\":\"目标缺体系支撑\"}],\"weeklyContract\":{\"targetDays\":4,\"targetCards\":25},\"summary\":\"8 周完成从基础到爬虫实例,同步建体系骨架\"}"
6+
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ pub struct AiSettings {
4444
/// 精修产出策略偏好(v0.17.0 REQ-245):默认档位 + 逐维覆盖——
4545
/// 全局默认(任务级覆盖优先);serde default:旧 JSON 零迁移回填标准档
4646
pub refine_strategy: RefineStrategyPrefs,
47+
/// v0.18.2(REQ-254):目标 AI(规划师)独立闸门——内容门控之外的
48+
/// 专用开关,默认关(与全局 enabled 独立:目标规划是上传类调用,
49+
/// 双闸门 = content_gate + 本开关)。
50+
pub goal_plan_enabled: bool,
51+
/// v0.18.2(REQ-254):目标规划预算档位(light/standard/deep;默认标准)。
52+
pub goal_plan_tier: String,
4753
}
4854

4955
impl Default for AiSettings {
@@ -57,6 +63,8 @@ impl Default for AiSettings {
5763
remember_cost_choice: false,
5864
vision_refine_enabled: false,
5965
refine_strategy: RefineStrategyPrefs::default(),
66+
goal_plan_enabled: false,
67+
goal_plan_tier: "standard".to_string(),
6068
}
6169
}
6270
}
@@ -100,6 +108,19 @@ impl AiSettings {
100108
Ok(())
101109
}
102110

111+
/// 目标规划双闸门(v0.18.2 REQ-254):content_gate + 目标 AI 专用开关。
112+
///
113+
/// @ai-context: 目标规划=内容上传类调用(最小检索片段上云),在全局
114+
/// 授权红线之上再设独立开关(默认关)——"本地优先 + 用户
115+
/// 知情";失败文案引导设置页「目标 AI」区,不静默。
116+
pub fn goal_plan_gate(&self) -> Result<(), String> {
117+
self.content_gate()?;
118+
if !self.goal_plan_enabled {
119+
return Err("目标 AI 规划未开启(设置→目标 AI 段打开开关;关闭时按规则草案正常规划,零影响)".to_string());
120+
}
121+
Ok(())
122+
}
123+
103124
/// 非内容类 AI 调用门控(余额查询/测试连接:仅要求开关开启——避免关闭
104125
/// 状态下误以为功能可用;测试连接为配置验证操作不 gate,见 command 层)。
105126
/// 当前无消费方(预留——若未来余额查询/测试连接要求开启开关时启用),

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ fn save_load_roundtrip() {
6060
default_ladder: "deep".to_string(),
6161
dim_overrides: std::collections::HashMap::from([("examples".to_string(), "condensed".to_string())]),
6262
},
63+
// v0.18.2:目标 AI 独立闸门 + 预算档位 roundtrip 保真
64+
goal_plan_enabled: true,
65+
goal_plan_tier: "deep".to_string(),
6366
};
6467
s.save(&path).expect("保存成功");
6568
let loaded = AiSettings::load(&path);
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//! 上下文预算分配器(v0.18.2 REQ-254;ADR-027 §4 硬约束纯函数)。
2+
//!
3+
//! @ai-context: 调用成本与库规模解耦——O(1) 恒量预算:档位内 系统+目标摘要
4+
//! ~2K|信号 ~0.3K|检索片段 ~2.4K|输出预留 ~2K(设计 §六),
5+
//! 超限按相关性截断且**诚实提示绝不静默**;任意大输入 → 恒定输出。
6+
//! @ai-context: 档位化(轻量 4K/标准 10K/深度 30K,默认标准)——设置页可选。
7+
8+
/// 档位 token 上限(轻量/标准/深度)。
9+
pub const TIER_LIGHT_TOKENS: usize = 4_000;
10+
pub const TIER_STANDARD_TOKENS: usize = 10_000;
11+
pub const TIER_DEEP_TOKENS: usize = 30_000;
12+
13+
/// 字符→token 粗估(中文 ≈1.5-2 字/token,保守 2 字/token——低估 token 会爆
14+
/// 预算,取保守值做**上界预留**)。
15+
pub const CHARS_PER_TOKEN: usize = 2;
16+
17+
/// 档位解析(未知/空 → 标准——诚实回落不猜)。
18+
pub fn tier_tokens(tier: &str) -> usize {
19+
match tier {
20+
"light" => TIER_LIGHT_TOKENS,
21+
"deep" => TIER_DEEP_TOKENS,
22+
_ => TIER_STANDARD_TOKENS,
23+
}
24+
}
25+
26+
/// 预算分解(系统+摘要/信号/检索/输出预留——剩余留断言与容错)。
27+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
28+
#[serde(rename_all = "camelCase")]
29+
pub struct BudgetPlan {
30+
pub tier: String,
31+
pub total_tokens: usize,
32+
pub overhead_tokens: usize,
33+
pub retrieval_tokens: usize,
34+
pub output_reserve_tokens: usize,
35+
/// 检索侧可用的字符预算(截断上界)。
36+
pub retrieval_chars: usize,
37+
}
38+
39+
/// 按档位生成预算(开销恒量——任意档位 O(1))。
40+
///
41+
/// @ai-context: 摘要/信号为恒量开销(<2K),检索按剩余额度分配——
42+
/// 输出预留 2K + 系统/摘要/信号 2.3K 是硬保留。
43+
pub fn plan_budget(tier: &str) -> BudgetPlan {
44+
let total = tier_tokens(tier);
45+
let overhead_tokens = 2_300; // 系统提示 + 目标摘要 + 标量信号
46+
let output_reserve_tokens = 2_000;
47+
let retrieval_tokens = total.saturating_sub(overhead_tokens + output_reserve_tokens);
48+
BudgetPlan {
49+
tier: tier.to_string(),
50+
total_tokens: total,
51+
overhead_tokens,
52+
retrieval_tokens,
53+
output_reserve_tokens,
54+
retrieval_chars: retrieval_tokens * CHARS_PER_TOKEN,
55+
}
56+
}
57+
58+
/// 检索片段截断(字符上界;O(len) 单遍——片段长时按界截)。
59+
pub fn truncate_retrieval(text: &str, budget_chars: usize) -> String {
60+
if budget_chars == 0 {
61+
return String::new();
62+
}
63+
text.chars().take(budget_chars).collect()
64+
}
65+
66+
/// 多片段排序截断(按给定相关性得分离低;总量 ≤ budget_chars——O(N log N) 排序
67+
/// 后单遍累加,**输出上界与输入规模无关**:预算恒定)。
68+
/// 登记豁免 dead_code:多片段检索打包为 L3 FTS 接线预留(当前最小面上文为
69+
/// 单体串截断 truncate_retrieval——本函数 TDD 就绪,随 REQ-024 检索增强启用)。
70+
#[allow(dead_code)]
71+
pub fn pack_fragments(fragments: &[(String, f64)], budget_chars: usize) -> (String, bool) {
72+
let mut sorted: Vec<&(String, f64)> = fragments.iter().collect();
73+
sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
74+
let mut out = String::new();
75+
let mut used = 0usize;
76+
let mut truncated = false;
77+
for (text, _score) in sorted {
78+
let remaining = budget_chars.saturating_sub(used);
79+
if remaining == 0 {
80+
truncated = true;
81+
break;
82+
}
83+
let take = text.chars().count().min(remaining);
84+
if take < text.chars().count() {
85+
truncated = true;
86+
}
87+
out.push_str(&text.chars().take(take).collect::<String>());
88+
out.push('\n');
89+
used += take + 1;
90+
}
91+
(out, truncated)
92+
}
93+
94+
/// 诚实提示文案(截断/降级时注入——绝不静默;前端展示用)。
95+
pub fn honest_truncation_note(truncated: bool) -> &'static str {
96+
if truncated {
97+
"⚠️ 上下文超限,已按相关性精简(成本与库规模解耦,不影响结论)"
98+
} else {
99+
""
100+
}
101+
}
102+
103+
#[cfg(test)]
104+
#[path = "budget_allocator_tests.rs"]
105+
mod tests;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! budget_allocator 单测(档位矩阵/O(1) 恒量/截断与诚实信号)。
2+
3+
use crate::budget_allocator::{
4+
honest_truncation_note, pack_fragments, plan_budget, tier_tokens, truncate_retrieval,
5+
};
6+
7+
#[test]
8+
fn tier_matrix() {
9+
assert_eq!(tier_tokens("light"), 4_000);
10+
assert_eq!(tier_tokens("standard"), 10_000);
11+
assert_eq!(tier_tokens("deep"), 30_000);
12+
assert_eq!(tier_tokens(""), 10_000, "未知回落标准");
13+
assert_eq!(tier_tokens("huge"), 10_000);
14+
}
15+
16+
#[test]
17+
fn budget_plan_constant_components() {
18+
let s = plan_budget("standard");
19+
assert_eq!(s.retrieval_tokens, 10_000 - 2_300 - 2_000);
20+
assert_eq!(s.retrieval_chars, s.retrieval_tokens * 2);
21+
let d = plan_budget("deep");
22+
assert_eq!(d.retrieval_tokens, 30_000 - 2_300 - 2_000);
23+
// O(1):输出上界与输入规模无关——budget 恒定
24+
assert_eq!(s.retrieval_chars, (10_000 - 4_300) * 2);
25+
}
26+
27+
#[test]
28+
fn truncate_respects_char_budget() {
29+
let text = "中".repeat(1000);
30+
let out = truncate_retrieval(&text, 100);
31+
assert_eq!(out.chars().count(), 100);
32+
assert_eq!(truncate_retrieval(&text, 0), "");
33+
let short = truncate_retrieval("短", 100);
34+
assert_eq!(short, "短");
35+
}
36+
37+
#[test]
38+
fn pack_fragments_orders_by_score_and_bounds_total() {
39+
let frags = vec![
40+
("低".to_string(), 0.2),
41+
("高相关片段".to_string(), 0.9),
42+
("中".to_string(), 0.5),
43+
];
44+
let (out, truncated) = pack_fragments(&frags, 20);
45+
assert!(out.contains("高相关片段"), "高相关优先入包: {}", out);
46+
assert!(out.find("高相关片段").unwrap() < out.find("中").unwrap());
47+
assert!(truncated, "预算 20 字符必截断——诚实信号");
48+
let (all, t2) = pack_fragments(&frags, 10_000);
49+
assert!(all.contains("低"));
50+
assert!(!t2);
51+
}
52+
53+
#[test]
54+
fn honest_note_only_when_truncated() {
55+
assert_eq!(honest_truncation_note(false), "");
56+
assert!(honest_truncation_note(true).contains("已按相关性精简"));
57+
}

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ pub struct AiSettingsView {
3333
pub vision_refine_enabled: bool,
3434
/// 精修产出策略偏好(v0.17.0 REQ-245:默认档位 + 逐维覆盖)
3535
pub refine_strategy: crate::ai_strategy::RefineStrategyPrefs,
36+
/// v0.18.2(REQ-254):目标 AI(规划师)独立开关——默认关(双闸门之二)
37+
pub goal_plan_enabled: bool,
38+
/// v0.18.2(REQ-254):目标规划预算档位(light/standard/deep——默认标准)
39+
pub goal_plan_tier: String,
3640
/// 是否已配置密钥(env 或凭据库)
3741
pub has_key: bool,
3842
/// 密钥来源:credential | env | none
@@ -70,6 +74,8 @@ pub fn ai_get_settings(state: State<'_, AppState>) -> Result<AiSettingsView, Str
7074
remember_cost_choice: s.remember_cost_choice,
7175
vision_refine_enabled: s.vision_refine_enabled,
7276
refine_strategy: s.refine_strategy.clone(),
77+
goal_plan_enabled: s.goal_plan_enabled,
78+
goal_plan_tier: s.goal_plan_tier.clone(),
7379
has_key,
7480
key_source,
7581
})
@@ -94,6 +100,27 @@ pub fn ai_clear_key(state: State<'_, AppState>) -> Result<(), String> {
94100
state.ai_credentials.clear_key("default")
95101
}
96102

103+
/// v0.18.2(REQ-254):目标 AI 设置(read-modify-write 最小面——
104+
/// 不覆盖其他设置字段;档位白名单 light/standard/deep)。
105+
#[tauri::command]
106+
pub fn ai_set_goal_plan(
107+
state: State<'_, AppState>,
108+
enabled: bool,
109+
tier: Option<String>,
110+
) -> Result<(), String> {
111+
let tier = tier.unwrap_or_else(|| "standard".to_string());
112+
if !matches!(tier.as_str(), "light" | "standard" | "deep") {
113+
return Err(format!("不支持的预算档位: {}(支持: light/standard/deep)", tier));
114+
}
115+
let mut s = state
116+
.ai_settings
117+
.lock()
118+
.map_err(|e| format!("AI 设置锁中毒: {}", e))?;
119+
s.goal_plan_enabled = enabled;
120+
s.goal_plan_tier = tier;
121+
s.save(&state.ai_settings_path).map_err(|e| e.to_string())
122+
}
123+
97124
/// 更新 AI 设置(白名单校验后持久化;command 层锁内 read-modify-write)。
98125
///
99126
/// @ai-context: 校验:端点非空且 http(s) 前缀、模型非空、低余额阈值 ∈ (0, 1e6];

0 commit comments

Comments
 (0)