Skip to content

Commit 897ee96

Browse files
committed
fix(ai): 精修丢图修复——配图行缺括号 bug + 本地合并降级回填 (F1-A2)
1 parent 1845462 commit 897ee96

5 files changed

Lines changed: 186 additions & 1 deletion

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,10 @@ fn run_refine_task_inner(
388388
);
389389
}
390390
// ④ 合并 + 与规则版 diff(基线=本地版,AI 变化点高亮)
391+
// 丢图修复(2026-08-21 F1):协议 v2 前,模型可能丢弃规则版画面配图行
392+
// (`- ![画面 N](session-images/..)`)——本地合并降级:AI 未保留配图时
393+
// 把规则版配图行按章节合并回精修版(不丢不假,零模型成本)
394+
let refined = crate::note_image_merge::merge_rule_images(&draft.markdown, &refined);
391395
let diff = diff_markdown(&draft.markdown, &refined);
392396
let (added, removed, _) = diff_stats(&diff);
393397
Ok(AiRefineResult {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ mod model_registry;
164164
mod note_filter;
165165
mod note_filter_ai;
166166
mod note_filter_discourse;
167+
// v0.8.0 F1(REQ-141 丢图修复):精修版配图本地合并降级(协议 v2 image 块兜底)
168+
mod note_image_merge;
167169
mod novelty;
168170
mod ocr;
169171
mod ocr_correction;

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,10 @@ pub fn render_screen_points(screens: &[SessionScreen]) -> Vec<String> {
445445
lines.push(format!(" - 标签:{}", s.labels.join(" · ")));
446446
}
447447
if let Some(rel) = &s.image_ref {
448-
lines.push(format!(" - ![画面 {}](session-images/{}/{}", i + 1, s.session_id, rel));
448+
// 修复(2026-08-21 审查):缺闭合 `)`——前端渲染正则
449+
// `^\s*-\s*!\[([^\]]*)\]\(([^)]*)\)$` 要求 `)$` 结尾,缺括号时
450+
// 配图行永远匹配不上 → 渲染为纯文本而非图片(丢图真因之一)
451+
lines.push(format!(" - ![画面 {}](session-images/{}/{})", i + 1, s.session_id, rel));
449452
}
450453
}
451454
lines
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! 精修版配图本地合并降级(REQ-141 丢图修复,2026-08-21 F1)。
2+
//!
3+
//! @ai-context: 规则版 markdown 含画面要点配图行(`- ![画面 N](session-images/..)`,
4+
//! 由 note_filter::render_screen_points 生成);AI 精修协议
5+
//! v1 无 image 块类型,模型可能丢弃配图行 → 精修版丢图。
6+
//! 本模块为降级路径:AI 未保留配图时,把规则版配图行按章节
7+
//! 合并回精修版(零模型成本,不丢不假)。协议 v2(F3)AI
8+
//! 返回 image 块后,本路径仅在 AI 未返回时兜底。
9+
//! @ai-context: 纯函数可单测;配图行识别用宽松模式(行含 session-images/ 且
10+
//! 匹配 `- ![` 前缀——与前端渲染正则同口径,兼容缩进变体)。
11+
12+
/// 配图行前缀(规则版生成格式;前端渲染正则 `^\s*-\s*!\[` 同口径)。
13+
const IMG_MARK: &str = "session-images/";
14+
15+
/// 从 markdown 提取配图行(保留原行文本;空输入 → 空向量)。
16+
///
17+
/// @ai-context: 只认列表项形态的配图行(`- ![` 前缀 + session-images/ 引用);
18+
/// 其他形态(正文内嵌图等)不在规则版生成范围内,不处理。
19+
pub fn extract_image_lines(markdown: &str) -> Vec<String> {
20+
markdown
21+
.lines()
22+
.filter(|l| {
23+
let t = l.trim_start();
24+
t.starts_with("- ![") && l.contains(IMG_MARK)
25+
})
26+
.map(|l| l.to_string())
27+
.collect()
28+
}
29+
30+
/// 精修版是否已含配图行(AI 已保留/生成 → 本地合并跳过,不干预 AI 输出)。
31+
pub fn has_image_lines(markdown: &str) -> bool {
32+
markdown.lines().any(|l| l.contains(IMG_MARK))
33+
}
34+
35+
/// 把规则版配图行合并回精修版(降级路径总入口)。
36+
///
37+
/// @ai-context: 规则:① 规则版无配图 → 原样返回精修版;② 精修版已有配图
38+
/// (AI 保留或 image 块渲染)→ 原样返回(AI 处理优先);
39+
/// ③ 否则把规则版配图行作为「画面要点」章节追加——
40+
/// 精修版已有 `## 画面要点` 章节则插入其标题之后,
41+
/// 无则文末追加新章节(配图是课程内容,精修=整理不创作
42+
/// 语义下必须保留)。
43+
pub fn merge_rule_images(base_markdown: &str, refined_markdown: &str) -> String {
44+
let imgs = extract_image_lines(base_markdown);
45+
if imgs.is_empty() || has_image_lines(refined_markdown) {
46+
return refined_markdown.to_string();
47+
}
48+
let section = format!("## 画面要点\n\n{}\n", imgs.join("\n"));
49+
// 精修版已有画面要点章节 → 插入标题行后(保持章节内聚)
50+
let heading_pos = refined_markdown
51+
.lines()
52+
.position(|l| {
53+
let t = l.trim_end();
54+
t == "## 画面要点" || t == "## 画面要点 "
55+
});
56+
match heading_pos {
57+
Some(idx) => {
58+
let mut out = String::new();
59+
for (i, line) in refined_markdown.lines().enumerate() {
60+
out.push_str(line);
61+
out.push('\n');
62+
if i == idx {
63+
out.push('\n');
64+
out.push_str(&section);
65+
}
66+
}
67+
out
68+
}
69+
None => {
70+
let trimmed = refined_markdown.trim_end();
71+
format!("{}\n\n{}\n", trimmed, section)
72+
}
73+
}
74+
}
75+
76+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
77+
#[cfg(test)]
78+
#[path = "note_image_merge_tests.rs"]
79+
mod tests;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! note_image_merge.rs 单测(AAA 模式)。
2+
//!
3+
//! @ai-context: 覆盖:提取(宽松匹配/空输入/非配图行排除)、精修版已含图
4+
//! 跳过、已有画面要点章节插入、无章节文末追加、降级不动输入。
5+
6+
use crate::note_image_merge::{extract_image_lines, has_image_lines, merge_rule_images};
7+
8+
const RULE_MD: &str = "# 课程标题\n\n正文内容\n\n## 画面要点\n\n- **画面 1**\n - ![画面 1](session-images/5/full/30000.webp)\n";
9+
10+
#[test]
11+
fn extract_picks_list_item_image_lines() {
12+
// Act
13+
let imgs = extract_image_lines(RULE_MD);
14+
// Assert:只提取配图行(画面要点标题/正文行不含 session-images/ 不提取)
15+
assert_eq!(imgs.len(), 1);
16+
assert!(imgs[0].contains("session-images/5/full/30000.webp"));
17+
}
18+
19+
#[test]
20+
fn extract_empty_input_returns_empty() {
21+
assert!(extract_image_lines("").is_empty());
22+
assert!(extract_image_lines(" \n ").is_empty());
23+
}
24+
25+
#[test]
26+
fn extract_excludes_non_list_image_forms() {
27+
// 正文内嵌 markdown 图(非列表项)不在规则版生成范围——不提取
28+
let md = "正文 ![内嵌](session-images/5/full/a.webp) 继续\n- ![画面 1](session-images/5/full/b.webp)";
29+
let imgs = extract_image_lines(md);
30+
assert_eq!(imgs.len(), 1);
31+
assert!(imgs[0].contains("b.webp"));
32+
}
33+
34+
#[test]
35+
fn merge_skips_when_rule_has_no_images() {
36+
// Arrange:规则版无配图
37+
let base = "# 标题\n\n正文";
38+
let refined = "## 精修\n\n内容";
39+
// Act
40+
let out = merge_rule_images(base, refined);
41+
// Assert:原样返回精修版
42+
assert_eq!(out, refined);
43+
}
44+
45+
#[test]
46+
fn merge_skips_when_refined_already_has_images() {
47+
// Arrange:精修版已有配图(AI 保留/image 块渲染)→ 不干预
48+
let base = "# 标题\n\n## 画面要点\n\n- ![画面 1](session-images/5/full/30000.webp)";
49+
let refined = "## 精修\n\n内容\n\n- ![画面 1](session-images/5/full/30000.webp)";
50+
// Act
51+
let out = merge_rule_images(base, refined);
52+
// Assert:原样(不重复追加)
53+
assert_eq!(out, refined);
54+
}
55+
56+
#[test]
57+
fn merge_appends_new_section_when_refined_has_no_heading() {
58+
// Arrange:精修版无画面要点章节
59+
let refined = "## 精修\n\n正文内容";
60+
// Act
61+
let out = merge_rule_images(RULE_MD, refined);
62+
// Assert:文末追加画面要点章节 + 配图行
63+
assert!(out.contains("## 画面要点"));
64+
assert!(out.contains("session-images/5/full/30000.webp"));
65+
// 配图行在文末(追加语义——不破坏精修结构)
66+
assert!(out.trim_end().ends_with("30000.webp)"));
67+
}
68+
69+
#[test]
70+
fn merge_inserts_after_existing_heading() {
71+
// Arrange:精修版已有画面要点章节(空——AI 没保留配图)
72+
let refined = "## 精修\n\n正文\n\n## 画面要点\n";
73+
// Act
74+
let out = merge_rule_images(RULE_MD, refined);
75+
// Assert:配图行插入画面要点标题之后
76+
let pos_title = out.find("## 画面要点").unwrap();
77+
let pos_img = out.find("session-images/5/full/30000.webp").unwrap();
78+
assert!(pos_img > pos_title, "配图行应在画面要点标题之后");
79+
}
80+
81+
#[test]
82+
fn merge_does_not_mutate_inputs() {
83+
// Arrange
84+
let base = RULE_MD.to_string();
85+
let refined = "## 精修\n\n正文".to_string();
86+
// Act
87+
let _ = merge_rule_images(&base, &refined);
88+
// Assert:输入不变(纯函数)
89+
assert!(base.contains("session-images/5/full/30000.webp"));
90+
assert_eq!(refined, "## 精修\n\n正文");
91+
}
92+
93+
#[test]
94+
fn has_images_detects_any_session_images_ref() {
95+
assert!(has_image_lines("- ![画面 1](session-images/5/full/a.webp)"));
96+
assert!(!has_image_lines("纯文本无图"));
97+
}

0 commit comments

Comments
 (0)