@@ -19,13 +19,14 @@ use crate::ai_client::AiClient;
1919use crate :: ai_cost:: estimate_for_content_model;
2020use crate :: ai_enrich_protocol:: { AiEnrichBlock , AiEnrichKind , AiEnrichRequest , AiEnrichResponse } ;
2121use crate :: ai_mock:: AiMockAdapter ;
22- use crate :: ai_note_enrich:: AiNoteEnrichAdapter ;
22+ use crate :: ai_note_enrich:: { AiNoteEnrichAdapter , EnrichOutcome } ;
2323use crate :: ai_task:: { slice_note, AiTaskFailure , AiTaskState , SLICE_MAX_CHARS } ;
2424use crate :: commands:: AppState ;
2525use crate :: commands_ai_refine:: {
2626 set_task, trim_tasks, AiTaskEntry , AiTaskHandle , RefineEstimateView ,
2727} ;
2828use crate :: enrich_placement:: render_enriched_note;
29+ use crate :: enrich_salvage:: { chapter_titles_of, salvage_blocks} ;
2930use crate :: types:: Note ;
3031
3132/// mock 模式 env 键(本地规则补充,不联网——测试/离线开发)。
@@ -47,6 +48,12 @@ pub struct AiEnrichResult {
4748 pub slices : usize ,
4849 /// 实际返回的子项(kebab-case)
4950 pub kinds : Vec < String > ,
51+ /// 丢弃块数(2026-09 修复:逐块审查回执——被隔离的违规块数与原因可见)
52+ #[ serde( default ) ]
53+ pub dropped_blocks : usize ,
54+ /// 丢弃原因(逐条人类可读;为空=无丢弃——UI 明示"哪些块为何未落")
55+ #[ serde( default ) ]
56+ pub dropped_reasons : Vec < String > ,
5057 pub model : String ,
5158}
5259
@@ -285,24 +292,33 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
285292 ) ;
286293 let adapter = AiNoteEnrichAdapter :: new ( client. clone ( ) ) ;
287294 let mock_adapter = AiMockAdapter ;
295+ // 全篇章节目录(2026-09 修复:跨片全局注入——切片正文常不含章节标题,
296+ // 目录=anchor_ref 白名单;空=笔记无章节,深度块允许 null 锚点落尾部)
297+ let chapter_dir = chapter_titles_of ( & note. content ) ;
288298 // 切片(长笔记按章节切——REQ-145 基建复用;锚点跨片=全局章节标题)
289299 let slices = slice_note ( & note. content , SLICE_MAX_CHARS ) ;
290300 let total = slices. len ( ) ;
291301 set_task ( & st, task_id, AiTaskState :: Running { finished_slices : 0 , total_slices : total } ) ;
292302 let mut all_blocks: Vec < AiEnrichBlock > = Vec :: new ( ) ;
303+ // 2026-09 修复:逐块审查丢弃回执(坏块隔离,原因跨片聚合随结果返回)
304+ let mut dropped_reasons_all: Vec < String > = Vec :: new ( ) ;
293305 let mut turns: Vec < crate :: ai_chat:: AiTurn > = Vec :: with_capacity ( total) ;
294306 for ( i, slice) in slices. iter ( ) . enumerate ( ) {
295307 let req = AiEnrichRequest {
296308 note_content : slice. clone ( ) ,
297309 selected_kinds : selected. clone ( ) ,
298310 profile : profile. clone ( ) ,
311+ chapter_directory : chapter_dir. clone ( ) ,
299312 } ;
300313 // 单片重试 1 次(审查修复 2026-08-21:与精修 SLICE_RETRY 对齐——
301314 // 网络抖动瞬态失败直接重试,避免整任务失败浪费已成功的片)
302- let mut resp : Option < AiEnrichResponse > = None ;
315+ let mut outcome : Option < EnrichOutcome > = None ;
303316 for attempt in 0 ..=1 {
304317 let r = if mock {
305- Ok ( mock_adapter. enrich ( & req, & selected) )
318+ Ok ( EnrichOutcome {
319+ response : mock_adapter. enrich ( & req, & selected) ,
320+ dropped_reasons : Vec :: new ( ) ,
321+ } )
306322 } else {
307323 adapter. enrich ( & req, & selected) . map_err ( AiTaskFailure :: from)
308324 } ;
@@ -313,9 +329,9 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
313329 turn : i + 1 ,
314330 system : adapter. prompt . build_system ( & selected) ,
315331 user : serde_json:: to_string ( & req) . unwrap_or_default ( ) ,
316- response : serde_json:: to_string ( & v) . unwrap_or_default ( ) ,
332+ response : serde_json:: to_string ( & v. response ) . unwrap_or_default ( ) ,
317333 } ) ;
318- resp = Some ( v) ;
334+ outcome = Some ( v) ;
319335 break ;
320336 }
321337 Err ( e) if attempt < 1 => {
@@ -324,17 +340,25 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
324340 Err ( e) => return Err ( e) ,
325341 }
326342 }
327- let resp = resp. expect ( "重试循环必然产出结果或返回" ) ;
328- all_blocks. extend ( resp. blocks ) ;
343+ let outcome = outcome. expect ( "重试循环必然产出结果或返回" ) ;
344+ dropped_reasons_all. extend ( outcome. dropped_reasons ) ;
345+ all_blocks. extend ( outcome. response . blocks ) ;
329346 set_task (
330347 & st,
331348 task_id,
332349 AiTaskState :: Running { finished_slices : i + 1 , total_slices : total } ,
333350 ) ;
334351 }
335- // 合并后整体强校验(非法 → 不落任何补充内容)
336- let resp = AiEnrichResponse { blocks : all_blocks } ;
337- resp. validate ( & selected) . map_err ( AiTaskFailure :: InvalidResponse ) ?;
352+ // 合并后整体复审(2026-09 修复:逐块审查替代全有或全无 validate——
353+ // 单片已隔离违规块;此处兜底跨片总量上限,全部不合规 → 不落任何内容)
354+ let final_out = salvage_blocks (
355+ AiEnrichResponse { blocks : all_blocks } ,
356+ & selected,
357+ & chapter_dir,
358+ )
359+ . map_err ( AiTaskFailure :: InvalidResponse ) ?;
360+ dropped_reasons_all. extend ( final_out. dropped_reasons ) ;
361+ let resp = AiEnrichResponse { blocks : final_out. kept } ;
338362 let enriched = render_enriched_note ( & note. content , & resp) ;
339363 let depth = resp. blocks . iter ( ) . filter ( |b| b. kind . is_depth ( ) ) . count ( ) ;
340364 let breadth = resp. blocks . len ( ) - depth;
@@ -349,6 +373,8 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
349373 breadth_blocks : breadth,
350374 slices : total,
351375 kinds,
376+ dropped_blocks : dropped_reasons_all. len ( ) ,
377+ dropped_reasons : dropped_reasons_all,
352378 model : client. config . model ,
353379 } ,
354380 turns,
0 commit comments