Skip to content

Commit 6fd4d1e

Browse files
committed
fix(ai): 修复精修工作台采纳前右侧恒空与精修线路优化
refine_workbench 仅按已落库笔记取精修版导致采纳前右侧恒空(且事件先于 DB 落库存在竞态)。修复:三级数据源(refineResult 参数 > 未采纳成功任务 > 已落库笔记)+ Db::find_latest_unadopted_refine 持久化兜底;线路优化:完成即自动打开工作台、采纳落库回传 taskId(标记 adopted+成本回填,堵重复采纳)、重新生成接入父级管线。新增 Rust 2 项 + 前端 4 项回归测试;v0.12.5 版本文档/CHANGELOG/版本号。
1 parent 58882e0 commit 6fd4d1e

13 files changed

Lines changed: 355 additions & 27 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55

66
## [Unreleased] - 2026-08-23
77

8+
### v0.12.5 AI 精修线路修复:工作台采纳前右侧恒空 + 精修流程优化(2026-08-23 交付,详见 [docs/versions/v0.12.5.md](docs/versions/v0.12.5.md))
9+
10+
- **P0 采纳前工作台右侧恒空(用户报告)**:精修成功 → 「打开工作台」→ 左侧规则版有内容、右侧精修版空("尚未精修"占位)。根因:`refine_workbench` 的精修版只按**已落库笔记**取(`find_note_by_session`),采纳前笔记不存在 → `None`;前端持有的 `AiRefineResult` 未进入数据源;且任务成功事件先于 DB 落库(竞态)。修复:`refine_workbench` 新增可选 `refine_result` 参数(调用方内存结果,消除竞态)→ 新增 `Db::find_latest_unadopted_refine`(`ai_tasks` 未采纳成功任务按会话取最新——重启后仍可回显)→ 已落库笔记最新版本,三级数据源;前端 `RefineWorkbench` 携带 `taskResult` 时回传 `refineResult`;采纳前成本不虚假回填
11+
- **线路优化:完成即自动打开工作台**:任务 Succeeded → 直开工作台(工作台内含 采纳落库/放弃/重新生成 三出口,消除"打开工作台/放弃"中间步);关闭后卡片保留重开按钮
12+
- **P1 采纳落库回传真实 taskId**:原来恒传 `null` → 任务不标记 adopted、成本不回填 → 重启后任务中心仍可恢复该结果并再次采纳(**重复建笔记风险**);现回传并服务端标记 + 成本回填(与任务中心采纳路径收敛)
13+
- **P1 重新生成接入父级管线**:工作台 `onRegenerate` 复用卡片 start(running 态 + 轮询/事件 + 卡住检测);原实现直连 `ai_refine_start` → 卡片停留旧结果 done 态、新任务无进度(状态残留)
14+
- 验证:Rust 新增 2 项单测(find_latest_unadopted_refine_by_session / stats_from 统计口径)全量通过(既有基线失败项与本版无关);`tsc --noEmit` 零错误 / vitest 全绿(新增 `RefineWorkbench.test.tsx` 4 项:refineResult 契约 / null 兜底 / 采纳 taskId / null 占位不崩)
15+
816
### v0.12.4 视频会话画面要点纯图落地 + 笔记预览时间戳锚点 chip 化(2026-08-23 交付,详见 [docs/versions/v0.12.4.md](docs/versions/v0.12.4.md))
917

1018
- **P0 视频画面要点残留 OCR 文字(用户 0.12.1 复测)**:v0.12.0 M5(ADR-023)帧侧只删了全帧 OCR 兜底与明文路径,残留两条仍产 OCR 文字的分支——直播路径 `!is_subtitle` 布局分析后仍调 `region_ocr_blocks`(`region=full` 入库),导入路径关键帧不归档图片 + 中区域 OCR。修复:直播 `else` 纯关键帧归档(零 OCR);导入关键帧无条件纯图归档(`rgb_to_bgra`),仅字幕 OCR 门控保留,删中区域 OCR;展示层 `build_view_screens` 按 kind 分派(photo 保留 ADR-021 OCR 文本屏不变,video 走 `build_keyframe_screens` 纯图屏);`get_session_detail`/`preview_session_note`/`manual_capture_inner` 三处接入

