Skip to content

Commit e41023a

Browse files
committed
refactor(rust): 拆 commands_ai_refine 步 2 — 工作台域(392 行外壳)
1 parent 74e9a5c commit e41023a

3 files changed

Lines changed: 215 additions & 193 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ pub fn handle() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync
400400
crate::commands_ai_refine::ai_refine_prompt_preview,
401401
crate::commands_ai_refine::ai_refine_result,
402402
crate::commands_ai_refine::ai_refine_apply,
403-
crate::commands_ai_refine::refine_workbench,
403+
crate::commands_ai_refine::workbench::refine_workbench,
404404
// v0.17.0(REQ-246):笔记级 AI 精修——估计/启动/采纳(手写笔记)
405405
crate::commands_ai_note_refine::ai_note_refine_estimate,
406406
crate::commands_ai_note_refine::ai_note_refine_start,

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

Lines changed: 2 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
//! · dto.rs —— IPC 契约类型(4 个 serde 结构体;字段序 = JSON 键序)
1717
//! · gate.rs —— 成本硬拦截(免费档/本地 Provider 跳过;查询失败宽容放行)
1818
//! · registry.rs —— 任务注册表 + id 序列 + 容量守卫 + 状态写入口
19+
//! · workbench.rs —— 精修工作台(三级数据源 + 章节 diff 统计 + 内联契约测试)
1920
//! @ai-context: 本外壳只留「跨域共享的命令 + 重导出门面」;外部 10 文件
2021
//! 20 处 crate::commands_ai_refine::X 引用**零改动**(逐项重导出在下方);
2122
//! #[tauri::command] 定义随域下沉后,注册路径同步改成
@@ -24,6 +25,7 @@
2425
mod dto;
2526
mod gate;
2627
mod registry;
28+
pub(crate) mod workbench;
2729

2830
pub use dto::{AiRefineResult, AiTaskHandle, RefineEstimateView, RefineStrategyInfo};
2931
pub use registry::{claim_task_id, task_registry, task_seq, task_seq_lower_bound, AiTaskEntry};
@@ -36,12 +38,9 @@ use crate::ai_cost::estimate_for_content_model;
3638
use crate::ai_note_refine::NoteRefinePrompt;
3739
use crate::ai_strategy::{RefineStrategyMeta, StrategyOverride};
3840
use crate::ai_task::AiTaskState;
39-
use crate::anchor_strip::strip_anchors;
4041
use crate::commands::AppState;
4142
use crate::commands_session_note::build_rule_draft_with_analysis;
42-
use crate::note_diff::{diff_sections, DiffStats, SectionDiff};
4343
use crate::note_filter::PurifyEnv;
44-
use crate::note_version::VersionMeta;
4544
use crate::types::{NewNote, Note};
4645
use crate::video_profile::ProfileKind;
4746

