Skip to content

Commit 39dd1a7

Browse files
committed
feat(ai): 精修程度自定义档与标准恒纯净(REQ-279)
1 parent d6bfb57 commit 39dd1a7

13 files changed

Lines changed: 449 additions & 87 deletions

‎app/src-tauri/prompts/note_refine.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@
140140
"desc": "只留骨架:概念/步骤/结论",
141141
"instruction": "本次为极简提取档:每节只保留核心概念/步骤/结论,删除例子与铺垫,单节内容块不超过 3 个,直接呈现骨架。",
142142
"dimValues": { "examples": "condensed", "colloquial": "purified", "emotion": "drop", "conclusion": "as_is", "sections": "inherit" }
143+
},
144+
{
145+
"id": "custom",
146+
"name": "自定义",
147+
"desc": "写下你的具体处理要求(REQ-279)",
148+
"instruction": "",
149+
"dimValues": {}
143150
}
144151
],
145152
"intents": [

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ fn run_note_refine_inner(
112112
strategy: Some(crate::commands_ai_refine::RefineStrategyInfo {
113113
preset_id: dims.preset_id.clone(),
114114
dims: dims.dims.clone(),
115+
// REQ-279:自定义档文本随结果落库(溯源/重生成沿用)
116+
custom_text: if dims.custom_text.is_empty() { None } else { Some(dims.custom_text.clone()) },
115117
}),
116118
},
117119
turns,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,8 @@ fn run_refine_task_inner(
336336
strategy: Some(crate::commands_ai_refine::RefineStrategyInfo {
337337
preset_id: dims.preset_id.clone(),
338338
dims: dims.dims.clone(),
339+
// REQ-279:自定义档文本随结果落库(溯源/重生成沿用)
340+
custom_text: if dims.custom_text.is_empty() { None } else { Some(dims.custom_text.clone()) },
339341
}),
340342
},
341343
turns,

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

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,34 +84,59 @@ pub struct RefineStrategyPrefs {
8484
pub dim_overrides: HashMap<String, String>,
8585
}
8686

87-
/// 任务级覆盖(发起点传参:预设档位 id + 逐维覆盖——可整体换档或单维微调)。
87+
/// 任务级覆盖(发起点传参:预设档位 id + 逐维覆盖 + 自定义档自由文本——
88+
/// 可整体换档/单维微调/自定义描述;custom_text 仅 preset=自定义 时生效)。
8889
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
8990
#[serde(rename_all = "camelCase")]
9091
pub struct StrategyOverride {
9192
pub preset_id: Option<String>,
9293
#[serde(default)]
9394
pub dims: HashMap<String, String>,
95+
/// 自定义档的自由文本(REQ-279;空/None=未启用——后端回退 standard)
96+
#[serde(default)]
97+
pub custom_text: Option<String>,
9498
}
9599

100+
/// 自定义档自由文本上限(字符;防御性边界——防提示词膨胀)
101+
pub const MAX_CUSTOM_TEXT_CHARS: usize = 500;
102+
96103
/// 解析结果(每维最终值——Send+Clone,可跨任务线程;instruction=档级总体
97104
/// 指令,空=不追加)。
98105
#[derive(Debug, Clone, PartialEq, Default)]
99106
pub struct ResolvedDims {
100107
pub preset_id: String,
101108
pub instruction: String,
102109
pub dims: HashMap<String, String>,
110+
/// 自定义档自由文本(仅 preset=custom 且非空时非空——溯源展示/重生成沿用)
111+
pub custom_text: String,
103112
}
104113

105114
/// 解析:任务覆盖 > 全局默认 > 内置 standard;非法值回退声明默认。
106115
///
107116
/// @ai-context: 防御性边界:未知档位 → 回退 standard 基准;未知维度 key →
108117
/// 丢弃;非法档值 → 该维回退声明 default。返回的 ResolvedDims
109118
/// 永远只含声明维度(每维必有值)。
119+
/// @ai-context: REQ-279 净化规则(所见即所发 + 标准恒纯净):
120+
/// ① 任务显式给出档位时(含 standard/custom)一律不叠加全局逐维
121+
/// 覆盖——草稿已把用户可见的全部维度值经 dims 传入,后端不得
122+
/// 再隐式叠加任何残留;
123+
/// ② 未给显式档位(走全局默认)时仅当 default_ladder 显式非空
124+
/// 才折叠偏好覆盖——旧版意图基准污染形状(default_ladder=""
125+
/// + 全维 overrides)被跳过;
126+
/// ③ custom 档无文本 → 整档回退 standard(零变化保证不破裂)。
110127
pub fn resolve(
111128
decl: &NoteRefinePrompt,
112129
global: &RefineStrategyPrefs,
113130
over: Option<&StrategyOverride>,
114131
) -> ResolvedDims {
132+
// 自定义文本先取先净化(首 500 字符——防膨胀)
133+
let custom_text: String = over
134+
.and_then(|o| o.custom_text.as_deref())
135+
.unwrap_or("")
136+
.trim()
137+
.chars()
138+
.take(MAX_CUSTOM_TEXT_CHARS)
139+
.collect();
115140
// ① 基准档位:任务 preset > 全局默认 > "standard"
116141
let base_id = over
117142
.and_then(|o| o.preset_id.as_ref().filter(|s| !s.is_empty()).cloned())
@@ -120,22 +145,43 @@ pub fn resolve(
120145
if s.is_empty() { None } else { Some(s.to_string()) }
121146
})
122147
.unwrap_or_else(|| "standard".to_string());
123-
let base = decl.ladder_presets.iter().find(|p| p.id == base_id);
124-
let (preset_id, mut dims, instruction) = match base {
148+
// custom 无文本 = 无效档(前端守卫 + 后端兜底统一口径:回退标准)
149+
let effective_id =
150+
if base_id == "custom" && custom_text.is_empty() { "standard" } else { base_id.as_str() };
151+
let base = decl.ladder_presets.iter().find(|p| p.id == effective_id);
152+
let (preset_id, mut dims, mut instruction) = match base {
125153
Some(p) => (p.id.clone(), p.dim_values.clone(), p.instruction.clone()),
126154
// 未知档位回退标准(零变化基准——不阻断)
127155
None => ("standard".to_string(), HashMap::new(), String::new()),
128156
};
129-
// ② 全局逐维覆盖 ③ 任务级逐维覆盖(任务优先)
130-
for (k, v) in &global.dim_overrides {
131-
dims.insert(k.clone(), v.clone());
157+
// ② 全局逐维覆盖:仅「无任务显式档位 + 全局默认档显式非空」时折叠——
158+
// 任务显式档位一律不叠(所见即所发);""+overrides = 旧版意图基准
159+
// 污染形状(legacy),跳过(REQ-279 标准恒纯净后端兜底)
160+
let has_explicit_task_preset = over
161+
.and_then(|o| o.preset_id.as_ref().filter(|s| !s.is_empty()))
162+
.is_some();
163+
if !has_explicit_task_preset && !global.default_ladder.trim().is_empty() {
164+
for (k, v) in &global.dim_overrides {
165+
dims.insert(k.clone(), v.clone());
166+
}
132167
}
168+
// ③ 任务级逐维覆盖(任务优先)
133169
if let Some(o) = over {
134170
for (k, v) in &o.dims {
135171
dims.insert(k.clone(), v.clone());
136172
}
137173
}
138-
ResolvedDims { preset_id, instruction, dims: validate_dims(decl, &dims) }
174+
// custom 档:自由文本落为档级指令(dimValues 为空——无叠加维度指令)
175+
if preset_id == "custom" {
176+
instruction = format!("本次为自定义档:{}", custom_text);
177+
}
178+
let custom_text_owned = if preset_id == "custom" { custom_text } else { String::new() };
179+
ResolvedDims {
180+
preset_id,
181+
instruction,
182+
dims: validate_dims(decl, &dims),
183+
custom_text: custom_text_owned,
184+
}
139185
}
140186

141187
/// 维度值干净化:仅保留声明维度;非法值/缺省 → 声明 default。

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

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ fn deep_preset_injects_strategy_section() {
4242
let dims = resolve(
4343
&p,
4444
&RefineStrategyPrefs::default(),
45-
Some(&StrategyOverride { preset_id: Some("deep".to_string()), dims: HashMap::new() }),
45+
Some(&StrategyOverride { preset_id: Some("deep".to_string()), dims: HashMap::new(), custom_text: None }),
4646
);
4747
let s = strategy_instructions(&dims, &p);
4848
assert!(s.contains("深度改写"), "档级指令缺失: {}", s);
@@ -62,7 +62,7 @@ fn unknown_preset_falls_back_to_standard() {
6262
let dims = resolve(
6363
&p,
6464
&RefineStrategyPrefs::default(),
65-
Some(&StrategyOverride { preset_id: Some("no-such".to_string()), dims: HashMap::new() }),
65+
Some(&StrategyOverride { preset_id: Some("no-such".to_string()), dims: HashMap::new(), custom_text: None }),
6666
);
6767
assert_eq!(dims.preset_id, "standard");
6868
assert!(strategy_instructions(&dims, &p).is_empty());
@@ -77,6 +77,7 @@ fn invalid_dim_value_falls_back_to_declared_default() {
7777
Some(&StrategyOverride {
7878
preset_id: None,
7979
dims: dims_map(&[("examples", "bogus"), ("concept", "plain")]),
80+
custom_text: None,
8081
}),
8182
);
8283
// bogus → 声明默认 standard;plain 合法保留
@@ -93,12 +94,13 @@ fn global_default_then_task_override_wins() {
9394
assert_eq!(d1.preset_id, "minimal");
9495
assert_eq!(d1.dims.get("examples").map(|s| s.as_str()), Some("condensed"));
9596
// 任务覆盖档位:深度覆盖极简
96-
let d2 = resolve(&p, &prefs, Some(&StrategyOverride { preset_id: Some("deep".to_string()), dims: HashMap::new() }));
97+
let d2 = resolve(&p, &prefs, Some(&StrategyOverride { preset_id: Some("deep".to_string()), dims: HashMap::new(), custom_text: None }));
9798
assert_eq!(d2.preset_id, "deep");
9899
// 单维覆盖只改目标维
99100
let d3 = resolve(&p, &prefs, Some(&StrategyOverride {
100101
preset_id: None,
101102
dims: dims_map(&[("emotion", "keep")]),
103+
custom_text: None,
102104
}));
103105
assert_eq!(d3.preset_id, "minimal");
104106
assert_eq!(d3.dims.get("emotion").map(|s| s.as_str()), Some("keep"));
@@ -119,7 +121,7 @@ fn intent_meta_breaks_into_chips_and_keywords() {
119121
fn preview_system_contains_strategy_and_matches_real_build() {
120122
let p = NoteRefinePrompt::bundled();
121123
let prefs = RefineStrategyPrefs { default_ladder: "deep".to_string(), dim_overrides: HashMap::new() };
122-
let over = StrategyOverride { preset_id: None, dims: dims_map(&[("examples", "condensed")]) };
124+
let over = StrategyOverride { preset_id: None, dims: dims_map(&[("examples", "condensed")]), custom_text: None };
123125
let preview = preview_system(&p, "lecture", &prefs, Some(&over));
124126
let dims = resolve(&p, &prefs, Some(&over));
125127
// 预览与实发同一 build_system 路径(逐字节一致)
@@ -142,5 +144,95 @@ fn meta_serializes_declaration_with_all_intents() {
142144
for want in ["原文保真", "考点浓缩", "通俗转述", "速查纲要", "金句摘录"] {
143145
assert!(labels.contains(&want), "chips 书面命名缺失: {}(实得 {:?})", want, labels);
144146
}
145-
assert_eq!(meta.ladder_presets.len(), 4, "四档阶梯应齐全");
147+
assert_eq!(meta.ladder_presets.len(), 5, "五档阶梯应齐全(REQ-279 新增自定义)");
148+
assert!(meta.ladder_presets.iter().any(|l| l.id == "custom"), "custom 档应声明");
149+
}
150+
151+
#[test]
152+
fn standard_ignores_legacy_global_overrides() {
153+
// REQ-279 污染通道③后端兜底:standard 档不受全局逐维覆盖影响——
154+
// 旧版 prefsFromDraft 曾把意图基准全维写入 dim_overrides,叠加后「标准」
155+
// 语义漂移;后端对 standard 恒不叠加(与零覆盖解析逐字段等价)。
156+
let p = NoteRefinePrompt::bundled();
157+
let prefs = RefineStrategyPrefs {
158+
default_ladder: String::new(),
159+
dim_overrides: dims_map(&[("concept", "plain"), ("conclusion", "summary_top")]),
160+
};
161+
let dirty = resolve(&p, &prefs, None);
162+
let clean = resolve(&p, &RefineStrategyPrefs::default(), None);
163+
assert_eq!(dirty.preset_id, "standard");
164+
assert_eq!(dirty, clean, "standard 必须与零覆盖逐字段等价(含指令与每维值)");
165+
assert!(strategy_instructions(&dirty, &p).is_empty());
166+
// 对照:显式阶梯档(deep)仍允许全局覆盖折叠(设置页默认偏好的语义保留)
167+
let ladder_prefs = RefineStrategyPrefs {
168+
default_ladder: "deep".to_string(),
169+
dim_overrides: dims_map(&[("examples", "condensed")]),
170+
};
171+
let dl = resolve(&p, &ladder_prefs, None);
172+
assert_eq!(dl.preset_id, "deep");
173+
assert_eq!(dl.dims.get("examples").map(|s| s.as_str()), Some("condensed"));
174+
}
175+
176+
#[test]
177+
fn custom_preset_free_text_becomes_instruction_and_surfaces_in_info() {
178+
// REQ-279:自定义档自由文本 → 档级指令(预览/实发同路径可见);无维度注入
179+
let p = NoteRefinePrompt::bundled();
180+
let dims = resolve(
181+
&p,
182+
&RefineStrategyPrefs::default(),
183+
Some(&StrategyOverride {
184+
preset_id: Some("custom".to_string()),
185+
dims: HashMap::new(),
186+
custom_text: Some("写得更口语一些,多举一个生活化例子。".to_string()),
187+
}),
188+
);
189+
assert_eq!(dims.preset_id, "custom");
190+
assert_eq!(dims.custom_text, "写得更口语一些,多举一个生活化例子。");
191+
let s = strategy_instructions(&dims, &p);
192+
assert!(s.contains("本次为自定义档"), "档级指令缺失: {}", s);
193+
assert!(s.contains("生活化例子"), "自由文本未进指令: {}", s);
194+
// 每维保持声明默认 → 无维度指令注入(纯文本档)
195+
assert_eq!(dims.dims.get("conclusion").map(|s| s.as_str()), Some("as_is"));
196+
}
197+
198+
#[test]
199+
fn custom_preset_empty_text_falls_back_to_standard() {
200+
// 前端守卫 + 后端兜底统一口径:custom 无文本 = 无效 → standard(零变化)
201+
let p = NoteRefinePrompt::bundled();
202+
let dims = resolve(
203+
&p,
204+
&RefineStrategyPrefs::default(),
205+
Some(&StrategyOverride {
206+
preset_id: Some("custom".to_string()),
207+
dims: HashMap::new(),
208+
custom_text: Some(" ".to_string()),
209+
}),
210+
);
211+
assert_eq!(dims.preset_id, "standard");
212+
assert!(dims.custom_text.is_empty());
213+
assert!(strategy_instructions(&dims, &p).is_empty());
214+
}
215+
216+
#[test]
217+
fn custom_preset_ignores_global_overrides_and_caps_text_length() {
218+
// 净化:custom 与 standard 同属「用户显式文本」档——全局残留不得叠加;
219+
// 自由文本 500 字符截断(防提示词膨胀)
220+
let p = NoteRefinePrompt::bundled();
221+
let long: String = "a".repeat(1200);
222+
let prefs = RefineStrategyPrefs {
223+
default_ladder: String::new(),
224+
dim_overrides: dims_map(&[("concept", "plain")]),
225+
};
226+
let dims = resolve(
227+
&p,
228+
&prefs,
229+
Some(&StrategyOverride {
230+
preset_id: Some("custom".to_string()),
231+
dims: HashMap::new(),
232+
custom_text: Some(long),
233+
}),
234+
);
235+
assert_eq!(dims.preset_id, "custom");
236+
assert_eq!(dims.custom_text.chars().count(), crate::ai_strategy::MAX_CUSTOM_TEXT_CHARS);
237+
assert_eq!(dims.dims.get("concept").map(|s| s.as_str()), Some("original"), "全局覆盖不得进 custom 档");
146238
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,14 @@ pub struct AiRefineResult {
7474
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
7575
#[serde(rename_all = "camelCase")]
7676
pub struct RefineStrategyInfo {
77-
/// 档位 id(standard/faithful/deep/minimal 或 intent:xxx——名称由前端
77+
/// 档位 id(standard/faithful/deep/minimal/custom 或 intent:xxx——名称由前端
7878
/// 按 meta 声明解析,未知 id 原样展示——诚实不猜)
7979
pub preset_id: String,
8080
/// 每维最终值(key → value;chips 渲染源)
8181
pub dims: std::collections::HashMap<String, String>,
82+
/// 自定义档自由文本(仅 preset=custom 时有值——溯源条/重生成沿用,REQ-279)
83+
#[serde(default, skip_serializing_if = "Option::is_none")]
84+
pub custom_text: Option<String>,
8285
}
8386

8487
/// 任务句柄(前端轮询/事件对应用)。

‎app/src/components/AiServicePanel.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ export default function AiServicePanel() {
212212
meta={meta}
213213
value={strategyDraft ?? { presetId: "", dims: {} }}
214214
onChange={setStrategyDraft}
215+
allowCustom={false}
216+
showIntents={false}
215217
/>
216218
{msg?.kind === "ok" && <div style={{ color: "#0d9488", marginTop: 6 }}>{msg.text}</div>}
217219
</div>

‎app/src/components/RefineLaunchDialog.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1313
import { invoke } from "@tauri-apps/api/core";
1414
import type { AiSettingsView, BalanceView, RefineEstimateView, RefineStrategyMeta } from "../types";
1515
import {
16-
draftFromPrefs, loadDraft, saveDraft, toOverride, type StrategyDraft,
16+
draftFromPrefs, loadDraft, sanitizeDraft, saveDraft, toOverride, type StrategyDraft,
1717
} from "../utils/refineStrategy";
1818
import RefineStrategyPicker from "./RefineStrategyPicker";
1919

@@ -59,8 +59,9 @@ export default function RefineLaunchDialog({
5959
setSettings(st);
6060
setMeta(mt);
6161
if (mt) {
62-
// 记忆恢复优先,其次设置全局默认(draftFromPrefs 覆盖 standard 兜底)
63-
const saved = loadDraft();
62+
// 记忆恢复优先(REQ-279 净化:拒绝旧版污染的 standard 残留/空自定义),
63+
// 其次设置全局默认(draftFromPrefs 覆盖 standard 兜底)
64+
const saved = sanitizeDraft(loadDraft(), mt);
6465
setDraft(saved ?? draftFromPrefs(st?.refineStrategy, mt));
6566
}
6667
setEstimate(
@@ -99,6 +100,11 @@ export default function RefineLaunchDialog({
99100
/** 确认启动(策略随任务级覆盖传入;会话级/笔记级双命令) */
100101
const confirm = async () => {
101102
if (!draft) return;
103+
// REQ-279 前端守卫:自定义档空文本 = 无效(后端会按标准精修兜底——先诚实提示)
104+
if (draft.presetId === "custom" && !(draft.customText ?? "").trim()) {
105+
setMsg("自定义档需先写下具体处理要求(或改选其它档位)");
106+
return;
107+
}
102108
setMsg("");
103109
setStarting(true);
104110
if (remember) {

0 commit comments

Comments
 (0)