From f963f1cf9b771448474e5f9487d128ae48703978 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 01:29:12 +0800 Subject: [PATCH] fix: close rectification input bypasses --- .../evidence-extractor.ts | 27 ++++++++++++-- .../narrative-agent.ts | 8 +++-- .../conversational-evidence-extractor.test.ts | 2 ++ .../conversational-narrative-agent.test.ts | 36 +++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index 5a662a54..a1c47f9d 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -102,13 +102,34 @@ function evidenceId(input: ExtractLifeEventEvidenceInput, index: number, summary return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`; } +function splitSentenceFragments(sentence: string): string[] { + const fragments: string[] = []; + const separators = /\s*(并且|并|以及|同时|然后|后来又|又|,|,)\s*/g; + let cursor = 0; + let prefixForNext = ""; + for (const match of sentence.matchAll(separators)) { + const index = match.index ?? cursor; + const fragment = sentence.slice(cursor, index).trim(); + if (fragment) { + fragments.push(`${prefixForNext}${fragment}`); + prefixForNext = ""; + } + const separator = match[1] ?? ""; + if (separator === "然后" || separator === "后来又") { + prefixForNext += separator; + } + cursor = index + match[0].length; + } + const tail = sentence.slice(cursor).trim(); + if (tail) fragments.push(`${prefixForNext}${tail}`); + return fragments; +} + function splitSentences(value: string): string[][] { const sentences = value.split(/[。!?!?;;]/) .map((sentence) => sentence.trim()) .filter(Boolean) - .map((sentence) => sentence.split(/\s*(?:并且|并|以及|同时|然后|后来又|又|,|,)\s*/) - .map((fragment) => fragment.trim()) - .filter(Boolean)); + .map(splitSentenceFragments); return sentences.length > 0 ? sentences : [[value.trim()]]; } diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index bb3a76f8..c040e56c 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -16,7 +16,8 @@ const sensitiveSemanticsPattern = /(?:敏感|变化|差异|切换|不同|sensiti const discriminationSemanticsPattern = /(?:区分|辨别|判别|验证|差异|变化|discriminat|distinguish)/i; const broadYearRangePattern = /(?:19|20)\d{2}\s*年?\s*(?:[-–—~~至到\/]|\.\.)\s*(?:19|20)\d{2}\s*年?/i; const explicitYearPattern = /(?:19|20)\d{2}\s*年?/g; -const choiceQuestionPattern = /(?:哪(?:一|个)?(?:年份|年代|时间段|区间|时期)|哪个时间段|选择|选项|更符合|更匹配|A\s*[.、::)]|B\s*[.、::)]|which\s+(?:year|period|range)|options?)/i; +const proposedYearAlternativesPattern = /(?:19|20)\d{2}\s*年?\s*(?:还是|或者|或是|或|、|,|,)\s*(?:19|20)\d{2}\s*年?/i; +const choiceQuestionPattern = /(?:哪(?:一|个)?(?:年|年份|年代|时间段|区间|时期)|哪个时间段|还是|选择|选项|更符合|更匹配|A\s*[.、::)]|B\s*[.、::)]|which\s+(?:year|period|range)|options?)/i; const domainSemantics = { career: /(?:事业|工作|职业|career)/i, education: /(?:教育|学业|学校|education)/i, @@ -120,8 +121,9 @@ function narrativeReferences(value: string): string[] { function isGenericBroadYearChoiceQuestionnaire(value: string): boolean { const distinctYears = unique((value.match(explicitYearPattern) ?? []) .map((year) => year.replace(/\s*年$/, ""))); - return choiceQuestionPattern.test(value) - && (broadYearRangePattern.test(value) || distinctYears.length >= 2); + return proposedYearAlternativesPattern.test(value) + || (choiceQuestionPattern.test(value) + && (broadYearRangePattern.test(value) || distinctYears.length >= 2)); } function proseFields(output: RectificationNarrativeModelOutput): readonly { diff --git a/frontend/tests/conversational-evidence-extractor.test.ts b/frontend/tests/conversational-evidence-extractor.test.ts index a08bf657..720c0779 100644 --- a/frontend/tests/conversational-evidence-extractor.test.ts +++ b/frontend/tests/conversational-evidence-extractor.test.ts @@ -80,6 +80,8 @@ for (const rawText of [ "2021年7月毕业并后来工作", "2021年7月毕业并第二年工作", "2021年7月毕业并此前工作", + "2021年7月毕业然后工作", + "2021年7月毕业后来又工作", ]) { test(`does not propagate a shared date through an unresolved relative clause: ${rawText}`, () => { const evidence = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" }); diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts index 3a2eec23..028fa39e 100644 --- a/frontend/tests/conversational-narrative-agent.test.ts +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -217,6 +217,42 @@ test("rejects generic individual-year options even without a written range", () assert.ok(result.issues.some((issue) => issue.includes("broad-year choice questionnaire"))); }); +for (const prompt of [ + "请提供已经发生的真实事件:2018年还是2021年?也请说明哪一月以及发生了什么。", + "请提供已经发生的真实事件,并说明哪一年:2018年、2021年;也请说明哪一月以及发生了什么。", + "请提供已经发生的真实事件:2018年或2021年,也请说明哪一月以及发生了什么。", +]) { + test(`rejects a rich first turn that proposes multiple years: ${prompt}`, () => { + const output = richOutput(); + assert.ok(output.evidenceRequest); + const invalid = { + ...output, + evidenceRequest: { ...output.evidenceRequest, prompt }, + } satisfies RectificationNarrativeModelOutput; + const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first"); + + assert.equal(result.valid, false); + assert.ok(result.issues.some((issue) => issue.includes("broad-year choice questionnaire"))); + }); +} + +test("accepts a request for one real past event's year and month without proposed years", () => { + const output = richOutput(); + assert.ok(output.evidenceRequest); + const legitimate = { + ...output, + evidenceRequest: { + ...output.evidenceRequest, + prompt: "请提供一件已经发生的真实事件,并说明是哪一年、哪一月以及发生了什么。", + }, + } satisfies RectificationNarrativeModelOutput; + + assert.deepEqual( + validateNarrativeAgainstPacket(legitimate, syntheticTechnicalPacket(), "first"), + { valid: true, issues: [] }, + ); +}); + test("rejects ungrounded layers and references nested in a domain reason", () => { const output = richOutput(); const firstReason = output.domainReasons[0];