@@ -391,192 +390,3 @@ pub fn ai_refine_apply(
391390
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
392391
Ok(note_row)
393392
}
394-
395-
// ────────────────────────────────────────────────────────────
396-
// 精修工作台(Task 11 / spec 6️⃣——规则草稿+精修结果+章节分组 diff 一次取全)
397-
// ────────────────────────────────────────────────────────────
398-
399-
/// 工作台数据(前端 RefineWorkbench 模态数据源)。
400-
///
401-
/// @ai-context: 必须 camelCase(与 AiRefineResult 等兄弟结构体一致)——本模块
402-
/// 其余结构体均已 2026-08 统一契约;此处缺失曾导致前端按
403-
/// ruleMarkdown 读取得到 undefined → renderMd(undefined).split
404-
/// ("Cannot read properties of undefined (reading 'split')",
405-
/// v0.11.5 引入、真机验收漏测,v0.12.3 修复补测)。
406-
/// @ai-context: 嵌套 section(SectionDiff)保持 snake_case 是刻意契约
407-
/// (首次出现即定,前端类型注释"勿改"),rename 只作用于顶层键。
408-
#[derive(Debug, Clone, serde::Serialize)]
409-
#[serde(rename_all = "camelCase")]
410-
pub struct WorkbenchData {
411-
pub rule_markdown: String,
412-
pub refined_markdown: Option<String>,
413-
pub sections: Vec<SectionDiff>,
414-
pub stats: DiffStats,
415-
pub meta: Option<VersionMeta>,
416-
}
417-
418-
/// 工作台数据接口:规则草稿 + 精修结果 + 章节分组 diff 一次取全。
419-
///
420-
/// refined_markdown 为 None → 尚未精修;否则包含最新精修版内容与章节 diff。
421-
///
422-
/// @ai-context: 精修版三级数据源(修复:原实现只查已落库笔记——采纳前打开
423-
/// 工作台右侧恒空;且任务成功事件先于 DB 落库,存在竞态):
424-
/// ① refine_result 参数(调用方内存结果——采纳前刚完成的任务,
425-
/// 消除竞态)② 最新未采纳成功任务(DB 持久化——重启后恢复)
426-
/// ③ 已落库笔记最新版本(采纳后)。
427-
#[tauri::command]
428-
pub async fn refine_workbench(
429-
state: State<'_, AppState>,
430-
session_id: i64,
431-
refine_result: Option<AiRefineResult>,
432-
) -> Result<WorkbenchData, String> {
433-
if session_id <= 0 {
434-
return Err("无效的会话 id".to_string());
435-
}
436-
// ① 构建规则草稿(与精修任务同管线)
437-
let env = PurifyEnv {
438-
config: state.purify.clone(),
439-
symbol: state.symbol_normalize.clone(),
440-
corrections: state.ocr_corrections.clone(),
441-
};
442-
let (draft, _) = build_rule_draft_with_analysis(
443-
&state.db,
444-
&state.ui_junk,
445-
&env,
446-
&state.data_dir,
447-
session_id,
448-
None,
449-
)
450-
.map_err(|e| e.to_string())?;
451-
let rule_md = strip_anchors(&draft.markdown);
452-
453-
// ② 精修版数据源:内存结果(优先)> 未采纳成功任务(DB 兜底)> 已落库笔记
454-
let unadopted = state
455-
.db
456-
.find_latest_unadopted_refine(session_id)
457-
.map_err(|e| e.to_string())?;
458-
let pending_result: Option<AiRefineResult> = match (refine_result, unadopted) {
459-
(Some(r), _) => Some(r),
460-
// 解析失败降级(日志可观测——不影响工作台打开,可经任务中心重取)
461-
(None, Some(task)) => task.result_json.as_deref().and_then(|j| {
462-
serde_json::from_str::<AiRefineResult>(j)
463-
.map_err(|e| eprintln!("[refine-workbench] 未采纳任务 {} 结果解析失败: {}", task.task_id, e))
464-
.ok()
465-
}),
466-
(None, None) => None,
467-
};
468-
469-
let note = state.db.find_note_by_session(session_id).map_err(|e| e.to_string())?;
470-
let (refined_md, sections, stats, meta) = if let Some(result) = pending_result {
471-
let secs = diff_sections(&rule_md, &result.refined_markdown);
472-
let st = stats_from(&secs);
473-
// 未落库:成本按模型单价在 apply 时核算——此处不做虚假回填
474-
let m = VersionMeta {
475-
cost_yuan: None,
476-
model: Some(result.model.clone()),
477-
slices: Some(result.slices),
478-
merged_from: None,
479-
};
480-
(Some(result.refined_markdown), secs, st, Some(m))
481-
} else if let Some(ref note) = note {
482-
// 取最新版本内容作为精修版
483-
let versions = state.db.list_versions(note.id).map_err(|e| e.to_string())?;
484-
let latest = versions.last()
485-
.map(|v| v.content.clone())
486-
.unwrap_or_else(|| note.content.clone());
487-
let secs = diff_sections(&rule_md, &latest);
488-
let st = stats_from(&secs);
489-
// 取最新版本 meta
490-
let m = versions.last().map(|v| VersionMeta {
491-
cost_yuan: v.meta.cost_yuan,
492-
model: v.meta.model.clone(),
493-
slices: v.meta.slices,
494-
merged_from: v.meta.merged_from.clone(),
495-
});
496-
(Some(latest), secs, st, m)
497-
} else {
498-
(None, vec![], DiffStats { added: 0, removed: 0, unchanged: 0 }, None)
499-
};
500-
501-
Ok(WorkbenchData {
502-
rule_markdown: rule_md,
503-
refined_markdown: refined_md,
504-
sections,
505-
stats,
506-
meta,
507-
})
508-
}
509-
510-
/// 章节分组 diff 统计(新增/删除/未变行数——与 diff_sections 同口径)。
511-
fn stats_from(secs: &[SectionDiff]) -> DiffStats {
512-
DiffStats {
513-
added: secs.iter().map(|s| s.added_lines.len()).sum(),
514-
removed: secs.iter().map(|s| s.removed_lines.len()).sum(),
515-
unchanged: secs.iter()
516-
.filter(|s| s.status == crate::note_diff::DiffStatus::Unchanged)
517-
.count(),
518-
}
519-
}
520-
521-
#[cfg(test)]
522-
mod tests {
523-
use super::*;
524-
525-
/// v0.12.3 回归(Bug#2):WorkbenchData 顶层键必须 camelCase——
526-
/// 前端按 ruleMarkdown/refinedMarkdown 读取;snake_case 键使字段为
527-
/// undefined → renderMd(undefined).split 抛
528-
/// "Cannot read properties of undefined (reading 'split')"
529-
/// (原实现缺失 rename_all,v0.11.5 引入、真机验收漏测)。
530-
#[test]
531-
fn workbench_data_serializes_camel_case_top_level() {
532-
let data = WorkbenchData {
533-
rule_markdown: "# 标题\n正文".to_string(),
534-
refined_markdown: Some("# 标题\n精修正文".to_string()),
535-
sections: vec![crate::note_diff::SectionDiff {
536-
heading: "标题".to_string(),
537-
status: crate::note_diff::DiffStatus::Modified,
538-
removed_lines: vec!["正文".to_string()],
539-
added_lines: vec!["精修正文".to_string()],
540-
}],
541-
stats: crate::note_diff::DiffStats {
542-
added: 1,
543-
removed: 1,
544-
unchanged: 0,
545-
},
546-
meta: Some(crate::note_version::VersionMeta {
547-
cost_yuan: Some(0.01),
548-
model: Some("test-model".to_string()),
549-
slices: Some(1),
550-
merged_from: None,
551-
}),
552-
};
553-
let v = serde_json::to_value(&data).expect("序列化失败");
554-
let obj = v.as_object().expect("应为 JSON 对象");
555-
assert!(obj.contains_key("ruleMarkdown"), "顶层键必须为 ruleMarkdown(camelCase)");
556-
assert!(obj.contains_key("refinedMarkdown"), "顶层键必须为 refinedMarkdown(camelCase)");
557-
assert!(obj.contains_key("sections"));
558-
assert!(obj.contains_key("stats"));
559-
assert!(obj.contains_key("meta"));
560-
// 嵌套 SectionDiff 保持 snake_case(首次出现即定的契约,勿随顶层 rename)
561-
let sec = &obj["sections"][0];
562-
assert!(sec.get("removed_lines").is_some(), "SectionDiff 嵌套键保持 snake_case");
563-
assert!(sec.get("added_lines").is_some());
564-
assert_eq!(sec["status"], "modified");
565-
}
566-
567-
/// stats_from:按 diff_sections 分组行数统计(工作台头部 新增/删除/未变 徽标)。
568-
#[test]
569-
fn stats_from_aggregates_section_lines() {
570-
use crate::note_diff::{DiffStatus, SectionDiff};
571-
let secs = vec![
572-
SectionDiff { heading: "A".into(), status: DiffStatus::Modified, removed_lines: vec!["a".into()], added_lines: vec!["A".into(), "B".into()] },
573-
SectionDiff { heading: "B".into(), status: DiffStatus::Unchanged, removed_lines: vec![], added_lines: vec![] },
574-
SectionDiff { heading: "C".into(), status: DiffStatus::Added, removed_lines: vec![], added_lines: vec!["c".into()] },
575-
SectionDiff { heading: "D".into(), status: DiffStatus::Removed, removed_lines: vec!["d".into()], added_lines: vec![] },
576-
];
577-
let s = stats_from(&secs);
578-
assert_eq!(s.added, 3);
579-
assert_eq!(s.removed, 2);
580-
assert_eq!(s.unchanged, 1);
581-
}
582-
}

0 commit comments

Comments
 (0)