Skip to content

Commit de1f43a

Browse files
committed
feat(notes): 新增带证据三轨的模型层纯函数
1 parent 2da0b14 commit de1f43a

3 files changed

Lines changed: 607 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* noteEvidence.fixtures —— 笔记「带证据三轨」的**纯数据夹具**(批 8 T6;规格 §7.4 §B/§C 的落地)。
3+
*
4+
* @ai-context 本件**只有类型与常量**(无函数、无副作用、无 I/O):夹具进棘轮域(它不是 `*.test.*`)
5+
* ⇒ 与 `noteEvidenceModel.ts` 同受「零样式字面量」约束(见 `noteEvidenceModel.test.ts` 的 V5 读数)。
6+
* @ai-context **四类无锚点**(规格 §B):① `OCR_DIRECT`(图文会话)② `WEB_ARTICLE`(网页正文)
7+
* ③ `MANUAL_NOTE`(手动笔记)④ `ANCHOR_SWITCH` 的 `off` 态(`anchor_timestamps=false`)。
8+
* 前三类的候选轨**刻意非空** —— 否则「0 个证据列」是**空真**(没有候选本来也不会有证据)。
9+
* @ai-context **两态夹具**(规格 §C):`ANCHOR_SWITCH`(同一正文的开关两态)与 `REFINE_PAIR`
10+
* (精修前 / 精修后)。两态**共用同一组候选轨**(`segments` / `ocr` 逐字相同)⇒ 两态读数的差
11+
* 只可能来自**锚点的存亡**,是**受控对比**(不等长/不等候选的对比只能当存在性证据)。
12+
* 两态的「同一份正文」由判据用 `before[i].text.endsWith(after[i].text)` 逐段对拍(锚点在段首)。
13+
* @ai-context 🔴 **精修后段落锚点消失是设计行为**(`anchor_strip.rs` 主动剥离、只回挂章节锚点)
14+
* ⇒ 这些段落必须走 E2 的**失配分支**(显式「无证据」标记),**不得**报成缺陷、**不得**回滚精修。
15+
*/
16+
17+
import type { EvidenceCandidate, EvidenceLine, OcrEvidenceCandidate } from "./noteEvidenceModel";
18+
19+
/** 一类静态夹具(段落轨 + 转写段轨 + OCR 块轨) */
20+
export interface NoteEvidenceFixture {
21+
/** 类名 —— 断言与报告**点名**用它,不用序号 */
22+
readonly kind: "ocrDirect" | "web" | "manual";
23+
/** 该类在规格 §B 的「出处(发射路径)」列(**含「未接线」的如实登记**) */
24+
readonly origin: string;
25+
readonly lines: readonly EvidenceLine[];
26+
readonly segments: readonly EvidenceCandidate[];
27+
readonly ocr: readonly OcrEvidenceCandidate[];
28+
}
29+
30+
/**
31+
* ① `OcrDirect`(图文会话):正文由 `note_filter_ocr::rebuild_ocr_markdown` 从 OCR 块拼出,
32+
* 该文件里 `format_timestamp` 的调用点 = 0 ⇒ **段落全部无锚点**(不是缺陷,是这一类的一等形态)。
33+
*/
34+
export const OCR_DIRECT: NoteEvidenceFixture = {
35+
kind: "ocrDirect",
36+
origin: "note_filter_render.rs:85(rebuild_ocr_markdown)",
37+
lines: [
38+
{ paragraphIndex: 0, text: "粉底液色号对照:冷调偏粉、暖调偏黄。" },
39+
{ paragraphIndex: 1, text: "上妆顺序:隔离 → 粉底 → 遮瑕 → 定妆。" },
40+
{ paragraphIndex: 2, text: "手背试色要在自然光下看。" },
41+
],
42+
segments: [
43+
{ id: 1, startMs: 3000 },
44+
{ id: 2, startMs: 18000 },
45+
],
46+
ocr: [
47+
{ id: 11, timestampMs: 3000 },
48+
{ id: 12, timestampMs: 18000 },
49+
{ id: 13, timestampMs: 33000 },
50+
],
51+
};
52+
53+
/**
54+
* ② `Web`(网页正文):正文直取 `web_session_pages.markdown`。
55+
* 🔴 `BodySource::Web` 今天在 `note_body_source.rs` 里是 `#[allow(dead_code)]` **预留变体**、
56+
* **没有生产发射路径** ⇒ 本夹具是**未接线变体**的**唯一**覆盖面,不得读成「真实网页路径已验证」。
57+
*/
58+
export const WEB_ARTICLE: NoteEvidenceFixture = {
59+
kind: "web",
60+
origin: "note_body_source.rs:24-27(BodySource::Web,未接线)",
61+
lines: [
62+
{ paragraphIndex: 0, text: "遮瑕的三种质地:膏状遮盖强、液状自然、笔状精准。" },
63+
{ paragraphIndex: 1, text: "定妆粉的粒径越小,雾面感越强。" },
64+
],
65+
segments: [{ id: 7, startMs: 12000 }],
66+
ocr: [{ id: 21, timestampMs: 12000 }],
67+
};
68+
69+
/**
70+
* ③ 手动笔记(用户手写/改写):**无发射路径**。
71+
* 🔴 候选轨**非空**是有意的 —— 它证明「没有锚点语法就不会被补锚点」不是因为没有证据可挂
72+
* (与规格 §4.4 的「绝不猜」同源纪律):`extractAnchors` 从不读候选轨。
73+
*/
74+
export const MANUAL_NOTE: NoteEvidenceFixture = {
75+
kind: "manual",
76+
origin: "无发射路径(用户手写/改写)",
77+
lines: [
78+
{ paragraphIndex: 0, text: "老师说冷皮用粉调,我试了确实更亮。" },
79+
{ paragraphIndex: 1, text: "下次记得带自己的刷子。" },
80+
],
81+
segments: [
82+
{ id: 2, startMs: 9000 },
83+
{ id: 3, startMs: 26000 },
84+
],
85+
ocr: [{ id: 31, timestampMs: 9000 }],
86+
};
87+
88+
/** 静态三类(第 ④ 类是开关两态,见 `ANCHOR_SWITCH`)—— 供判据**逐类**遍历,避免漏类 */
89+
export const STATIC_FIXTURES: readonly NoteEvidenceFixture[] = [OCR_DIRECT, WEB_ARTICLE, MANUAL_NOTE];
90+
91+
/** ④ `anchor_timestamps` 开关的**同一份正文两态**(`purify_config.rs` 默认 `true`) */
92+
export interface AnchorSwitchFixture {
93+
readonly segments: readonly EvidenceCandidate[];
94+
readonly ocr: readonly OcrEvidenceCandidate[];
95+
/** 开关 `true`:段首锚点在(第 3 段刻意无锚点 ⇒ 分母甲 ≠ 分母乙) */
96+
readonly on: readonly EvidenceLine[];
97+
/** 开关 `false`:同一正文、锚点语法整体缺席(金测试 `note_filter_golden_tests.rs:125-127` 已钉住) */
98+
readonly off: readonly EvidenceLine[];
99+
}
100+
101+
/** 第 4 段的 47120 与锚点 47000 相差 120 ms —— 刻意落在容差内,让「抖动容差」在夹具里真的被用到。 */
102+
export const ANCHOR_SWITCH: AnchorSwitchFixture = {
103+
segments: [
104+
{ id: 1, startMs: 5000 },
105+
{ id: 2, startMs: 20000 },
106+
{ id: 3, startMs: 47120 },
107+
],
108+
ocr: [
109+
{ id: 11, timestampMs: 5000 },
110+
{ id: 12, timestampMs: 20000 },
111+
{ id: 13, timestampMs: 47200 },
112+
],
113+
on: [
114+
{ paragraphIndex: 0, text: "[⏱ 00:05]([[ts:5000]]) 隔离霜先抚平毛孔。" },
115+
{ paragraphIndex: 1, text: "[⏱ 00:20]([[ts:20000]]) 粉底用美妆蛋少量多次。" },
116+
{ paragraphIndex: 2, text: "[⏱ 00:47]([[ts:47000]]) 遮瑕点在泪沟最深处。" },
117+
{ paragraphIndex: 3, text: "定妆喷雾距离面部三十厘米。" },
118+
],
119+
off: [
120+
{ paragraphIndex: 0, text: "隔离霜先抚平毛孔。" },
121+
{ paragraphIndex: 1, text: "粉底用美妆蛋少量多次。" },
122+
{ paragraphIndex: 2, text: "遮瑕点在泪沟最深处。" },
123+
{ paragraphIndex: 3, text: "定妆喷雾距离面部三十厘米。" },
124+
],
125+
};
126+
127+
/** ⑤/⑥ 精修前 / 精修后两态(规格 §C 契约:段落锚点被主动剥离,只回挂章节锚点) */
128+
export interface RefinePairFixture {
129+
readonly segments: readonly EvidenceCandidate[];
130+
readonly ocr: readonly OcrEvidenceCandidate[];
131+
readonly before: readonly EvidenceLine[];
132+
readonly after: readonly EvidenceLine[];
133+
}
134+
135+
/**
136+
* 两态的**候选轨逐字相同**(受控对比);标题行两态都带**章节锚点**(外带 `[...]` 包裹的形态),
137+
* 段首锚点只在 `before`(回挂只认标题行精确匹配 ⇒ 正文段落的锚点不会回来)。
138+
* 第 4 段(`## 定妆 [[⏱ 01:39]([[ts:99000]])]`)距最近转写段 51880 ms > 容差 ⇒ 两态都出**失配**,
139+
* 用来证明「失配不是精修造成的」。
140+
*/
141+
export const REFINE_PAIR: RefinePairFixture = {
142+
segments: [
143+
{ id: 1, startMs: 5000 },
144+
{ id: 2, startMs: 20000 },
145+
{ id: 3, startMs: 47120 },
146+
],
147+
ocr: [
148+
{ id: 11, timestampMs: 5000 },
149+
{ id: 12, timestampMs: 20000 },
150+
{ id: 13, timestampMs: 47200 },
151+
],
152+
before: [
153+
{ paragraphIndex: 0, text: "## 底妆顺序 [[⏱ 00:05]([[ts:5000]])]" },
154+
{ paragraphIndex: 1, text: "[⏱ 00:05]([[ts:5000]]) 隔离霜先抚平毛孔。" },
155+
{ paragraphIndex: 2, text: "[⏱ 00:20]([[ts:20000]]) 粉底用美妆蛋少量多次。" },
156+
{ paragraphIndex: 3, text: "[⏱ 00:47]([[ts:47000]]) 遮瑕点在泪沟最深处。" },
157+
{ paragraphIndex: 4, text: "## 定妆 [[⏱ 01:39]([[ts:99000]])]" },
158+
{ paragraphIndex: 5, text: "散粉压两遍,鼻翼用余粉带过。" },
159+
],
160+
after: [
161+
{ paragraphIndex: 0, text: "## 底妆顺序 [[⏱ 00:05]([[ts:5000]])]" },
162+
{ paragraphIndex: 1, text: "隔离霜先抚平毛孔。" },
163+
{ paragraphIndex: 2, text: "粉底用美妆蛋少量多次。" },
164+
{ paragraphIndex: 3, text: "遮瑕点在泪沟最深处。" },
165+
{ paragraphIndex: 4, text: "## 定妆 [[⏱ 01:39]([[ts:99000]])]" },
166+
{ paragraphIndex: 5, text: "散粉压两遍,鼻翼用余粉带过。" },
167+
],
168+
};

0 commit comments

Comments
 (0)