‎app/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "app",
33
"private": true,
4-
"version": "0.12.4",
4+
"version": "0.12.5",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

‎app/src-tauri/Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/src-tauri/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "entropydecrease"
3-
version = "0.12.4"
3+
version = "0.12.5"
44
description = "熵减 - 视频知识提取与持久化(本地优先)"
55
authors = ["Aparencia"]
66
edition = "2021"

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

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -371,12 +371,18 @@ pub struct WorkbenchData {
371371

372372
/// 工作台数据接口:规则草稿 + 精修结果 + 章节分组 diff 一次取全。
373373
///
374-
/// refined_markdown 为 None → 尚未精修;否则包含最新笔记精修版内容
375-
/// 与章节 diff。
374+
/// refined_markdown 为 None → 尚未精修;否则包含最新精修版内容与章节 diff。
375+
///
376+
/// @ai-context: 精修版三级数据源(修复:原实现只查已落库笔记——采纳前打开
377+
/// 工作台右侧恒空;且任务成功事件先于 DB 落库,存在竞态):
378+
/// ① refine_result 参数(调用方内存结果——采纳前刚完成的任务,
379+
/// 消除竞态)② 最新未采纳成功任务(DB 持久化——重启后恢复)
380+
/// ③ 已落库笔记最新版本(采纳后)。
376381
#[tauri::command]
377382
pub async fn refine_workbench(
378383
state: State<'_, AppState>,
379384
session_id: i64,
385+
refine_result: Option<AiRefineResult>,
380386
) -> Result<WorkbenchData, String> {
381387
if session_id <= 0 {
382388
return Err("无效的会话 id".to_string());
@@ -398,25 +404,42 @@ pub async fn refine_workbench(
398404
.map_err(|e| e.to_string())?;
399405
let rule_md = strip_anchors(&draft.markdown);
400406

401-
// ② 查已落库精修笔记(有无精修结果)
407+
// ② 精修版数据源:内存结果(优先)> 未采纳成功任务(DB 兜底)> 已落库笔记
408+
let unadopted = state
409+
.db
410+
.find_latest_unadopted_refine(session_id)
411+
.map_err(|e| e.to_string())?;
412+
let pending_result: Option<AiRefineResult> = match (refine_result, unadopted) {
413+
(Some(r), _) => Some(r),
414+
// 解析失败降级(日志可观测——不影响工作台打开,可经任务中心重取)
415+
(None, Some(task)) => task.result_json.as_deref().and_then(|j| {
416+
serde_json::from_str::<AiRefineResult>(j)
417+
.map_err(|e| eprintln!("[refine-workbench] 未采纳任务 {} 结果解析失败: {}", task.task_id, e))
418+
.ok()
419+
}),
420+
(None, None) => None,
421+
};
422+
402423
let note = state.db.find_note_by_session(session_id).map_err(|e| e.to_string())?;
403-
let (refined_md, sections, stats, meta) = if let Some(ref note) = note {
424+
let (refined_md, sections, stats, meta) = if let Some(result) = pending_result {
425+
let secs = diff_sections(&rule_md, &result.refined_markdown);
426+
let st = stats_from(&secs);
427+
// 未落库:成本按模型单价在 apply 时核算——此处不做虚假回填
428+
let m = VersionMeta {
429+
cost_yuan: None,
430+
model: Some(result.model.clone()),
431+
slices: Some(result.slices),
432+
merged_from: None,
433+
};
434+
(Some(result.refined_markdown), secs, st, Some(m))
435+
} else if let Some(ref note) = note {
404436
// 取最新版本内容作为精修版
405437
let versions = state.db.list_versions(note.id).map_err(|e| e.to_string())?;
406438
let latest = versions.last()
407439
.map(|v| v.content.clone())
408440
.unwrap_or_else(|| note.content.clone());
409441
let secs = diff_sections(&rule_md, &latest);
410-
let total_added: usize = secs.iter().map(|s| s.added_lines.len()).sum();
411-
let total_removed: usize = secs.iter().map(|s| s.removed_lines.len()).sum();
412-
let total_unchanged: usize = secs.iter()
413-
.filter(|s| s.status == crate::note_diff::DiffStatus::Unchanged)
414-
.count();
415-
let st = DiffStats {
416-
added: total_added,
417-
removed: total_removed,
418-
unchanged: total_unchanged,
419-
};
442+
let st = stats_from(&secs);
420443
// 取最新版本 meta
421444
let m = versions.last().map(|v| VersionMeta {
422445
cost_yuan: v.meta.cost_yuan,
@@ -438,6 +461,17 @@ pub async fn refine_workbench(
438461
})
439462
}
440463

464+
/// 章节分组 diff 统计(新增/删除/未变行数——与 diff_sections 同口径)。
465+
fn stats_from(secs: &[SectionDiff]) -> DiffStats {
466+
DiffStats {
467+
added: secs.iter().map(|s| s.added_lines.len()).sum(),
468+
removed: secs.iter().map(|s| s.removed_lines.len()).sum(),
469+
unchanged: secs.iter()
470+
.filter(|s| s.status == crate::note_diff::DiffStatus::Unchanged)
471+
.count(),
472+
}
473+
}
474+
441475
/// 更新任务状态并推送事件(短锁内完成即释放)。M3 补充任务复用(pub(crate))。
442476
pub(crate) fn set_task(st: &AppState, task_id: u64, new_state: AiTaskState) {
443477
if let Ok(mut tasks) = st.ai_tasks.lock() {
@@ -570,4 +604,20 @@ mod tests {
570604
assert!(sec.get("added_lines").is_some());
571605
assert_eq!(sec["status"], "modified");
572606
}
607+
608+
/// stats_from:按 diff_sections 分组行数统计(工作台头部 新增/删除/未变 徽标)。
609+
#[test]
610+
fn stats_from_aggregates_section_lines() {
611+
use crate::note_diff::{DiffStatus, SectionDiff};
612+
let secs = vec![
613+
SectionDiff { heading: "A".into(), status: DiffStatus::Modified, removed_lines: vec!["a".into()], added_lines: vec!["A".into(), "B".into()] },
614+
SectionDiff { heading: "B".into(), status: DiffStatus::Unchanged, removed_lines: vec![], added_lines: vec![] },
615+
SectionDiff { heading: "C".into(), status: DiffStatus::Added, removed_lines: vec![], added_lines: vec!["c".into()] },
616+
SectionDiff { heading: "D".into(), status: DiffStatus::Removed, removed_lines: vec!["d".into()], added_lines: vec![] },
617+
];
618+
let s = stats_from(&secs);
619+
assert_eq!(s.added, 3);
620+
assert_eq!(s.removed, 2);
621+
assert_eq!(s.unchanged, 1);
622+
}
573623
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,29 @@ impl Db {
165165
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(|e| e.into())
166166
}
167167

168+
/// 会话最新未采纳的成功精修任务(精修工作台数据源——采纳前/重启后回显)。
169+
///
170+
/// @ai-context: 精修结果在任务成功时已落库(finish_ai_task),但采纳前
171+
/// 笔记未建——refine_workbench 原只查已采纳笔记导致工作台
172+
/// 右侧恒空;本方法取回那条"待决策"结果(重启后同样可用,
173+
/// 与 list_restorable_succeeded 同数据面,粒度按会话)。
174+
pub fn find_latest_unadopted_refine(&self, ref_id: i64) -> Result<Option<AiTaskRecord>> {
175+
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
176+
let mut stmt = conn.prepare(
177+
"SELECT task_id, op_type, ref_id, state, result_json, cost_yuan, elapsed_ms,
178+
model, error, slices, created_at, finished_at, adopted
179+
FROM ai_tasks
180+
WHERE op_type='refine' AND ref_id=?1 AND state='succeeded'
181+
AND adopted=0 AND result_json IS NOT NULL
182+
ORDER BY created_at DESC LIMIT 1",
183+
)?;
184+
let mut rows = stmt.query_map(params![ref_id], map_record)?;
185+
match rows.next() {
186+
Some(r) => Ok(Some(r?)),
187+
None => Ok(None),
188+
}
189+
}
190+
168191
/// 任务历史(任务面板数据源;op_type 过滤 + 时间倒序 + 上限)。
169192
pub fn list_ai_tasks(&self, op_type: &str, limit: usize) -> Result<Vec<AiTaskRecord>> {
170193
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,22 @@ fn cost_backfill_updates_record() {
121121
assert_eq!(list[0].cost_yuan, Some(0.5));
122122
}
123123

124+
#[test]
125+
fn find_latest_unadopted_refine_by_session() {
126+
let db = open_mem();
127+
// Arrange:同会话两条成功任务(旧+新)+ 他会话已采纳任务 + 他类任务
128+
db.insert_ai_task(&rec(10, "refine", 5, "succeeded")).unwrap();
129+
db.insert_ai_task(&rec(20, "refine", 5, "succeeded")).unwrap();
130+
db.insert_ai_task(&rec(30, "refine", 6, "succeeded")).unwrap();
131+
db.mark_ai_task_adopted(30).unwrap();
132+
db.insert_ai_task(&rec(40, "enrich", 5, "succeeded")).unwrap();
133+
// Act + Assert:取最新未采纳(created_at=20);已采纳/他类/不存在 → None
134+
let found = db.find_latest_unadopted_refine(5).unwrap();
135+
assert_eq!(found.as_ref().map(|t| t.task_id), Some(20), "应取同会话最新未采纳成功任务");
136+
assert!(db.find_latest_unadopted_refine(6).unwrap().is_none(), "已采纳任务不返回");
137+
assert!(db.find_latest_unadopted_refine(999).unwrap().is_none(), "无任务不返回");
138+
}
139+
124140
#[test]
125141
fn adopted_query_true_after_mark_false_otherwise() {
126142
let db = open_mem();

‎app/src-tauri/tauri.conf.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Entropydecrease",
4-
"version": "0.12.4",
4+
"version": "0.12.5",
55
"identifier": "com.entropydecrease.app",
66
"build": {
77
"beforeDevCommand": "npm run dev",

‎app/src/components/AiRefineCard.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
5151
if (r) {
5252
setResult(r);
5353
setPhase("done");
54+
// 线路优化:完成即自动打开工作台(对比+采纳/放弃/重新生成一体——
55+
// 原"打开工作台/放弃"中间步冗余,工作台内本就有这两个出口)
56+
setShowWorkbench(true);
5457
}
5558
} else if (typeof st === "object" && st !== null && "Failed" in st) {
5659
setFailure(st.Failed.reason as AiTaskFailureLike);
@@ -241,13 +244,19 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
241244
</div>
242245
)}
243246

244-
{/* 精修完成 → 打开工作台(替代内嵌 diff) */}
247+
{/* 精修完成 → 工作台(完成即自动打开;关闭后可经下方按钮重开) */}
245248
{phase === "done" && result && (
246249
<div>
247250
{showWorkbench ? (
248251
<RefineWorkbench
249252
sessionId={sessionId}
250253
taskResult={result}
254+
// 采纳落库回传真实 taskId——标记 adopted + 成本回填(防重启后
255+
// 任务中心重复采纳产生重复笔记;原实现恒传 null 丢失该保障)
256+
taskId={taskIdRef.current}
257+
// 重新生成走父级任务管线(running 态 + 轮询/事件 + 卡住检测)——
258+
// 原工作台直连 ai_refine_start 导致卡片状态残留(旧结果+无进度)
259+
onRegenerate={async () => { await start(); }}
251260
onClose={() => setShowWorkbench(false)}
252261
onApplied={(id) => { reset(); onApplied?.(id); }}
253262
/>
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* RefineWorkbench.test.tsx — 精修工作台数据源回归测试(Bug#:采纳前右侧恒空)。
4+
*
5+
* @ai-context: 工作台在采纳前打开,笔记尚未落库——refine_workbench 必须收到
6+
* 调用方内存结果(refineResult 参数)才能回显精修版;采纳落库须
7+
* 回传 taskId(标记 adopted 防重启重复采纳、成本回填)。invoke
8+
* 全 mock(不触碰真实后端),断言命令参数契约与双栏渲染。
9+
*/
10+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
12+
import type { AiRefineResult, WorkbenchData } from "../types";
13+
14+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
15+
vi.mock("@tauri-apps/api/core", () => ({
16+
invoke: invokeMock,
17+
}));
18+
19+
import RefineWorkbench from "./RefineWorkbench";
20+
21+
/** 最小精修结果桩(camelCase 契约) */
22+
const resultStub: AiRefineResult = {
23+
title: "测试精修",
24+
baseMarkdown: "# 标题\n规则内容",
25+
refinedMarkdown: "# 标题\n精修内容",
26+
diff: [],
27+
addedLines: 1,
28+
removedLines: 1,
29+
slices: 1,
30+
failedSlices: 0,
31+
model: "test-model",
32+
};
33+
34+
/** 后端工作台数据桩(含章节 diff——右侧带徽标) */
35+
const wbStub: WorkbenchData = {
36+
ruleMarkdown: "# 标题\n规则内容",
37+
refinedMarkdown: "# 标题\n精修内容",
38+
sections: [{ heading: "标题", status: "modified", removed_lines: ["规则内容"], added_lines: ["精修内容"] }],
39+
stats: { added: 1, removed: 1, unchanged: 0 },
40+
meta: { costYuan: null, model: "test-model", slices: 1, mergedFrom: null },
41+
};
42+
43+
beforeEach(() => {
44+
invokeMock.mockReset();
45+
invokeMock.mockImplementation(async (_cmd: string) => wbStub);
46+
});
47+
48+
afterEach(() => cleanup());
49+
50+
describe("RefineWorkbench 采纳前数据源(Bug# 回归)", () => {
51+
it("带 taskResult 打开:refine_workbench 收到 refineResult,右侧渲染精修版", async () => {
52+
// Arrange/Act
53+
render(<RefineWorkbench sessionId={1} onClose={vi.fn()} taskResult={resultStub} taskId={7} />);
54+
// Assert:命令契约——内存结果必须回传(原实现未传 → 后端取不到未落库笔记 → 右侧恒空)
55+
await waitFor(() => {
56+
expect(invokeMock).toHaveBeenCalledWith("refine_workbench", { sessionId: 1, refineResult: resultStub });
57+
});
58+
expect(await screen.findByText(/精修内容/)).toBeTruthy();
59+
expect(screen.getByText(/规则内容/)).toBeTruthy();
60+
// 右侧不再出现"尚未精修"占位(原 Bug 表象)
61+
expect(screen.queryByText(/尚未精修/)).toBeNull();
62+
});
63+
64+
it("无 taskResult(重启/恢复路径):refineResult 传 null,后端兜底未采纳任务", async () => {
65+
// Arrange/Act
66+
render(<RefineWorkbench sessionId={2} onClose={vi.fn()} />);
67+
// Assert
68+
await waitFor(() => {
69+
expect(invokeMock).toHaveBeenCalledWith("refine_workbench", { sessionId: 2, refineResult: null });
70+
});
71+
});
72+
73+
it("采纳落库回传真实 taskId(标记 adopted + 成本回填——防重启重复采纳)", async () => {
74+
// Arrange:工作台数据 + apply 返回笔记 id
75+
invokeMock.mockImplementation(async (cmd: string) => (cmd === "ai_refine_apply" ? { id: 42 } : wbStub));
76+
// Act
77+
const onApplied = vi.fn();
78+
const onClose = vi.fn();
79+
render(<RefineWorkbench sessionId={1} onClose={onClose} taskResult={resultStub} taskId={7} onApplied={onApplied} />);
80+
fireEvent.click(await screen.findByRole("button", { name: /采纳落库/ }));
81+
// Assert
82+
await waitFor(() => {
83+
expect(invokeMock).toHaveBeenCalledWith("ai_refine_apply", {
84+
sessionId: 1,
85+
result: resultStub,
86+
taskId: 7,
87+
});
88+
});
89+
expect(onApplied).toHaveBeenCalledWith(42);
90+
expect(onClose).toHaveBeenCalled();
91+
});
92+
93+
it("后端返回 refinedMarkdown=null(无任何精修来源)→ 显示占位而非空白", async () => {
94+
// Arrange:后端兜底也拿不到精修版
95+
invokeMock.mockImplementation(async (_cmd: string) => ({
96+
...wbStub,
97+
refinedMarkdown: null,
98+
sections: [],
99+
stats: { added: 0, removed: 0, unchanged: 0 },
100+
}));
101+
// Act
102+
render(<RefineWorkbench sessionId={3} onClose={vi.fn()} />);
103+
// Assert:占位提示可见,右栏不崩
104+
expect(await screen.findByText(/尚未精修/)).toBeTruthy();
105+
expect(screen.getByText(/规则内容/)).toBeTruthy();
106+
});
107+
});

0 commit comments

Comments
 (0)