diff --git a/.github/workflows/apply-production-rectification-migrations.yml b/.github/workflows/apply-production-rectification-migrations.yml index 2a296509..37e7231e 100644 --- a/.github/workflows/apply-production-rectification-migrations.yml +++ b/.github/workflows/apply-production-rectification-migrations.yml @@ -66,6 +66,7 @@ jobs: frontend/supabase/migrations/20260724010000_global_birth_locations.sql \ frontend/supabase/migrations/20260724020000_align_global_birthplace_rectification_contract.sql \ frontend/supabase/migrations/20260724030000_allow_assistant_only_rectification_regenerate.sql \ + frontend/supabase/migrations/20260725010000_structured_conversational_date_confirmation.sql \ "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_DIR/" - name: Check or apply reviewed migrations @@ -116,7 +117,8 @@ jobs: "$REMOTE_DIR/20260723030000_align_conversational_follow_up_request.sql" \ "$REMOTE_DIR/20260724010000_global_birth_locations.sql" \ "$REMOTE_DIR/20260724020000_align_global_birthplace_rectification_contract.sql" \ - "$REMOTE_DIR/20260724030000_allow_assistant_only_rectification_regenerate.sql" + "$REMOTE_DIR/20260724030000_allow_assistant_only_rectification_regenerate.sql" \ + "$REMOTE_DIR/20260725010000_structured_conversational_date_confirmation.sql" do filename="$(basename "$sql_file")" checksum="$(sha256sum "$sql_file" | awk '{print $1}')" @@ -168,4 +170,3 @@ jobs: echo "production migration state is current" fi REMOTE - diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2f752ecc..aaaf4103 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1317,9 +1317,24 @@ - 影响面:生时校正连续评分、候选收敛、VedAstro 外部验证与有限结果终止 - 用户现象:用户连续提供多条真实经历后,本地候选已经缩小到单分钟或极窄范围,系统仍可能退回原始范围并继续追问;提问还可能停留在“为什么辞职、主动还是被动、造成什么影响”等不会改变当前评分的细节。 - 触发条件:Technical Packet 收到单分钟候选或不足两个新建议领域;本地候选宽度已经适合外部验证但最终 margin 尚未达到确认阈值;事件达到旧的 3 条/2 领域门槛却未达到前端 4 条/3 领域门槛;候选连续多轮不再变化;或 `family/other` 背景事件被编排层计入评分覆盖。 -- 根因:收集阶段和最终确认阶段共用了错误门槛。Technical Packet 把“没有足够的新问题可问”当成候选无效,Orchestrator 又把窄候选失败静默替换为 `baseRange`;评分、技法合同和 Candidate Schema 分别使用 3/2 与 4/3 门槛,前两条事件不进入评分;plateau 只记录不终止,系统验证阻塞继续被转换成用户问题。外部验证入口还被误收紧为最终确认所需的 `width <= 5` 且 `margin >= 20%`。同时 `family/other` 虽不进入 Python 评分器,却仍帮助编排层满足事件数和领域数;Agent 也继续追问不参与当前评分的原因、主动性和影响。 -- 修复:建立前后端共享的收敛策略:第一条有效事件即评分,最终确认统一要求至少 4 条事件、3 个可评分领域、候选宽度不超过 5 分钟且 margin 不低于 20%。Technical Packet 接受单分钟和零建议领域;删除窄候选失败后退回 `baseRange` 的路径。候选在证据覆盖后连续两轮不变时结束为 `completed + pending_validation`,仅剩系统验证阻塞时直接结束有限结果,不再追问用户。外部验证单独使用最多 15 分钟的入口宽度,只要求唯一领先和必需层完整,不再提前要求最终 margin。编排层统一排除 `family/other` 的评分与确认计数,但继续持久化并展示为背景。Narrative Agent 的提示词和确定性校验同时禁止对已经进入评分的事件继续追问无计算价值详情。 -- 验证:前端 7 个聚焦测试文件共 158 项通过,覆盖结构化日期确认、单事件起评、单分钟候选、plateau/系统阻塞终止、家庭背景不进入评分计数和已评分事件详情追问拦截。Python 聚焦测试覆盖长对话窄化后恢复 VedAstro 调用、4 条/3 领域统一门槛、官方分钟快照判别与 Technique Contract,全部通过。 +- 根因:收集阶段和最终确认阶段共用了错误门槛。Technical Packet 把“没有足够的新问题可问”当成候选无效,Orchestrator 又把窄候选失败静默替换为 `baseRange`;评分、技法合同和 Candidate Schema 分别使用 3/2 与 4/3 门槛,前两条事件不进入评分;plateau 只记录不终止,系统验证阻塞继续被转换成用户问题。外部验证入口还被误收紧为最终确认所需的 `width <= 5` 且 `margin >= 20%`。同时 `family/other` 虽不进入 Python 评分器,却仍帮助编排层满足事件数和领域数;Agent 也继续追问不参与当前评分的原因、主动性和影响。原确定性校验只识别正确标注为 `event_detail` 的请求,模型可把同一详情问题错标成 `new_event` 绕过;最终轮也只靠提示词要求 `evidenceRequest=null`。 +- 修复:建立前后端共享的收敛策略:第一条有效事件即评分,最终确认统一要求至少 4 条事件、3 个可评分领域、候选宽度不超过 5 分钟且 margin 不低于 20%。Technical Packet 接受单分钟和零建议领域;删除窄候选失败后退回 `baseRange` 的路径。候选在证据覆盖后连续两轮不变时结束为 `completed + pending_validation`,仅剩系统验证阻塞时直接结束有限结果,不再追问用户。外部验证单独使用最多 15 分钟的入口宽度,只要求唯一领先和必需层完整,不再提前要求最终 margin。编排层统一排除 `family/other` 的评分与确认计数,但继续持久化并展示为背景。Narrative Agent 的共享校验器禁止对已评分事件继续追问无计算价值详情,包括错标为 `new_event` 的详情语义;最终轮任何非空 `evidenceRequest` 都会触发重试,连续失败后才使用既有无追问 fallback。 +- 验证:前端聚焦测试覆盖结构化日期确认、单事件起评、单分钟候选、plateau/系统阻塞终止、家庭背景不进入评分计数、已评分事件详情错标重试,以及最终轮非空 `evidenceRequest` 重试。Python 聚焦测试覆盖长对话窄化后恢复 VedAstro 调用、4 条/3 领域统一门槛、官方分钟快照判别与 Technique Contract。 - 防复发:必须区分“开始本地评分”“进入外部验证”“允许最终确认”三个阶段;没有新问题可问不得解释为候选无效。只有评分器实际支持的领域可以推进收敛计数,模型提问必须能改变日期、事件身份或评分领域,否则由确定性校验拒绝。 - 相关记录:BUG-064、BUG-066、BUG-068 - 修复版本:待提交(本地可测) + +## BUG-070 | 结构化日期确认迁移未接入生产迁移工作流 + +- 状态:resolved +- 首次发现:2026-07-25 +- 最近更新:2026-07-25 +- 影响面:生产环境生时校正 follow-up 持久化、日期候选确认与数据库约束 +- 用户现象:代码已写入 `prompt`、`answerMode` 和 `proposedDate`,但生产迁移工作流不会上传或执行对应 migration;生产数据库仍可能按旧 JSON 合同拒绝新 turn。 +- 触发条件:从 GitHub Actions 手动执行生产生时校正 migration 的 `check` 或 `apply` 操作。 +- 根因:新增 `20260725010000_structured_conversational_date_confirmation.sql` 后,没有同步加入工作流的 `scp` 上传列表和远端顺序执行列表。 +- 修复:将该 migration 同时加入上传与执行列表,继续复用现有 checksum、迁移账本和单事务执行保护。 +- 验证:静态回归断言 migration 文件名在生产工作流中恰好出现两次,分别覆盖上传和执行路径。 +- 防复发:新增需要生产执行的 migration 时,必须同时更新受审生产迁移清单并由测试覆盖上传和执行两处引用。 +- 相关记录:BUG-068、BUG-069 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index 186bbdc4..78470183 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -65,6 +65,7 @@ const labeledYearChoicesPattern = /A\s*[.、::)]?[\s\S]{0,80}(?:19|20)\d{2}\s* const affirmativeAnswerPattern = /^\s*(?:是(?:的)?|对(?:的)?|没错|正确|确认|就是|嗯+|没问题)\s*[。.!!,,]?\s*$/u; const negativeAnswerPattern = /^\s*(?:不是|不对|错了|并不是|否)\s*[。.!!,,]?\s*$/u; const proposedDateQuestionPattern = /(?:19|20)\d{2}\s*年(?:\s*(?:1[0-2]|0?[1-9])\s*月)?(?:\s*(?:3[01]|[12]\d|0?[1-9])\s*(?:日|号))?[\s\S]{0,30}(?:吗|是否|是不是|确认|对不对|正确)/u; +const nonScoringDetailPattern = /为什么|原因|主动|被动|自愿|被迫|影响|感受|具体(?:体现|情况|经过)|哪些方面|正式工作|实习|兼职/u; const domainLabels = { career: "事业", education: "学业", @@ -261,9 +262,11 @@ export function validateNarrativeAgainstPacket( phase: RectificationNarrativePhase = "first", context: RectificationNarrativeContext = {}, ): NarrativeValidation { - void phase; const issues: string[] = []; const candidate = packet.candidate; + if (phase === "final" && output.evidenceRequest !== null) { + issues.push("final narrative must not request more evidence"); + } if (output.candidateStatus !== candidate.status) { issues.push(`candidateStatus ${output.candidateStatus} is not packet-grounded`); } @@ -302,6 +305,13 @@ export function validateNarrativeAgainstPacket( && packet.scoredHistoricalEvidence.some((item) => item.evidenceId === followUp.evidenceId)) { issues.push("event detail follow-up targets already scored evidence"); } + const latestActiveEvidenceId = context.eventLedger?.filter((item) => item.active).at(-1)?.id; + if (followUp?.kind === "new_event" + && latestActiveEvidenceId + && nonScoringDetailPattern.test(output.evidenceRequest.prompt) + && packet.scoredHistoricalEvidence.some((item) => item.evidenceId === latestActiveEvidenceId)) { + issues.push("new-event follow-up disguises detail about already scored evidence"); + } if (proposedDateQuestionPattern.test(output.evidenceRequest.prompt) && (followUp?.kind !== "event_date" || followUp.answerMode !== "yes_no" diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts index 642490b0..4c431f8d 100644 --- a/frontend/tests/conversational-narrative-agent.test.ts +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -1024,6 +1024,77 @@ test("uses a fresh second provider request after the first attempt times out", a assert.notEqual(signals[0], signals[1]); }); +test("retries when a final narrative asks for more evidence", async () => { + const invalid = richOutput(); + const valid = { + ...richOutput(), + narrative: "当前证据只能支持候选范围,本次不再要求继续提供人生事件。", + evidenceRequest: null, + } satisfies RectificationNarrativeModelOutput; + const result = await generateRectificationNarrative({ + phase: "final", + packet: syntheticTechnicalPacket(), + generator: generator([invalid, valid]), + }); + + assert.equal(result.attempts, 2); + assert.equal(result.fallbackUsed, false); + assert.equal(result.output.evidenceRequest, null); +}); + +test("retries a scored-event detail question mislabeled as new_event", async () => { + const evidenceId = "00000000-0000-4000-8000-000000000709"; + const packet = { + ...syntheticTechnicalPacket(), + scoredHistoricalEvidence: [{ + evidenceId, + domain: "education" as const, + candidateTime: "05:20", + score: 8, + ruleRefs: ["synthetic-education-rule"], + }], + }; + const invalid = { + ...richOutput(), + evidenceRequest: { + domains: ["career" as const], + datePrecision: "month_preferred" as const, + prompt: "这几个月里,学业压力具体体现在哪些方面,主要原因是什么?", + followUp: { kind: "new_event" as const, evidenceId: null }, + }, + }; + const valid = { + ...richOutput(), + evidenceRequest: { + domains: ["career" as const], + datePrecision: "month_preferred" as const, + prompt: "请再说一件已经发生的事业变化,并写明哪一年、哪一月。", + followUp: { kind: "new_event" as const, evidenceId: null }, + }, + }; + const result = await generateRectificationNarrative({ + phase: "intermediate", + packet, + context: { + eventLedger: [{ + id: evidenceId, + rawText: "1972年12月因为学业压力正式退学", + dateLabel: "1972-12", + summary: "因为学业压力正式退学", + domain: "education", + extractionStatus: "clear", + active: true, + correctsEvidenceIds: [], + }], + }, + generator: generator([invalid, valid]), + }); + + assert.equal(result.attempts, 2); + assert.equal(result.fallbackUsed, false); + assert.equal(result.output.evidenceRequest?.prompt, valid.evidenceRequest.prompt); +}); + test("records first-turn generation timeouts separately from schema failures", async () => { const warnings: string[] = []; const originalWarn = console.warn; diff --git a/frontend/tests/conversational-rectification-domain-migration.test.ts b/frontend/tests/conversational-rectification-domain-migration.test.ts index 9b98888e..6f54342d 100644 --- a/frontend/tests/conversational-rectification-domain-migration.test.ts +++ b/frontend/tests/conversational-rectification-domain-migration.test.ts @@ -44,6 +44,13 @@ const structuredDateConfirmationMigration = readFileSync( ), "utf8", ); +const productionMigrationWorkflow = readFileSync( + new URL( + "../../.github/workflows/apply-production-rectification-migrations.yml", + import.meta.url, + ), + "utf8", +); test("durable rectification SQL accepts every application evidence domain", () => { for (const validator of [ @@ -131,3 +138,10 @@ test("durable evidence requests persist strict structured date confirmation", () assert.match(structuredDateConfirmationMigration, /yes\/no date proposals/i); assert.match(structuredDateConfirmationMigration, /valid_uuid_text/); }); + +test("production workflow uploads and applies the structured date confirmation migration", () => { + assert.equal( + productionMigrationWorkflow.match(/20260725010000_structured_conversational_date_confirmation\.sql/g)?.length, + 2, + ); +}); diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index 0996844f..56c19a54 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -1498,7 +1498,7 @@ test("an authored event-detail follow-up survives progress decoration and keeps assert.doesNotMatch(completed.narrative, /大致是什么年月|只记得年份/); }); -test("a mislabeled new-event follow-up never lets program heuristics rewrite an undated reply as a correction", async () => { +test("an unscored event detail mislabeled as new_event is not heuristically merged as a correction", async () => { const value = harness({ readyAfterEvidenceCount: 99, continueLatestEvent: true,