fix(consult): split table cells by sentence and carry names inside a cell (#182 round 2, BUG-1825)

抽查补充:表格行先按 | 分格,格内再按同一套句读分句;同一格里后面的子句出现状态词时
沿用本格上文的名字(正文不跨句继承)。

验收那格「Sunaphaa、Vosi 状态为 candidate(…);Amala、Saraswati 等为 computed,不成立」
现在只有 Amala、Saraswati 两条不符。

Tests: 本单 14 条;fact-guard 13、fact-guard-stream 2、chat-reopt 4、
affliction-reading 6、professional-mode 10 全过。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-11-09 20:03:50 +08:00
co-authored by Grok (agent loop)
parent 84f2f29f87
commit be9b2e9131
3 changed files with 65 additions and 28 deletions
@@ -76,3 +76,11 @@ Claude 在本分支上已跑过一次全量、指出 3 条红;我这一轮把
给出「与 staging 逐条对比、集合差为空」的证据。等完整依赖装回来我会补跑并把名单对比写进来。
本轮能给的替代证据:3 条被点名的测试所在的两个文件(4 条 + 6 条)全部通过;
本单新增的 13 条、以及 fact-guard 13、fact-guard-stream 2、professional-mode 10 全部通过。
### 抽查补充:表格格内按句读分句
表格行先按 `|` 分格,**每一格内部再按同一套句读规则分句**(`。!?;,,`,括号内不断)。
另外,同一格里后面的子句出现状态词时,沿用本格上文提到的格局名——验收给的那格
「Sunaphaa、Vosi 状态为 candidate(…);Amala、Saraswati 等为 computed,不成立」现在只有
**Amala、Saraswati 两条不符**,Sunaphaa、Vosi 不出现(有测试)。正文不跨句继承。
@@ -353,19 +353,12 @@ export function findAnswerFactMismatches(answer: string, card: EvidenceCard): An
});
}
/** 分句:。!?;,, 与换行;括号里的标点不断句。表格行按 | 分格。 */
function yogaSentences(line: string): Array<{ start: number; end: number }> {
if (line.trimStart().startsWith("|")) {
return line.split("|").reduce<Array<{ start: number; end: number }>>((acc, piece) => {
const start = acc.length ? acc[acc.length - 1]!.end + 1 : 0;
acc.push({ start, end: start + piece.length });
return acc;
}, []);
}
/** 分句:。!?;,, 与换行;括号里的标点不断句。表格行先按 | 分格,格内再按同一套句读分句。 */
function splitProse(line: string, base = 0): Array<{ start: number; end: number }> {
const out: Array<{ start: number; end: number }> = [];
let depth = 0;
let start = 0;
const push = (end: number) => out.push({ start, end });
const push = (end: number) => out.push({ start: base + start, end: base + end });
for (let index = 0; index < line.length; index += 1) {
const char = line[index]!;
if (char === "(" || char === "(") depth += 1;
@@ -379,6 +372,17 @@ function yogaSentences(line: string): Array<{ start: number; end: number }> {
return out;
}
function yogaSentences(line: string): Array<{ start: number; end: number }> {
if (!line.trimStart().startsWith("|")) return splitProse(line);
const out: Array<{ start: number; end: number }> = [];
let base = 0;
for (const cell of line.split("|")) {
out.push(...splitProse(cell, base));
base += cell.length + 1;
}
return out;
}
/** 一句话里的状态词类别;同时有肯定与否定、或全是排除写法时返回 null(不检查)。 */
function yogaWordClass(sentence: string): { kind: "affirm" | "deny"; word: string; at: number } | null {
const { affirm, deny, exempt, conditional } = YOGA_STATUS_WORD_CLASSES;
@@ -426,35 +430,46 @@ function yogaWordClass(sentence: string): { kind: "affirm" | "deny"; word: strin
function checkYogaStatus(line: string, offset: number, view: CardView, push: Emit) {
const entries = Object.entries(view.yogas ?? {});
if (!entries.length) return;
const table = line.trimStart().startsWith("|");
// 表格一格内的状态词可以落在同格后面的子句里(「Amala、Saraswati 等为 computed,不成立」),
// 所以同一格里记住刚提到的格局名;正文不跨句继承。
let carried: Array<{ entry: YogaCardEntry; name: string }> = [];
for (const sentence of yogaSentences(line)) {
const text = line.slice(sentence.start, sentence.end);
if (!text.trim()) continue;
const wordClass = yogaWordClass(text);
if (!wordClass) continue;
const found: Array<{ entry: YogaCardEntry & { pattern: RegExp }; name: string }> = [];
for (const [, entry] of entries) {
if ("ambiguous" in entry) continue;
for (const hit of text.matchAll(entry.pattern)) {
const after = text.slice(hit.index! + hit[0].length);
const bracket = /^\s*[((]([^))]*)[))]/u.exec(after);
if (bracket && YOGA_STATUS_WORD_CLASSES.literalStatus.some((word) => bracket[1]!.includes(word))) continue;
const matches = (entry.key === "formed_under_source" && wordClass.kind === "affirm")
|| (entry.key === "conditions_not_met" && wordClass.kind === "deny");
const at = offset + sentence.start + wordClass.at;
push({
kind: "yoga_status",
layer: "D1",
entity: entry.name,
reference: "yogas_detail status",
claimed: wordClass.word,
card: entry.text,
fact_id: entry.factId,
source_path: entry.sourcePath,
quote: wordClass.word,
start: at,
end: at + wordClass.word.length,
}, matches ? "match" : "mismatch");
found.push({ entry, name: entry.name });
}
}
const wordClass = yogaWordClass(text);
const targets = found.length ? found : (table ? carried : []);
if (found.length) carried = found;
if (!wordClass || !targets.length) continue;
for (const { entry, name } of targets) {
const matches = (entry.key === "formed_under_source" && wordClass.kind === "affirm")
|| (entry.key === "conditions_not_met" && wordClass.kind === "deny");
const at = offset + sentence.start + wordClass.at;
push({
kind: "yoga_status",
layer: "D1",
entity: name,
reference: "yogas_detail status",
claimed: wordClass.word,
card: entry.text,
fact_id: entry.factId,
source_path: entry.sourcePath,
quote: wordClass.word,
start: at,
end: at + wordClass.word.length,
}, matches ? "match" : "mismatch");
}
if (!table) carried = [];
}
}
@@ -304,3 +304,17 @@ test("usage negations with 已 in the middle are not false positives", () => {
assert.deepEqual(mismatches(answer, evidence), []);
assert.deepEqual(mismatches("Amala Yoga 不视为已成立。", evidence), []);
});
test("a table cell splits by sentence inside the cell, so neighbours are not blamed", () => {
const evidence = card([
{ name: "Sunaphaa Yoga", status: "candidate" },
{ name: "Vosi Yoga", status: "candidate" },
{ name: "Amala Yoga", status: "computed" },
{ name: "Saraswati Yoga", status: "computed" },
], ["Sunaphaa Yoga", "Vosi Yoga", "Amala Yoga", "Saraswati Yoga"]);
const cell = "Sunaphaa、Vosi 状态为 candidate(support_only_candidate);Amala、Saraswati 等为 computed,不成立";
const found = mismatches(`| 格局 | ${cell} |`, evidence);
assert.deepEqual(found.map((item) => item.entity).sort(), ["Amala Yoga", "Saraswati Yoga"]);
for (const item of found) assert.equal(item.claimed, "不成立");
});