diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b7dccc90..2f752ecc 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1293,18 +1293,33 @@ - 复发自:无 - 修复版本:待提交(待生产验收) -## BUG-068 | 生时校正确认词被当成新事件且 Agent 每轮丢失对话历史 +## BUG-068 | 生时校正确认词被当成新事件且重复追问同一日期 - 状态:resolved - 首次发现:2026-07-25 - 最近更新:2026-07-25 - 影响面:生时校正连续问答、事件日期确认、刷新后继续会话 - 用户现象:Agent 问某个事件是否发生在明确年月,用户回答“是的”后,下一轮仍重复询问相同年月;确认词还可能被保存成一条日期待补充的新事件。 -- 触发条件:用户使用确认词、代词或承接上一问的简短自然语言回答,而当前轮没有再次写出完整事件和绝对日期。 -- 根因:叙事模型每轮只收到最新用户文字和事件账本,没有收到同一 case 的持久化 Assistant/User 消息序列;确定性提取器又把无法独立解析的确认词当成新事件,并用自动澄清 follow-up 覆盖模型的自然追问。 -- 修复:复用现有 turn 与 event evidence 存储,每轮向 Agent 注入同一 case 最近 40 条连续问答;确认词优先采用上一条 Agent 明确提出的日期并修正目标事件,无法落到明确日期时不生成伪事件。模型成功返回时,其 follow-up 不再被提取器的自动澄清覆盖;事件账本继续只负责审计、去重和技术候选计算。 -- 验证:新增回归覆盖“明确年月确认问题 → 是的”,断言保存为目标事件的年月修正、没有独立确认词事件,并断言叙事 prompt 末尾连续包含上一条 Assistant 问题和当前 User 回答。 -- 防复发:任何承接式回答必须以持久化会话历史为第一语境;结构化提取只能规范化可确认事实,不得决定 Agent 的下一句话。 +- 触发条件:用户使用确认词、否定词、代词或承接上一问的简短自然语言回答,而当前轮没有再次写出完整事件和绝对日期。 +- 根因:叙事模型原先每轮只收到最新用户文字和事件账本,缺少同一 case 的连续 Assistant/User 历史;即使补回历史,候选日期仍只存在于上一轮可见文案中,没有作为业务状态持久化,确定性提取器仍可能把确认词当成新事件。依赖从 Agent 文案正则反推日期也会随文案改写、语言变化或恢复路径失效。 +- 修复:复用现有 turn 与 event evidence 存储,每轮向 Agent 注入同一 case 最近 40 条连续问答;同时在共享 follow-up 合同和数据库 JSON 校验中持久化 `prompt`、`answerMode` 与 `proposedDate`。Orchestrator 在事件提取前确定性处理 yes/no:肯定时把候选日期作为 append-only correction 合并到目标 evidence,否定时不生成事件并把同一目标切换为开放式日期追问。模型成功返回时,其 follow-up 不再被提取器的自动澄清覆盖;Narrative Agent 拒绝缺少结构化候选、重复已解决问题或继续指向已完成 evidence 的输出。 +- 验证:回归覆盖“2020年10月吗?→是的”、单独“不是”、“不是,是2021年10月”、刷新/resume 后候选日期仍存在,以及同一 actionId 重放不重复写入;断言确认词不成为独立事件、修正 lineage 正确、否定后切换为 `free_text`,并断言叙事 prompt 末尾连续包含上一条 Assistant 问题和当前 User 回答。 +- 防复发:任何承接式回答必须以持久化会话历史为第一语境;确认或否认是否改变业务事实必须由持久化 follow-up 状态和确定性状态机执行,不得再从自然语言文案反推候选日期。 - 相关记录:BUG-032、BUG-063、BUG-067 - 复发自:无 - 修复版本:待提交(本地可测) + +## BUG-069 | 生时校正最终候选被收集阶段约束退回并无限追问 + +- 状态:resolved +- 首次发现:2026-07-25 +- 最近更新:2026-07-25 +- 影响面:生时校正连续评分、候选收敛、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,全部通过。 +- 防复发:必须区分“开始本地评分”“进入外部验证”“允许最终确认”三个阶段;没有新问题可问不得解释为候选无效。只有评分器实际支持的领域可以推进收敛计数,模型提问必须能改变日期、事件身份或评分领域,否则由确定性校验拒绝。 +- 相关记录:BUG-064、BUG-066、BUG-068 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/api/birth-time-conversation/handler.ts b/frontend/src/app/api/birth-time-conversation/handler.ts index 4dc8785b..a3b68635 100644 --- a/frontend/src/app/api/birth-time-conversation/handler.ts +++ b/frontend/src/app/api/birth-time-conversation/handler.ts @@ -30,6 +30,7 @@ import { } from "../../../lib/conversational-rectification/narrative-agent.ts"; import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts"; import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts"; +import { RECTIFICATION_POLICY } from "../../../lib/rectification-policy.ts"; import type { CandidateDifferenceBuild } from "../../../lib/birth-time-dynamic-choice-internal.ts"; import { BirthProfileTimezoneError, @@ -683,7 +684,7 @@ export async function buildProductionConversationalRectificationPacket( input.evidence as readonly LifeEventEvidence[], input.declaredBirthInput.birthDate, ); - const eventScore: CandidateResult | null = events.length >= 3 + const eventScore: CandidateResult | null = events.length >= RECTIFICATION_POLICY.minScoringEvents ? await rectificationPacketStage("score_events", () => engine.scoreEvents({ birthDate: input.declaredBirthInput.birthDate, startTime: baseRange.startTime, @@ -782,28 +783,13 @@ export async function buildProductionConversationalRectificationPacket( const packetEventScore = input.preserveCandidateRange && eventScore ? { ...eventScore, confidence: "low" as const, canApply: false, winningSegment: null } : eventScore; - try { - return { - packet: await buildForRange(selectedRange, packetEventScore), - resultId: eventScore?.resultId ?? null, - }; - } catch (error) { - const selectedWasNarrowed = selectedRange.startTime !== baseRange.startTime - || selectedRange.endTime !== baseRange.endTime; - if (!selectedWasNarrowed - || !(error instanceof technicalPacketModule.RectificationTechnicalPacketRangeError)) { - return rectificationPacketStage("technical_packet", () => Promise.reject(error)); - } - return { - packet: await rectificationPacketStage("technical_packet", () => buildForRange( - baseRange, - eventScore - ? { ...eventScore, confidence: "low", canApply: false, winningSegment: null } - : null, - )), - resultId: null, - }; - } + return { + packet: await rectificationPacketStage( + "technical_packet", + () => buildForRange(selectedRange, packetEventScore), + ), + resultId: eventScore?.resultId ?? null, + }; } function requestedNarrativeModelId(command: ConversationalRectificationCommand): string | undefined { diff --git a/frontend/src/lib/birth-time-evidence.ts b/frontend/src/lib/birth-time-evidence.ts index e4f631dd..21bcee00 100644 --- a/frontend/src/lib/birth-time-evidence.ts +++ b/frontend/src/lib/birth-time-evidence.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { RECTIFICATION_POLICY } from "./rectification-policy.ts"; import type { JourneySnapshot } from "./birth-time-journey.ts"; const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); @@ -128,14 +129,14 @@ export const candidateResultSchema = z.object({ techniqueReceipt: rectificationTechniqueReceiptSchema.optional(), }).strict().readonly().superRefine((value, context) => { const eligible = value.confidence === "high" && highCandidateMeetsSafetyGates(value); - if (value.confidence === "high" && value.eventCount < 4) { + if (value.confidence === "high" && value.eventCount < RECTIFICATION_POLICY.minConfirmationEvents) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["eventCount"], message: "high candidates require at least four effective evidence items", }); } - if (value.confidence === "high" && value.domainCount < 3) { + if (value.confidence === "high" && value.domainCount < RECTIFICATION_POLICY.minConfirmationDomains) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["domainCount"], @@ -149,14 +150,15 @@ export const candidateResultSchema = z.object({ message: "high candidates require a winning segment", }); } - if (value.confidence === "high" && value.winningSegment !== null && value.winningSegment.widthMinutes > 5) { + if (value.confidence === "high" && value.winningSegment !== null + && value.winningSegment.widthMinutes > RECTIFICATION_POLICY.maxConfirmationWidthMinutes) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["winningSegment", "widthMinutes"], message: "high candidate segments cannot exceed five minutes", }); } - if (value.confidence === "high" && value.marginPercent < 20) { + if (value.confidence === "high" && value.marginPercent < RECTIFICATION_POLICY.minConfirmationMarginPercent) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["marginPercent"], @@ -183,10 +185,10 @@ function highCandidateMeetsSafetyGates(value: { }): boolean { return value.confidence === "high" && value.winningSegment !== null - && value.eventCount >= 4 - && value.domainCount >= 3 - && value.winningSegment.widthMinutes <= 5 - && value.marginPercent >= 20; + && value.eventCount >= RECTIFICATION_POLICY.minConfirmationEvents + && value.domainCount >= RECTIFICATION_POLICY.minConfirmationDomains + && value.winningSegment.widthMinutes <= RECTIFICATION_POLICY.maxConfirmationWidthMinutes + && value.marginPercent >= RECTIFICATION_POLICY.minConfirmationMarginPercent; } export class CandidateConfirmationError extends Error { diff --git a/frontend/src/lib/conversational-rectification/contracts.ts b/frontend/src/lib/conversational-rectification/contracts.ts index 8afbea41..50cf4c95 100644 --- a/frontend/src/lib/conversational-rectification/contracts.ts +++ b/frontend/src/lib/conversational-rectification/contracts.ts @@ -17,6 +17,41 @@ const evidenceDomainSchema = z.enum([ "other", ]); +const proposedDateSchema = z.object({ + value: z.string().regex(/^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$/), + precision: z.enum(["year", "month", "day"]), +}).strict(); + +export const rectificationFollowUpSchema = z.object({ + kind: z.enum(["new_event", "event_date", "event_detail"]), + evidenceId: z.string().uuid().nullable(), + answerMode: z.enum(["free_text", "yes_no"]).optional(), + proposedDate: proposedDateSchema.nullable().optional(), +}).strict().superRefine((value, context) => { + if (value.kind === "new_event" && value.evidenceId !== null) { + context.addIssue({ code: "custom", message: "new_event cannot target existing evidence" }); + } + if (value.kind !== "new_event" && value.evidenceId === null) { + context.addIssue({ code: "custom", message: "event follow-up requires evidenceId" }); + } + if (value.answerMode === "yes_no" && (value.kind !== "event_date" || !value.proposedDate)) { + context.addIssue({ code: "custom", message: "date confirmation requires proposedDate" }); + } + if (value.answerMode !== "yes_no" && value.proposedDate) { + context.addIssue({ code: "custom", message: "proposedDate requires yes_no answer mode" }); + } + if (value.proposedDate) { + const expectedParts = value.proposedDate.precision === "year" + ? 1 + : value.proposedDate.precision === "month" ? 2 : 3; + if (value.proposedDate.value.split("-").length !== expectedParts) { + context.addIssue({ code: "custom", message: "proposedDate precision does not match value" }); + } + } +}); + +export type RectificationFollowUp = z.infer; + const boundedNonblankText = (maximum: number) => z.string() .min(1) .max(maximum) @@ -85,11 +120,10 @@ const evidenceRequestSchema = boundedJson(z.object({ domains: z.array(evidenceDomainSchema).min(1).max(4), datePrecision: z.enum(["month_preferred", "year_accepted"]), freeTextAllowed: z.literal(true), + // Optional for turns written before the authored question was persisted. + prompt: boundedNonblankText(1_000).optional(), // Optional for turns written before follow-up state was persisted. - followUp: z.object({ - kind: z.enum(["new_event", "event_date", "event_detail"]), - evidenceId: z.string().uuid().nullable(), - }).strict().optional(), + followUp: rectificationFollowUpSchema.optional(), }).strict(), 2_048); const evidenceRecapEntrySchema = boundedJson(z.object({ diff --git a/frontend/src/lib/conversational-rectification/convergence.ts b/frontend/src/lib/conversational-rectification/convergence.ts index c508ee3a..e2cb970a 100644 --- a/frontend/src/lib/conversational-rectification/convergence.ts +++ b/frontend/src/lib/conversational-rectification/convergence.ts @@ -1,8 +1,20 @@ -import type { RectificationTechnicalPacket } from "./technical-packet.ts"; - -export const MINIMUM_SCOREABLE_EVENTS = 3; +import { RECTIFICATION_POLICY } from "../rectification-policy.ts"; +import type { + RectificationEvidenceDomain, + RectificationTechnicalPacket, +} from "./technical-packet.ts"; const plateauNotePrefix = "range_plateau_count:"; +const systemBlockers = new Set([ + "required_layers_incomplete", + "three_engine_parity_not_passed", + "vedastro_validation_required", + "vedastro_validation_not_passed", + "vedastro_official_response_missing", + "vedastro_minute_snapshot_failed", + "vedastro_minute_sensitive_layers_not_discriminated", + "minute_holdout_not_ready", +]); type CandidateProgress = Readonly<{ rangeStart?: string | null; @@ -36,3 +48,21 @@ export function convergenceNotes(candidate: CandidateProgress, plateauCount: num `${plateauNotePrefix}${plateauCount}`, ]; } + +export function shouldCompleteBoundedResult(input: Readonly<{ + packet: RectificationTechnicalPacket; + scoreableEventCount: number; + scoreableDomainCount: number; + answeredDomains: ReadonlySet; + plateauCount: number; +}>): boolean { + if (input.packet.candidate.status === "ready_for_confirmation" + || input.scoreableEventCount < RECTIFICATION_POLICY.minConfirmationEvents + || input.scoreableDomainCount < RECTIFICATION_POLICY.minConfirmationDomains + || input.packet.suggestedDomains.some((item) => !input.answeredDomains.has(item.domain))) { + return false; + } + if (input.plateauCount >= RECTIFICATION_POLICY.maxPlateauRounds) return true; + const blockers = input.packet.expertWorkflow?.hardBlockers ?? []; + return blockers.length > 0 && blockers.every((blocker) => systemBlockers.has(blocker)); +} diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index b3669201..186bbdc4 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + rectificationFollowUpSchema, + type RectificationFollowUp, +} from "./contracts.ts"; import { projectRectificationTechnicalPacket, type RectificationEvidenceDomain, @@ -15,6 +19,9 @@ export type RectificationConversationMessage = Readonly<{ export type RectificationNarrativeContext = Readonly<{ recentConversation?: ReadonlyArray; latestUserText?: string; + previousAssistantNarrative?: string; + previousEvidencePrompt?: string; + previousFollowUp?: RectificationFollowUp; latestEvidence?: ReadonlyArray<{ id?: string; dateLabel: string; @@ -55,6 +62,9 @@ const broadYearRangePattern = /(?:19|20)\d{2}\s*年?\s*(?:[-–—~~至到\/]| 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 labeledYearChoicesPattern = /A\s*[.、::)]?[\s\S]{0,80}(?:19|20)\d{2}\s*年?[\s\S]{0,120}B\s*[.、::)]?[\s\S]{0,80}(?:19|20)\d{2}\s*年?/i; +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 domainLabels = { career: "事业", education: "学业", @@ -84,10 +94,7 @@ export const rectificationNarrativeOutputSchema = z.object({ domains: z.array(domainSchema).min(1).max(4), datePrecision: z.enum(["month_preferred", "year_accepted"]), prompt: z.string().trim().min(1).max(1_000), - followUp: z.object({ - kind: z.enum(["new_event", "event_date", "event_detail"]), - evidenceId: z.string().uuid().nullable(), - }).strict().default({ kind: "new_event", evidenceId: null }), + followUp: rectificationFollowUpSchema.default({ kind: "new_event", evidenceId: null }), }).strict().nullable(), }).strict(); @@ -99,10 +106,7 @@ export const rectificationNarrativeAuthoredOutputSchema = z.object({ domains: z.array(domainSchema).min(1).max(4).optional(), datePrecision: z.enum(["month_preferred", "year_accepted"]), prompt: z.string().trim().min(1).max(1_000), - followUp: z.object({ - kind: z.enum(["new_event", "event_date", "event_detail"]), - evidenceId: z.string().uuid().nullable(), - }).strict().default({ kind: "new_event", evidenceId: null }), + followUp: rectificationFollowUpSchema.default({ kind: "new_event", evidenceId: null }), }).strict().nullable(), }).strict(); @@ -255,6 +259,7 @@ export function validateNarrativeAgainstPacket( output: RectificationNarrativeModelOutput, packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase = "first", + context: RectificationNarrativeContext = {}, ): NarrativeValidation { void phase; const issues: string[] = []; @@ -292,6 +297,37 @@ export function validateNarrativeAgainstPacket( for (const domain of output.evidenceRequest.domains) { if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`); } + const followUp = output.evidenceRequest.followUp; + if (followUp?.kind === "event_detail" + && packet.scoredHistoricalEvidence.some((item) => item.evidenceId === followUp.evidenceId)) { + issues.push("event detail follow-up targets already scored evidence"); + } + if (proposedDateQuestionPattern.test(output.evidenceRequest.prompt) + && (followUp?.kind !== "event_date" + || followUp.answerMode !== "yes_no" + || !followUp.proposedDate)) { + issues.push("date confirmation prompt lacks structured proposedDate"); + } + } + + const previousFollowUp = context.previousFollowUp; + const latestUserText = context.latestUserText ?? ""; + if (previousFollowUp?.kind === "event_date" && previousFollowUp.answerMode === "yes_no") { + const nextRequest = output.evidenceRequest; + if (affirmativeAnswerPattern.test(latestUserText)) { + if (nextRequest?.followUp?.evidenceId === previousFollowUp.evidenceId) { + issues.push("resolved follow-up still targets completed evidence"); + } + if (context.previousEvidencePrompt + && normalizedQuestion(nextRequest?.prompt ?? "") === normalizedQuestion(context.previousEvidencePrompt)) { + issues.push("repeated resolved follow-up"); + } + } + if (negativeAnswerPattern.test(latestUserText) + && nextRequest?.followUp?.evidenceId === previousFollowUp.evidenceId + && nextRequest.followUp?.answerMode === "yes_no") { + issues.push("repeated rejected follow-up"); + } } const allowedTimes = [candidate.representativeTime, candidate.range.startTime, candidate.range.endTime]; @@ -321,6 +357,13 @@ export function validateNarrativeAgainstPacket( return { valid: uniqueIssues.length === 0, issues: uniqueIssues }; } +function normalizedQuestion(value: string): string { + return value + .normalize("NFKC") + .replace(/[\p{P}\p{S}\s]+/gu, "") + .toLocaleLowerCase("zh-CN"); +} + function grounding(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase) { const projected = projectRectificationTechnicalPacket(packet); const base = { @@ -361,6 +404,9 @@ function narrativeConversationContext(context: RectificationNarrativeContext) { return { recentConversation: context.recentConversation?.slice(-40), latestUserText: context.latestUserText, + previousAssistantNarrative: context.previousAssistantNarrative, + previousEvidencePrompt: context.previousEvidencePrompt, + previousFollowUp: context.previousFollowUp, latestEvidence: context.latestEvidence?.map(({ id, dateLabel, summary }) => ({ id, dateLabel, @@ -529,7 +575,8 @@ function promptFor( useExpertWorkflowAsTechniqueTruth: true, blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed: true, technicalTablesMayAppearWhenRelevant: true, - completeTechnicalTableSummaryRequiredBeforeConfirmation: phase === "final", + completeTechnicalTableSummaryRequiredBeforeConfirmation: phase === "final" + && packet.candidate.status === "ready_for_confirmation", unchangedTechnicalTablesShouldNotBeRepeated: true, privateScoresAndCandidateWeightsMustNeverBeShown: true, internalEventDomainsAndRoutingMustNeverBeShown: true, @@ -544,11 +591,15 @@ function promptFor( continueCurrentEventWhenItRemainsInformative: phase === "intermediate", resolveDateContradictionsBeforeScoring: phase === "intermediate", mergeSameEventDetailsWithoutDoubleCounting: phase === "intermediate", - treatCauseResultAgencyAndNextTransitionAsPartsOfTheCurrentEvent: phase === "intermediate", + onlyAskForDateEventIdentityOrInformationThatCanChangeTheScoringDomain: phase === "intermediate", + doNotAskWhyWhetherVoluntaryOrWhatImpactForAlreadyScoreableEvidence: phase === "intermediate", useEventLedgerToAvoidRepeatingAnsweredQuestions: phase === "intermediate", askForDatesOnlyWhenNeededToIdentifyOrScoreTheEvent: phase !== "final", persistFollowUpState: phase !== "final" - ? "Treat followUp as advisory metadata: use event_detail or event_date with an existing evidenceId when clear; otherwise omit assumptions and use new_event with null evidenceId." + ? "Persist the exact question intent. Use event_detail or event_date with an existing evidenceId. For a yes/no date proposal, set answerMode=yes_no and proposedDate={value,precision}; otherwise use answerMode=free_text and no proposedDate. Use new_event with null evidenceId only for a genuinely new event." + : false, + boundedResultBoundary: phase === "final" && packet.candidate.status === "pending_validation" + ? "当前只支持候选范围,系统验证尚未闭环。本次不会替换当前排盘时间,也不再要求用户继续提供人生事件;evidenceRequest 必须为 null。" : false, domainReasonsMayBeNaturallyParaphrased: true, }, @@ -560,8 +611,10 @@ function fallbackNarrative(packet: RectificationTechnicalPacket, phase: Rectific const candidate = packet.candidate; const nextDomain = packet.suggestedDomains[0]?.domain; const nextLabel = nextDomain ? domainLabels[nextDomain] : "重要经历"; - const phaseLine = phase === "final" + const phaseLine = phase === "final" && candidate.status === "ready_for_confirmation" ? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。" + : phase === "final" + ? "当前证据只能支持候选范围,系统验证尚未闭环;本次不会替换当前排盘时间,也不再强制追问更多人生事件。" : `先说一件已经发生的${nextLabel}事件好吗?尽量写明哪一年、哪一月以及发生了什么。`; return [ `当前仍在核对 ${candidate.range.startTime}–${candidate.range.endTime} 的候选范围,还不能把其中某一分钟当作确定出生时间。`, @@ -739,7 +792,12 @@ export async function generateRectificationNarrative(input: { parseModelOutput(generated.text, input.packet), input.phase, ); - const validation = validateNarrativeAgainstPacket(output, input.packet, input.phase); + const validation = validateNarrativeAgainstPacket( + output, + input.packet, + input.phase, + input.context ?? {}, + ); if (validation.valid) { const narrative = input.phase === "final" ? appendFinalAnalysisTables(output.narrative, input.packet, input.context ?? {}) diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 8c11b75e..277918ac 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -3,6 +3,7 @@ import { conversationalRectificationCommandSchema, conversationalRectificationTurnSchema, type ConversationalRectificationCommand, + type RectificationFollowUp, type ConversationalRectificationTurn, } from "./contracts.ts"; import { ConversationalRectificationError } from "./errors.ts"; @@ -24,13 +25,15 @@ import { } from "./narrative-agent.ts"; import { projectRectificationTechnicalPacket, + type RectificationEvidenceDomain, type RectificationTechnicalPacket, } from "./technical-packet.ts"; import { convergenceNotes, - MINIMUM_SCOREABLE_EVENTS, nextPlateauCount, + shouldCompleteBoundedResult, } from "./convergence.ts"; +import { RECTIFICATION_POLICY } from "../rectification-policy.ts"; import type { ConversationalRectificationBilling } from "./billing.ts"; import { projectLegacyCaseForConversationalImport, @@ -131,8 +134,8 @@ const genericUncertaintyPattern = /(?:不知道|不确定)/; const contextualRelativeMonthPattern = /(?:来年|次年|第二年|翌年|同年|当年|那年)\s*(\d{1,2})\s*月份?/; const contextualBareMonthDayPattern = /^\s*(\d{1,2})\s*月\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/; const contextualBareDayPattern = /^\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/; -const affirmativeAnswerPattern = /^\s*(?:是(?:的)?|对(?:的)?|没错|正确|确认|嗯+|没问题)\s*[。.!!]?\s*$/; -const proposedDatePattern = /((?:19|20)\d{2})\s*年\s*(1[0-2]|0?[1-9])\s*月(?:\s*(3[01]|[12]\d|0?[1-9])\s*(?:日|号))?/g; +const affirmativeAnswerPattern = /^\s*(?:是(?:的)?|对(?:的)?|没错|正确|确认|就是|嗯+|没问题)\s*[。.!!,,]?\s*$/u; +const negativeAnswerPattern = /^\s*(?:不是|不对|错了|并不是|否)\s*[。.!!,,]?\s*$/u; export function evidencePredatesBirthDate( evidence: Pick, @@ -289,6 +292,19 @@ function normalizedEventSemantics(value: string): string { .replace(/[\p{P}\p{S}\s]+/gu, ""); } +const scoringEvidenceDomains = new Set([ + "career", + "education", + "finance", + "health_pressure", + "relocation", + "relationship", +]); + +function hasScoringEvidenceDomain(item: Pick): boolean { + return scoringEvidenceDomains.has(item.domain); +} + function uniqueScoreableLifeEventEvidence( evidence: ReadonlyArray, birthDate: string, @@ -296,6 +312,7 @@ function uniqueScoreableLifeEventEvidence( const seen = new Set(); return effectiveLifeEventEvidence(evidence) .filter((item) => item.scoreable === true + && hasScoringEvidenceDomain(item) && item.extractionStatus !== "needs_clarification" && !evidencePredatesBirthDate(item, birthDate)) .filter((item) => { @@ -327,6 +344,9 @@ function evidenceRecap(evidence: ReadonlyArray) { function narrativeConversationContext(input: Readonly<{ recentConversation?: ReadonlyArray; latestUserText: string; + previousAssistantNarrative?: string; + previousEvidencePrompt?: string; + previousFollowUp?: RectificationFollowUp; allEvidence: ReadonlyArray; newEvidence: ReadonlyArray; }>) { @@ -335,6 +355,9 @@ function narrativeConversationContext(input: Readonly<{ return { recentConversation: input.recentConversation, latestUserText: input.latestUserText.trim().slice(0, 4_000), + previousAssistantNarrative: input.previousAssistantNarrative, + previousEvidencePrompt: input.previousEvidencePrompt, + previousFollowUp: input.previousFollowUp, latestEvidence: evidenceRecap(input.newEvidence).map((item) => ({ id: item.id, dateLabel: item.dateLabel, @@ -384,16 +407,24 @@ function actionsFor(status: "active" | "confirming") { function confirmationGatedPacket( packet: RectificationTechnicalPacket, scoreableEventCount: number, + scoreableDomainCount: number, ): RectificationTechnicalPacket { if (packet.candidate.status !== "ready_for_confirmation" - || scoreableEventCount >= MINIMUM_SCOREABLE_EVENTS) return packet; + || (scoreableEventCount >= RECTIFICATION_POLICY.minConfirmationEvents + && scoreableDomainCount >= RECTIFICATION_POLICY.minConfirmationDomains)) return packet; return { ...packet, candidate: { ...packet.candidate, status: "pending_validation" }, - useBoundary: `当前候选仍需至少 ${MINIMUM_SCOREABLE_EVENTS} 条时间明确、可评分的真实经历验证,不能作为已经校正完成的出生分钟。`, + useBoundary: `当前候选仍需至少 ${RECTIFICATION_POLICY.minConfirmationEvents} 条时间明确、覆盖 ${RECTIFICATION_POLICY.minConfirmationDomains} 个领域的真实经历验证,不能作为已经校正完成的出生分钟。`, }; } +function scoreableDomains( + evidence: ReadonlyArray, +): Set { + return new Set(evidence.map((item) => item.domain)); +} + function turnFromNarrative(input: { readonly caseId: string; readonly turnVersion: number; @@ -409,6 +440,7 @@ function turnFromNarrative(input: { domains: input.narrative.output.evidenceRequest.domains, datePrecision: input.narrative.output.evidenceRequest.datePrecision, freeTextAllowed: true as const, + prompt: input.narrative.output.evidenceRequest.prompt, followUp: input.narrative.output.evidenceRequest.followUp, } : null; @@ -435,6 +467,19 @@ function turnFromNarrative(input: { return parsed.data; } +function boundedResultTurn( + input: Parameters[0], +): ConversationalRectificationTurn { + const turn = turnFromNarrative(input); + return conversationalRectificationTurnSchema.parse({ + ...turn, + status: "completed", + candidate: { ...turn.candidate, status: "pending_validation" }, + evidenceRequest: null, + actions: turn.pendingConsultationQuestion ? ["continue_original_question"] : [], + }); +} + function privateCandidateFromPacket(input: { readonly packet: RectificationTechnicalPacket; readonly resultId: string | null; @@ -524,6 +569,7 @@ function nonScoringTurn(input: { readonly newEvidence: ReadonlyArray; readonly latestUserText: string; readonly authoredNarrative?: RectificationNarrativeResult | null; + readonly followUpOverride?: RectificationFollowUp; readonly correctionReset?: Readonly<{ packet: RectificationTechnicalPacket; reason: CorrectionResetReason; @@ -560,12 +606,13 @@ function nonScoringTurn(input: { domains: authoredRequest.domains, datePrecision: authoredRequest.datePrecision, freeTextAllowed: true as const, - followUp: authoredRequest.followUp, + prompt: authoredRequest.prompt, + followUp: input.followUpOverride ?? authoredRequest.followUp, } : priorRequest ? { ...priorRequest, - followUp: clarificationFollowUp ?? priorRequest.followUp, + followUp: input.followUpOverride ?? clarificationFollowUp ?? priorRequest.followUp, } : null; const parsed = conversationalRectificationTurnSchema.safeParse({ @@ -732,7 +779,7 @@ export function createConversationalRectificationService( evidence: projected.evidence, preserveCandidateRange: true, }); - const gatedPacket = confirmationGatedPacket(computed.packet, 0); + const gatedPacket = confirmationGatedPacket(computed.packet, 0, 0); const narrative = await generateRectificationNarrative({ phase: "first", packet: gatedPacket, @@ -795,14 +842,6 @@ export function createConversationalRectificationService( if (followUp?.kind !== "event_date" && followUp?.kind !== "event_detail") { return command.answer; } - if (followUp.kind === "event_date" && affirmativeAnswerPattern.test(command.answer)) { - const dates = [...current.latestTurn.narrative.matchAll(proposedDatePattern)]; - const proposed = dates.at(-1); - if (proposed) { - const [, year, month, day] = proposed; - return `${year}年${Number(month)}月${day ? `${Number(day)}日` : ""}`; - } - } const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence); const target = followUp.evidenceId ? activeEvidence.find((item) => item.id === followUp.evidenceId) @@ -857,11 +896,67 @@ export function createConversationalRectificationService( return narrativeConversationContext({ recentConversation: [...recentConversation, { role: "user", text: input.latestUserText }], latestUserText: input.latestUserText, + previousAssistantNarrative: input.current.latestTurn.narrative, + previousEvidencePrompt: input.current.latestTurn.evidenceRequest?.prompt, + previousFollowUp: input.current.latestTurn.evidenceRequest?.followUp, allEvidence: input.allEvidence, newEvidence: input.newEvidence, }); } + type StructuredFollowUpResolution = + | Readonly<{ kind: "confirmed"; evidence: readonly LifeEventEvidence[] }> + | Readonly<{ kind: "rejected"; evidence: readonly []; followUp: RectificationFollowUp }> + | null; + + function resolveStructuredFollowUp( + command: CommandOf<"answer">, + current: LoadedConversationalRectificationCase, + ): StructuredFollowUpResolution { + const followUp = current.latestTurn.evidenceRequest?.followUp; + if (followUp?.kind !== "event_date" + || followUp.answerMode !== "yes_no" + || !followUp.evidenceId + || !followUp.proposedDate) { + return null; + } + const target = effectiveLifeEventEvidence(current.eventEvidence) + .find((item) => item.id === followUp.evidenceId); + if (!target) return null; + + if (negativeAnswerPattern.test(command.answer)) { + return { + kind: "rejected", + evidence: [], + followUp: { + kind: "event_date", + evidenceId: target.id, + answerMode: "free_text", + proposedDate: null, + }, + }; + } + if (!affirmativeAnswerPattern.test(command.answer)) return null; + + const merged = extractLifeEventEvidence({ + rawText: `${followUp.proposedDate.value} ${target.eventSummary}`, + sourceTurnId: command.actionId, + asOfDate: ports.asOfDate(), + correctsEvidenceId: target.id, + }); + if (merged.length !== 1) return null; + return { + kind: "confirmed", + evidence: merged.map((item) => ({ + ...item, + rawText: `${target.rawText}\n确认:${command.answer}`, + eventSummary: target.eventSummary, + domain: target.domain, + correctsEvidenceIds: [target.id], + })), + }; + } + async function extractedEvidence( command: CommandOf<"answer">, current: LoadedConversationalRectificationCase, @@ -869,7 +964,8 @@ export function createConversationalRectificationService( let extracted: readonly LifeEventEvidence[]; try { const answerForExtraction = contextualizedAnswer(command, current); - if (affirmativeAnswerPattern.test(command.answer) && answerForExtraction === command.answer) { + if ((affirmativeAnswerPattern.test(command.answer) || negativeAnswerPattern.test(command.answer)) + && answerForExtraction === command.answer) { return []; } extracted = extractLifeEventEvidence({ @@ -1105,7 +1201,7 @@ export function createConversationalRectificationService( privateCandidate: null, evidence: [], }); - const gatedPacket = confirmationGatedPacket(computed.packet, 0); + const gatedPacket = confirmationGatedPacket(computed.packet, 0, 0); const narrative = await generateRectificationNarrative({ phase: "first", packet: gatedPacket, @@ -1198,12 +1294,16 @@ export function createConversationalRectificationService( if (receipt) return receipt; const current = await load(userId, command.caseId); requireMutable(current); + const structuredFollowUp = resolveStructuredFollowUp(command, current); + const extracted = structuredFollowUp?.kind === "confirmed" + ? structuredFollowUp.evidence + : structuredFollowUp?.kind === "rejected" + ? structuredFollowUp.evidence + : await extractedEvidence(command, current); const evidence = evidenceForDeclaredBirthDate( - completeLatestClarification({ - command, - current, - extracted: await extractedEvidence(command, current), - }), + structuredFollowUp?.kind === "confirmed" + ? extracted + : completeLatestClarification({ command, current, extracted }), current.declaredBirthInput.birthDate, ); @@ -1235,6 +1335,7 @@ export function createConversationalRectificationService( } const scoreableEvidence = evidence.filter((item) => item.scoreable === true + && hasScoringEvidenceDomain(item) && item.extractionStatus !== "needs_clarification"); const explicitDirectionChange = explicitDirectionChangePattern.test(command.answer); const directionChange = explicitDirectionChange @@ -1260,6 +1361,7 @@ export function createConversationalRectificationService( const gatedPacket = confirmationGatedPacket( computed.packet, allScoreable.length, + scoreableDomains(allScoreable).size, ); const replacement = evidence[0]; if (!replacement) throw new ConversationalRectificationError("invalid_command"); @@ -1309,7 +1411,16 @@ export function createConversationalRectificationService( return publicTurn(saved); } - const phase = gatedPacket.candidate.status === "ready_for_confirmation" + const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket); + const answeredDomains = scoreableDomains(allScoreable); + const boundedResult = shouldCompleteBoundedResult({ + packet: gatedPacket, + scoreableEventCount: allScoreable.length, + scoreableDomainCount: answeredDomains.size, + answeredDomains, + plateauCount, + }); + const phase = boundedResult || gatedPacket.candidate.status === "ready_for_confirmation" ? "final" as const : "intermediate" as const; const narrative = await generateRectificationNarrative({ @@ -1330,16 +1441,18 @@ export function createConversationalRectificationService( ? computed.resultId : null, iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1, - forceCollecting: gatedPacket.candidate.status !== "ready_for_confirmation", + notes: convergenceNotes(current.privateCandidate, plateauCount), + forceCollecting: boundedResult || gatedPacket.candidate.status !== "ready_for_confirmation", }); - const turn = turnFromNarrative({ + const turnInput = { caseId: command.caseId, turnVersion: command.turnVersion + 1, pendingConsultationQuestion: current.pendingConsultationQuestion, packet: gatedPacket, narrative, evidence: [...current.eventEvidence, ...evidence], - }); + }; + const turn = boundedResult ? boundedResultTurn(turnInput) : turnFromNarrative(turnInput); const saved = await ports.store.saveTurn({ userId, caseId: command.caseId, @@ -1371,6 +1484,7 @@ export function createConversationalRectificationService( const gatedPacket = confirmationGatedPacket( computed.packet, allScoreable.length, + scoreableDomains(allScoreable).size, ); authoredNarrative = await generateRectificationNarrative({ phase: "intermediate", @@ -1394,6 +1508,9 @@ export function createConversationalRectificationService( newEvidence: evidence, latestUserText: command.answer, authoredNarrative, + followUpOverride: structuredFollowUp?.kind === "rejected" + ? structuredFollowUp.followUp + : undefined, }); try { const saved = await ports.store.saveTurn({ @@ -1426,8 +1543,18 @@ export function createConversationalRectificationService( const gatedPacket = confirmationGatedPacket( computed.packet, allScoreable.length, + scoreableDomains(allScoreable).size, ); - const phase = gatedPacket.candidate.status === "ready_for_confirmation" + const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket); + const answeredDomains = scoreableDomains(allScoreable); + const boundedResult = shouldCompleteBoundedResult({ + packet: gatedPacket, + scoreableEventCount: allScoreable.length, + scoreableDomainCount: answeredDomains.size, + answeredDomains, + plateauCount, + }); + const phase = boundedResult || gatedPacket.candidate.status === "ready_for_confirmation" ? "final" as const : "intermediate" as const; const narrative = await generateRectificationNarrative({ @@ -1442,7 +1569,6 @@ export function createConversationalRectificationService( newEvidence: evidence, }), }); - const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket); const privateCandidate = privateCandidateFromPacket({ packet: gatedPacket, resultId: gatedPacket.candidate.status === "ready_for_confirmation" @@ -1450,16 +1576,17 @@ export function createConversationalRectificationService( : null, iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1, notes: convergenceNotes(current.privateCandidate, plateauCount), - forceCollecting: gatedPacket.candidate.status !== "ready_for_confirmation", + forceCollecting: boundedResult || gatedPacket.candidate.status !== "ready_for_confirmation", }); - const narratedTurn = turnFromNarrative({ + const turnInput = { caseId: command.caseId, turnVersion: command.turnVersion + 1, pendingConsultationQuestion: current.pendingConsultationQuestion, packet: gatedPacket, narrative, evidence: [...current.eventEvidence, ...evidence], - }); + }; + const narratedTurn = boundedResult ? boundedResultTurn(turnInput) : turnFromNarrative(turnInput); const saved = await ports.store.saveTurn({ userId, caseId: command.caseId, @@ -1507,7 +1634,11 @@ export function createConversationalRectificationService( evidence: scoreableEvidence, preserveCandidateRange: true, }); - const gatedPacket = confirmationGatedPacket(computed.packet, scoreableEvidence.length); + const gatedPacket = confirmationGatedPacket( + computed.packet, + scoreableEvidence.length, + scoreableDomains(scoreableEvidence).size, + ); const phase = gatedPacket.candidate.status === "ready_for_confirmation" ? "final" as const : activeEvidence.length === 0 ? "first" as const : "intermediate" as const; diff --git a/frontend/src/lib/conversational-rectification/technical-packet.ts b/frontend/src/lib/conversational-rectification/technical-packet.ts index f0714329..1f5569b8 100644 --- a/frontend/src/lib/conversational-rectification/technical-packet.ts +++ b/frontend/src/lib/conversational-rectification/technical-packet.ts @@ -404,7 +404,7 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica (timeToMinute(left.time) - timeToMinute(range.startTime) + 1_440) % 1_440 - (timeToMinute(right.time) - timeToMinute(range.startTime) + 1_440) % 1_440 )); - if (selectedSamples.length < 2) { + if (selectedSamples.length === 0 || (selectedSamples.length === 1 && !eventSegment)) { throw new RectificationTechnicalPacketRangeError("insufficient_samples"); } const layers = layerEvidence(selectedSamples.map((item) => item.sample), input.consultation); @@ -415,9 +415,6 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica && item.values.length > 1 && available.has(item.layer)); const domains = suggestedDomains(sensitiveLayers, selectedSamples); - if (domains.length < 2) { - throw new RectificationTechnicalPacketRangeError("insufficient_domains"); - } const scoredHistoricalEvidence = (input.eventScore?.evidence ?? []).map((item) => ({ evidenceId: item.eventId, domain: eventDomain(item.domain), @@ -497,11 +494,12 @@ export function projectRectificationTechnicalPacket(packet: RectificationTechnic .filter((reference) => reference.trim().length > 0 && reference.length <= 120) .slice(0, 40), }, - evidenceRequest: { - domains: packet.suggestedDomains.map((item) => item.domain), - datePrecision: "month_preferred" as const, - freeTextAllowed: true as const, - }, + evidenceRequest: packet.candidate.status === "ready_for_confirmation" + || packet.suggestedDomains.length === 0 ? null : { + domains: packet.suggestedDomains.map((item) => item.domain), + datePrecision: "month_preferred" as const, + freeTextAllowed: true as const, + }, futureWindows: packet.futureWindows.map((window) => ({ ...window })), }; } diff --git a/frontend/src/lib/rectification-policy.ts b/frontend/src/lib/rectification-policy.ts new file mode 100644 index 00000000..c1ee1a19 --- /dev/null +++ b/frontend/src/lib/rectification-policy.ts @@ -0,0 +1,3 @@ +import policy from "../../../references/rectification_policy.v1.json"; + +export const RECTIFICATION_POLICY = policy; diff --git a/frontend/supabase/migrations/20260725010000_structured_conversational_date_confirmation.sql b/frontend/supabase/migrations/20260725010000_structured_conversational_date_confirmation.sql new file mode 100644 index 00000000..4e692e6c --- /dev/null +++ b/frontend/supabase/migrations/20260725010000_structured_conversational_date_confirmation.sql @@ -0,0 +1,108 @@ +begin; + +-- Persist authored prompts and exact yes/no date proposals so short replies can +-- be resolved without scraping the previous assistant prose. +create or replace function public.conversational_rectification_valid_evidence_request( + p_value jsonb +) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.jsonb_typeof(p_value) = 'object' + and pg_catalog.octet_length(p_value::text) <= 2048 + and public.conversational_rectification_numbers_are_stable(p_value) + and public.conversational_rectification_has_only_keys( + p_value, + array['domains', 'datePrecision', 'freeTextAllowed', 'prompt', 'followUp']::text[] + ) + and p_value ?& array['domains', 'datePrecision', 'freeTextAllowed']::text[] + and pg_catalog.jsonb_typeof(p_value -> 'domains') = 'array' + and pg_catalog.jsonb_array_length(p_value -> 'domains') between 1 and 4 + and not exists ( + select 1 + from pg_catalog.jsonb_array_elements(p_value -> 'domains') domain + where pg_catalog.jsonb_typeof(domain) is distinct from 'string' + or domain #>> '{}' not in ( + 'career', 'education', 'finance', 'health_pressure', 'relocation', + 'relationship', 'family', 'other' + ) + ) + and pg_catalog.jsonb_typeof(p_value -> 'datePrecision') = 'string' + and p_value ->> 'datePrecision' in ('month_preferred', 'year_accepted') + and pg_catalog.jsonb_typeof(p_value -> 'freeTextAllowed') = 'boolean' + and p_value -> 'freeTextAllowed' = 'true'::jsonb + and ( + not (p_value ? 'prompt') + or ( + pg_catalog.jsonb_typeof(p_value -> 'prompt') = 'string' + and pg_catalog.char_length(pg_catalog.btrim(p_value ->> 'prompt')) between 1 and 1000 + ) + ) + and ( + not (p_value ? 'followUp') + or ( + pg_catalog.jsonb_typeof(p_value -> 'followUp') = 'object' + and public.conversational_rectification_has_only_keys( + p_value -> 'followUp', + array['kind', 'evidenceId', 'answerMode', 'proposedDate']::text[] + ) + and (p_value -> 'followUp') ?& array['kind', 'evidenceId']::text[] + and p_value #>> '{followUp,kind}' in ('new_event', 'event_date', 'event_detail') + and ( + not ((p_value -> 'followUp') ? 'answerMode') + or ( + pg_catalog.jsonb_typeof(p_value #> '{followUp,answerMode}') = 'string' + and p_value #>> '{followUp,answerMode}' in ('free_text', 'yes_no') + ) + ) + and ( + not ((p_value -> 'followUp') ? 'proposedDate') + or p_value #> '{followUp,proposedDate}' = 'null'::jsonb + or ( + pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate}') = 'object' + and public.conversational_rectification_has_only_keys( + p_value #> '{followUp,proposedDate}', + array['value', 'precision']::text[] + ) + and (p_value #> '{followUp,proposedDate}') ?& array['value', 'precision']::text[] + and pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate,value}') = 'string' + and pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate,precision}') = 'string' + and p_value #>> '{followUp,proposedDate,value}' + ~ '^[0-9]{4}(-((0[1-9])|(1[0-2]))(-((0[1-9])|([12][0-9])|(3[01])))?)?)?$' + and ( + (p_value #>> '{followUp,proposedDate,precision}' = 'year' + and p_value #>> '{followUp,proposedDate,value}' ~ '^[0-9]{4}$') + or (p_value #>> '{followUp,proposedDate,precision}' = 'month' + and p_value #>> '{followUp,proposedDate,value}' ~ '^[0-9]{4}-((0[1-9])|(1[0-2]))$') + or (p_value #>> '{followUp,proposedDate,precision}' = 'day' + and p_value #>> '{followUp,proposedDate,value}' + ~ '^[0-9]{4}-((0[1-9])|(1[0-2]))-((0[1-9])|([12][0-9])|(3[01]))$') + ) + ) + ) + and ( + (p_value #>> '{followUp,kind}' = 'new_event' + and p_value #>> '{followUp,evidenceId}' is null) + or (p_value #>> '{followUp,kind}' <> 'new_event' + and public.conversational_rectification_valid_uuid_text( + p_value #>> '{followUp,evidenceId}' + )) + ) + and ( + (p_value #>> '{followUp,answerMode}' = 'yes_no' + and p_value #>> '{followUp,kind}' = 'event_date' + and pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate}') = 'object') + or (p_value #>> '{followUp,answerMode}' is distinct from 'yes_no' + and ( + not ((p_value -> 'followUp') ? 'proposedDate') + or p_value #> '{followUp,proposedDate}' = 'null'::jsonb + )) + ) + ) + ); +$$; + +commit; diff --git a/frontend/tests/conversational-convergence.test.ts b/frontend/tests/conversational-convergence.test.ts index c12ea6c5..9c5ca64e 100644 --- a/frontend/tests/conversational-convergence.test.ts +++ b/frontend/tests/conversational-convergence.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { convergenceNotes, nextPlateauCount, + shouldCompleteBoundedResult, } from "../src/lib/conversational-rectification/convergence.ts"; import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts"; @@ -49,3 +50,60 @@ test("a changed range resets background plateau state without completing the ses assert.equal(nextPlateauCount(candidate, packet), 0); }); + +test("a covered 4-event 3-domain plateau completes as a bounded result", () => { + assert.equal(shouldCompleteBoundedResult({ + packet: pendingPacket, + scoreableEventCount: 4, + scoreableDomainCount: 3, + answeredDomains: new Set(["relationship", "finance", "career"]), + plateauCount: 2, + }), true); +}); + +test("an unanswered discriminating domain keeps the conversation active", () => { + assert.equal(shouldCompleteBoundedResult({ + packet: pendingPacket, + scoreableEventCount: 4, + scoreableDomainCount: 3, + answeredDomains: new Set(["relationship", "career", "education"]), + plateauCount: 2, + }), false); +}); + +test("system-only blockers complete a covered range without waiting for another plateau", () => { + const packet = { + ...pendingPacket, + suggestedDomains: [], + expertWorkflow: { hardBlockers: ["required_layers_incomplete", "minute_holdout_not_ready"] }, + } as unknown as RectificationTechnicalPacket; + assert.equal(shouldCompleteBoundedResult({ + packet, + scoreableEventCount: 4, + scoreableDomainCount: 3, + answeredDomains: new Set(["relationship", "career", "education"]), + plateauCount: 0, + }), true); +}); + +test("user-resolvable blockers do not complete a bounded result", () => { + const packet = { + ...pendingPacket, + suggestedDomains: [], + expertWorkflow: { hardBlockers: ["insufficient_events"] }, + } as unknown as RectificationTechnicalPacket; + assert.equal(shouldCompleteBoundedResult({ + packet, + scoreableEventCount: 4, + scoreableDomainCount: 3, + answeredDomains: new Set(["relationship", "career", "education"]), + plateauCount: 2, + }), true, "the completed evidence plateau, not a stale blocker, is decisive"); + assert.equal(shouldCompleteBoundedResult({ + packet, + scoreableEventCount: 4, + scoreableDomainCount: 3, + answeredDomains: new Set(["relationship", "career", "education"]), + plateauCount: 1, + }), false); +}); diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts index 8e745aef..642490b0 100644 --- a/frontend/tests/conversational-narrative-agent.test.ts +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -452,6 +452,11 @@ test("rejects a model-authored event table that exposes private numeric scoring" assert.equal(result.fallbackUsed, true); assert.doesNotMatch(result.narrative, /3\.5|得分 \/ 状态/); + assert.match(result.narrative, /候选范围/); + assert.match(result.narrative, /系统验证尚未闭环/); + assert.match(result.narrative, /不会替换当前排盘时间/); + assert.doesNotMatch(result.narrative, /先说一件|再说一件/); + assert.equal(result.output.evidenceRequest, null); assert.match(result.narrative, /\| 时间 \| 事件 \| 领域 \| 验证状态 \| 结论 \|/); }); @@ -779,6 +784,124 @@ test("does not mistake ordinary machine-readable status words for technical cita ); }); +test("rejects a date confirmation question without structured follow-up state", () => { + const output = richOutput(); + assert.ok(output.evidenceRequest); + const invalid = { + ...output, + evidenceRequest: { + ...output.evidenceRequest, + prompt: "这个10月是2020年10月吗?", + }, + } satisfies RectificationNarrativeModelOutput; + const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "intermediate"); + + assert.equal(result.valid, false); + assert.ok(result.issues.includes("date confirmation prompt lacks structured proposedDate")); +}); + +test("retries when an affirmative answer receives the same resolved date question", async () => { + const evidenceId = "11111111-1111-4111-8111-111111111111"; + const repeatedPrompt = "这个10月是2020年10月吗?"; + const followUp = { + kind: "event_date" as const, + evidenceId, + answerMode: "yes_no" as const, + proposedDate: { value: "2020-10", precision: "month" as const }, + }; + const output = richOutput(); + assert.ok(output.evidenceRequest); + const repeated = { + ...output, + evidenceRequest: { + ...output.evidenceRequest, + prompt: repeatedPrompt, + followUp, + }, + } satisfies RectificationNarrativeModelOutput; + const result = await generateRectificationNarrative({ + phase: "intermediate", + packet: syntheticTechnicalPacket(), + context: { + latestUserText: "是的", + previousEvidencePrompt: repeatedPrompt, + previousFollowUp: followUp, + }, + generator: generator([repeated, output]), + }); + + assert.equal(result.attempts, 2); + assert.notEqual(result.output.evidenceRequest?.prompt, repeatedPrompt); +}); + +test("rejects a follow-up that still targets evidence completed by an affirmative answer", () => { + const evidenceId = "11111111-1111-4111-8111-111111111111"; + const previousFollowUp = { + kind: "event_date" as const, + evidenceId, + answerMode: "yes_no" as const, + proposedDate: { value: "2020-10", precision: "month" as const }, + }; + const output = richOutput(); + assert.ok(output.evidenceRequest); + const invalid = { + ...output, + evidenceRequest: { + ...output.evidenceRequest, + prompt: "这段实习结束后,下一份工作是什么时候开始的?", + followUp: { + kind: "event_detail" as const, + evidenceId, + answerMode: "free_text" as const, + proposedDate: null, + }, + }, + } satisfies RectificationNarrativeModelOutput; + const result = validateNarrativeAgainstPacket( + invalid, + syntheticTechnicalPacket(), + "intermediate", + { latestUserText: "是的", previousFollowUp }, + ); + + assert.equal(result.valid, false); + assert.ok(result.issues.includes("resolved follow-up still targets completed evidence")); +}); + +test("rejects non-scoring detail questions for evidence already used by the scorer", () => { + const evidenceId = "11111111-1111-4111-8111-111111111111"; + const packet: RectificationTechnicalPacket = { + ...syntheticTechnicalPacket(), + scoredHistoricalEvidence: [{ + evidenceId, + domain: "career", + candidateTime: "05:20", + score: 3.5, + ruleRefs: ["vim-md-career"], + }], + }; + const output = richOutput(); + assert.ok(output.evidenceRequest); + const invalid = { + ...output, + evidenceRequest: { + ...output.evidenceRequest, + prompt: "你当时为什么辞职,是主动还是被动,对生活有什么影响?", + followUp: { + kind: "event_detail" as const, + evidenceId, + answerMode: "free_text" as const, + proposedDate: null, + }, + }, + } satisfies RectificationNarrativeModelOutput; + + const result = validateNarrativeAgainstPacket(invalid, packet, "intermediate"); + + assert.equal(result.valid, false); + assert.ok(result.issues.includes("event detail follow-up targets already scored evidence")); +}); + test("retries a grounded validation failure once with a compact packet", async () => { const packet = syntheticTechnicalPacket(); const prompts: string[] = []; diff --git a/frontend/tests/conversational-rectification-contracts.test.ts b/frontend/tests/conversational-rectification-contracts.test.ts index 8adc08ae..123db29e 100644 --- a/frontend/tests/conversational-rectification-contracts.test.ts +++ b/frontend/tests/conversational-rectification-contracts.test.ts @@ -129,6 +129,62 @@ test("accepts only the exact public turn shape", () => { }).success, false); }); +test("persists strict yes/no date confirmation state", () => { + const base = { + caseId, + journeyProtocol: "conversational-evidence-v3", + status: "active", + turnVersion: 1, + narrative: "这个10月是2020年10月吗?", + candidate: { + status: "pending_validation", + representativeTime: "05:21", + rangeStart: "05:10", + rangeEnd: "05:30", + }, + technicalReceipt: { + calculationVersion: "v3.0", + stableLayers: ["D1"], + sensitiveLayers: ["D9"], + candidateDifferenceRefs: ["candidate-difference-1"], + }, + evidenceRequest: { + domains: ["career"], + datePrecision: "month_preferred", + freeTextAllowed: true, + prompt: "这个10月是2020年10月吗?", + followUp: { + kind: "event_date", + evidenceId, + answerMode: "yes_no", + proposedDate: { value: "2020-10", precision: "month" }, + }, + }, + evidenceRecap: [], + actions: ["answer", "pause", "abandon"], + pendingConsultationQuestion: null, + }; + + assert.equal(conversationalRectificationTurnSchema.safeParse(base).success, true); + assert.equal(conversationalRectificationTurnSchema.safeParse({ + ...base, + evidenceRequest: { + ...base.evidenceRequest, + followUp: { ...base.evidenceRequest.followUp, proposedDate: null }, + }, + }).success, false); + assert.equal(conversationalRectificationTurnSchema.safeParse({ + ...base, + evidenceRequest: { + ...base.evidenceRequest, + followUp: { + ...base.evidenceRequest.followUp, + proposedDate: { value: "2020", precision: "month" }, + }, + }, + }).success, false); +}); + function assertNoReachableText(value: unknown, forbidden: string, seen = new Set()) { if (typeof value === "string") { assert.equal(value.includes(forbidden), false, `found raw text in ${value}`); diff --git a/frontend/tests/conversational-rectification-domain-migration.test.ts b/frontend/tests/conversational-rectification-domain-migration.test.ts index 77016a82..9b98888e 100644 --- a/frontend/tests/conversational-rectification-domain-migration.test.ts +++ b/frontend/tests/conversational-rectification-domain-migration.test.ts @@ -37,6 +37,13 @@ const assistantOnlyRegenerateMigration = readFileSync( ), "utf8", ); +const structuredDateConfirmationMigration = readFileSync( + new URL( + "../supabase/migrations/20260725010000_structured_conversational_date_confirmation.sql", + import.meta.url, + ), + "utf8", +); test("durable rectification SQL accepts every application evidence domain", () => { for (const validator of [ @@ -109,3 +116,18 @@ test("durable regenerate turns may persist an assistant-only replacement", () => /if p_user_message is null\s+or/, ); }); + + +test("durable evidence requests persist strict structured date confirmation", () => { + assert.match( + structuredDateConfirmationMigration, + /array\['domains', 'datePrecision', 'freeTextAllowed', 'prompt', 'followUp'\]/, + ); + assert.match( + structuredDateConfirmationMigration, + /array\['kind', 'evidenceId', 'answerMode', 'proposedDate'\]/, + ); + assert.match(structuredDateConfirmationMigration, /'free_text', 'yes_no'/); + assert.match(structuredDateConfirmationMigration, /yes\/no date proposals/i); + assert.match(structuredDateConfirmationMigration, /valid_uuid_text/); +}); diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index be47324a..0996844f 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -25,6 +25,7 @@ const laterActionId = "00000000-0000-4000-8000-000000000710"; const secondAnswerActionId = "00000000-0000-4000-8000-000000000711"; const thirdAnswerActionId = "00000000-0000-4000-8000-000000000712"; const fourthAnswerActionId = "00000000-0000-4000-8000-000000000713"; +const fifthAnswerActionId = "00000000-0000-4000-8000-000000000715"; const secondStartActionId = "00000000-0000-4000-8000-000000000714"; const declaredBirthInput = { @@ -215,6 +216,7 @@ function harness(options: { readonly completeFailures?: number; readonly releaseFailure?: boolean; readonly readyAfterEvidenceCount?: number; + readonly packetForEvidenceCount?: (count: number) => RectificationTechnicalPacket; readonly varyNarrative?: boolean; readonly invalidNarrativeFromGeneration?: number; readonly continueLatestEvent?: boolean; @@ -445,9 +447,12 @@ function harness(options: { if (options.packetFailure && (options.packetFailureFromBuild === undefined || packetBuilds >= options.packetFailureFromBuild)) throw options.packetFailure; - return input.evidence.length >= (options.readyAfterEvidenceCount ?? 1) - ? { packet: packet(true), resultId } - : { packet: packet(false), resultId: null }; + const selectedPacket = options.packetForEvidenceCount?.(input.evidence.length) + ?? packet(input.evidence.length >= (options.readyAfterEvidenceCount ?? 1)); + return { + packet: selectedPacket, + resultId: selectedPacket.candidate.status === "ready_for_confirmation" ? resultId : null, + }; }, narrativeGenerator: validGenerator( events, @@ -613,17 +618,17 @@ test("clear historical evidence is extracted, scored, narrated, recapped, and at caseId: startActionId, actionId: answerActionId, turnVersion: 0, - answer: "2018年6月毕业,2020年3月去外地工作,2022年8月结婚", + answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚", }); assert.equal(value.counts().packetBuilds, 2); assert.equal(turn.status, "confirming"); assert.equal(turn.candidate.status, "ready_for_confirmation"); assert.equal(turn.turnVersion, 1); - assert.equal(turn.evidenceRecap.length, 3); + assert.equal(turn.evidenceRecap.length, 4); const saved = value.cases.get(startActionId)?.row.eventEvidence ?? []; - assert.equal(saved.length, 3); - assert.ok(saved.every((item) => item.rawText === "2018年6月毕业,2020年3月去外地工作,2022年8月结婚")); + assert.equal(saved.length, 4); + assert.ok(saved.every((item) => item.rawText === "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚")); assert.ok(saved.every((item) => item.scoreable === true)); assert.ok(value.events.includes("score-packet")); }); @@ -792,7 +797,7 @@ test("every non-confirmable correction rescans the declared range and withdraws caseId: startActionId, actionId: answerActionId, turnVersion: 0, - answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家", + answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家,2022年8月结婚", }); assert.equal(prior.status, "confirming", scenario.name); const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id; @@ -837,7 +842,7 @@ test("a narrative fallback cannot discard a confirmation candidate produced by a await start(value, null); await value.service.answer(userId, { type: "answer", caseId: startActionId, actionId: answerActionId, - turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家", + turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家,2022年8月结婚", }); const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id; assert.ok(wrongId); @@ -867,7 +872,7 @@ test("a clear one-to-one correction can form a new confirmation candidate only a await start(value, null); await value.service.answer(userId, { type: "answer", caseId: startActionId, actionId: answerActionId, - turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家", + turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家,2022年8月结婚", }); const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id; assert.ok(wrongId); @@ -1179,6 +1184,81 @@ test("a bare month-day answer refines the targeted month without another confirm assert.doesNotMatch(completed.narrative, /是指.*7 月 10|哪一天|哪一年、哪一月/); }); +async function preparedDateConfirmation() { + const value = harness({ readyAfterEvidenceCount: 99 }); + await start(value, null); + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2020年我去石油化工研究院实习,后来主动辞职", + }); + const current = value.cases.get(startActionId)?.row; + const target = current?.eventEvidence.at(-1); + assert.ok(current); + assert.ok(target); + value.cases.set(startActionId, { + row: { + ...current, + latestTurn: { + ...current.latestTurn, + narrative: "这个10月是2020年10月吗?", + evidenceRequest: { + domains: ["career"], + datePrecision: "month_preferred", + freeTextAllowed: true, + prompt: "这个10月是2020年10月吗?", + followUp: { + kind: "event_date", + evidenceId: target.id, + answerMode: "yes_no", + proposedDate: { value: "2020-10", precision: "month" }, + }, + }, + }, + }, + }); + return { value, target, initialEvidenceCount: current.eventEvidence.length }; +} + +test("all supported affirmative tokens confirm the structured proposed date", async () => { + for (const answer of ["对", "没错", "嗯", "确认"]) { + const { value, target, initialEvidenceCount } = await preparedDateConfirmation(); + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer, + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, initialEvidenceCount + 1, answer); + assert.equal(stored.at(-1)?.dateValue, "2020-10", answer); + assert.deepEqual(stored.at(-1)?.correctsEvidenceIds, [target.id], answer); + } +}); + +test("the alternate negative token rejects the proposal without creating evidence", async () => { + const { value, target, initialEvidenceCount } = await preparedDateConfirmation(); + const completed = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "不对", + }); + + assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, initialEvidenceCount); + assert.deepEqual(completed.evidenceRequest?.followUp, { + kind: "event_date", + evidenceId: target.id, + answerMode: "free_text", + proposedDate: null, + }); +}); + test("an affirmative reply confirms the date proposed by the previous Agent turn", async () => { const value = harness({ readyAfterEvidenceCount: 99 }); await start(value, null); @@ -1206,13 +1286,19 @@ test("an affirmative reply confirms the date proposed by the previous Agent turn domains: ["career"], datePrecision: "month_preferred", freeTextAllowed: true, - followUp: { kind: "event_date", evidenceId: target.id }, + prompt: "这个10月是2020年10月吗?", + followUp: { + kind: "event_date", + evidenceId: target.id, + answerMode: "yes_no", + proposedDate: { value: "2020-10", precision: "month" }, + }, }, }, }, }); - await value.service.answer(userId, { + const completed = await value.service.answer(userId, { type: "answer", caseId: startActionId, actionId: secondAnswerActionId, @@ -1225,14 +1311,151 @@ test("an affirmative reply confirms the date proposed by the previous Agent turn assert.deepEqual(stored.at(-1)?.correctsEvidenceIds, [target.id]); assert.equal(stored.at(-1)?.dateValue, "2020-10"); assert.doesNotMatch(stored.at(-1)?.eventSummary ?? "", /^是的$/); + assert.doesNotMatch(completed.narrative, /这个10月是2020年10月吗/); + assert.notEqual(completed.evidenceRequest?.followUp?.evidenceId, target.id); const prompt = JSON.parse(value.narrativePrompts.at(-1) ?? "{}") as { - conversationContext?: { recentConversation?: Array<{ role: string; text: string }> }; + conversationContext?: { + recentConversation?: Array<{ role: string; text: string }>; + previousEvidencePrompt?: string; + previousFollowUp?: { answerMode?: string; proposedDate?: { value: string } }; + }; }; assert.deepEqual(prompt.conversationContext?.recentConversation?.slice(-2), [ { role: "assistant", text: "你说实习到10月份然后辞职,这个10月是2020年10月吗?" }, { role: "user", text: "是的" }, ]); + assert.equal(prompt.conversationContext?.previousEvidencePrompt, "这个10月是2020年10月吗?"); + assert.equal(prompt.conversationContext?.previousFollowUp?.answerMode, "yes_no"); + assert.equal(prompt.conversationContext?.previousFollowUp?.proposedDate?.value, "2020-10"); + + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "是的", + }); + assert.equal( + value.cases.get(startActionId)?.row.eventEvidence.length, + initialEvidenceCount + 1, + "replaying the same action does not duplicate the correction", + ); +}); + +test("a rejected proposed date stays on the same event and switches to free text", async () => { + const value = harness({ readyAfterEvidenceCount: 99 }); + await start(value, null); + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "我去石油化工研究院实习到10月,后来主动辞职", + }); + + const current = value.cases.get(startActionId)?.row; + const target = current?.eventEvidence.at(-1); + assert.ok(current); + assert.ok(target); + value.cases.set(startActionId, { + row: { + ...current, + latestTurn: { + ...current.latestTurn, + narrative: "这个10月是2020年10月吗?", + evidenceRequest: { + domains: ["career"], + datePrecision: "month_preferred", + freeTextAllowed: true, + prompt: "这个10月是2020年10月吗?", + followUp: { + kind: "event_date", + evidenceId: target.id, + answerMode: "yes_no", + proposedDate: { value: "2020-10", precision: "month" }, + }, + }, + }, + }, + }); + + const resumed = await value.service.resume(userId, { + type: "resume", + caseId: startActionId, + actionId: resumeActionId, + turnVersion: 1, + }); + assert.equal(resumed.evidenceRequest?.followUp?.proposedDate?.value, "2020-10"); + + const beforeCount = current.eventEvidence.length; + const completed = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "不是", + }); + + assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, beforeCount); + assert.deepEqual(completed.evidenceRequest?.followUp, { + kind: "event_date", + evidenceId: target.id, + answerMode: "free_text", + proposedDate: null, + }); + assert.doesNotMatch(completed.narrative, /这个10月是2020年10月吗/); +}); + +test("an explicit correction after rejecting a proposed date uses the supplied date", async () => { + const value = harness({ readyAfterEvidenceCount: 99 }); + await start(value, null); + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "我去石油化工研究院实习到10月,后来主动辞职", + }); + + const current = value.cases.get(startActionId)?.row; + const target = current?.eventEvidence.at(-1); + assert.ok(current); + assert.ok(target); + value.cases.set(startActionId, { + row: { + ...current, + latestTurn: { + ...current.latestTurn, + narrative: "这个10月是2020年10月吗?", + evidenceRequest: { + domains: ["career"], + datePrecision: "month_preferred", + freeTextAllowed: true, + prompt: "这个10月是2020年10月吗?", + followUp: { + kind: "event_date", + evidenceId: target.id, + answerMode: "yes_no", + proposedDate: { value: "2020-10", precision: "month" }, + }, + }, + }, + }, + }); + + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "不是,是2021年10月", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.at(-1)?.dateValue, "2021-10"); + assert.deepEqual(stored.at(-1)?.correctsEvidenceIds, [target.id]); + assert.doesNotMatch(stored.at(-1)?.eventSummary ?? "", /^不是/); }); test("an authored event-detail follow-up survives progress decoration and keeps the prior date", async () => { @@ -1411,13 +1634,14 @@ test("a rejected intermediate narrative returns a retryable error without saving assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 0); }); -test("one and two supported events save and narrate before the third accumulated event ranks", async () => { - const value = harness({ readyAfterEvidenceCount: 3 }); +test("one through three supported events save and narrate before the fourth accumulated event ranks", async () => { + const value = harness({ readyAfterEvidenceCount: 4 }); await start(value, null); const answers = [ [answerActionId, "2019年7月毕业"], [secondAnswerActionId, "2020年8月搬家"], [thirdAnswerActionId, "2021年9月换工作"], + [fourthAnswerActionId, "2022年10月结婚"], ] as const; for (const [index, [receivedActionId, answer]] of answers.entries()) { @@ -1431,13 +1655,13 @@ test("one and two supported events save and narrate before the third accumulated const stored = value.cases.get(startActionId)?.row; assert.equal(stored?.eventEvidence.length, index + 1); assert.equal(turn.evidenceRecap.length, index + 1); - assert.equal(turn.status, index < 2 ? "active" : "confirming"); + assert.equal(turn.status, index < 3 ? "active" : "confirming"); assert.ok(turn.narrative.length > 0); assert.doesNotMatch(turn.narrative, /当前累计|本轮已纳入|本轮区分重点|下一步:/); } - assert.equal(value.counts().packetBuilds, 4); - assert.equal(value.events.filter((event) => event === "narrative").length, 4); + assert.equal(value.counts().packetBuilds, 5); + assert.equal(value.events.filter((event) => event === "narrative").length, 5); }); test("intermediate narrative receives the complete active event ledger", async () => { @@ -1464,7 +1688,7 @@ test("intermediate narrative receives the complete active event ledger", async ( assert.match(prompt, /eventLedger/); }); -test("a non-confirmable conversational case remains open after the current discriminating domains are covered", async () => { +test("a plateaued non-confirmable conversational case returns a bounded candidate after current domains are covered", async () => { const value = harness({ readyAfterEvidenceCount: 99 }); await start(value, "请继续回答原来的事业问题"); const answers = [ @@ -1489,12 +1713,96 @@ test("a non-confirmable conversational case remains open after the current discr } } - assert.equal(latest?.status, "active"); + assert.equal(latest?.status, "completed"); assert.equal(latest?.candidate.status, "pending_validation"); - assert.deepEqual(latest?.actions, ["answer", "pause", "abandon"]); - assert.ok(latest?.evidenceRequest); - assert.equal(value.cases.get(startActionId)?.row.status, "active"); + assert.deepEqual(latest?.actions, ["continue_original_question"]); + assert.equal(latest?.evidenceRequest, null); + assert.equal(value.cases.get(startActionId)?.row.status, "completed"); assert.equal(value.cases.get(startActionId)?.row.privateCandidate.representativeTime, "05:20"); + assert.equal(value.cases.get(startActionId)?.row.privateCandidate.resultId, null); +}); + +test("an unanswered suggested domain keeps a plateaued candidate conversational", async () => { + const value = harness({ + packetForEvidenceCount() { + const pending = packet(false); + return { + ...pending, + suggestedDomains: [{ + domain: "finance", + layer: "D2", + reason: "D2 仍需要一条已发生的财务事件区分。", + }], + }; + }, + }); + await start(value, null); + const answers = [ + [answerActionId, "2019年7月毕业"], + [secondAnswerActionId, "2020年8月搬家"], + [thirdAnswerActionId, "2021年9月换工作"], + [fourthAnswerActionId, "2022年10月结婚"], + ] as const; + let latest = value.cases.get(startActionId)?.row.latestTurn; + + for (const [index, [receivedActionId, answer]] of answers.entries()) { + latest = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: receivedActionId, + turnVersion: index, + answer, + }); + } + + assert.equal(latest?.status, "active"); + assert.deepEqual(latest?.evidenceRequest?.domains, ["finance"]); + assert.deepEqual(latest?.actions, ["answer", "pause", "abandon"]); +}); + +test("system-only blockers return a bounded result without waiting for another plateau", async () => { + const value = harness({ + packetForEvidenceCount(count) { + const pending = packet(false); + if (count === 0) return pending; + const range = count >= 4 + ? { startTime: "05:00", endTime: "05:40" } + : pending.candidate.range; + return { + ...pending, + candidate: { ...pending.candidate, range }, + sensitivityScope: { + ...pending.sensitivityScope, + rangeStart: range.startTime, + rangeEnd: range.endTime, + }, + suggestedDomains: [], + expertWorkflow: { + boundary: "not_auto_rectified", + candidateWindows: [{ ...range, status: "pending_validation" }], + techniqueAuditTable: [], + confirmationAllowed: false, + hardBlockers: ["required_layers_incomplete"], + gates: {}, + }, + }; + }, + }); + await start(value, null); + const turn = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2019年7月毕业,2020年8月搬家,2021年9月换工作,2022年10月结婚", + }); + + assert.equal(turn.status, "completed"); + assert.equal(turn.candidate.status, "pending_validation"); + assert.equal(turn.evidenceRequest, null); + assert.deepEqual(turn.actions, []); + assert.equal(turn.candidate.rangeStart, "05:00"); + assert.equal(turn.candidate.rangeEnd, "05:40"); }); test("family evidence remains stored and public without changing its domain", async () => { @@ -1519,17 +1827,19 @@ test("family evidence remains stored and public without changing its domain", as dateLabel: "2020-07", domain: "family", }]); + assert.deepEqual(value.packetEvidenceCounts, [0, 0]); assert.equal(turn.status, "active"); }); -test("two valid events plus pre-birth evidence wait until a later valid third event scores", async () => { - const value = harness({ readyAfterEvidenceCount: 3 }); +test("three valid events plus pre-birth evidence wait until a later valid fourth event confirms", async () => { + const value = harness({ readyAfterEvidenceCount: 4 }); await start(value, null); const answers = [ [answerActionId, "2019年7月毕业"], [secondAnswerActionId, "2020年8月搬家"], [thirdAnswerActionId, "1999年12月开始工作"], [fourthAnswerActionId, "2021年9月换工作"], + [fifthAnswerActionId, "2022年10月结婚"], ] as const; let latest = value.cases.get(startActionId)?.row.latestTurn; @@ -1541,16 +1851,16 @@ test("two valid events plus pre-birth evidence wait until a later valid third ev turnVersion: index, answer, }); - assert.equal(latest.status, index < 3 ? "active" : "confirming"); + assert.equal(latest.status, index < 4 ? "active" : "confirming"); } const stored = value.cases.get(startActionId)?.row; const preBirth = stored?.eventEvidence.find((item) => item.dateValue === "1999-12"); assert.equal(preBirth?.scoreable, false); assert.equal(preBirth?.extractionStatus, "needs_clarification"); - assert.equal(stored?.eventEvidence.length, 4); - assert.equal(latest?.evidenceRecap.length, 4); - assert.deepEqual(value.packetEvidenceCounts, [0, 1, 2, 2, 3]); + assert.equal(stored?.eventEvidence.length, 5); + assert.equal(latest?.evidenceRecap.length, 5); + assert.deepEqual(value.packetEvidenceCounts, [0, 1, 2, 2, 3, 4]); }); test("vague, future, and unmatched answers stay conversational and never score", async () => { @@ -1656,6 +1966,8 @@ test("regenerate rewrites only the current narrative and preserves evidence, sco const evidenceBefore = structuredClone(storedBefore.eventEvidence); const candidateBefore = structuredClone(storedBefore.privateCandidate); const countsBefore = value.counts(); + const scoreableCountBefore = value.packetEvidenceCounts.at(-1); + const scoreableIdsBefore = value.packetEvidenceIds.at(-1); const regenerated = await value.service.regenerate(userId, { type: "regenerate", @@ -1671,7 +1983,8 @@ test("regenerate rewrites only the current narrative and preserves evidence, sco assert.deepEqual(storedAfter.eventEvidence, evidenceBefore); assert.deepEqual(storedAfter.privateCandidate, candidateBefore); assert.equal(value.counts().reserveCount, countsBefore.reserveCount); - assert.equal(value.packetEvidenceCounts.at(-1), evidenceBefore.length); + assert.equal(value.packetEvidenceCounts.at(-1), scoreableCountBefore); + assert.deepEqual(value.packetEvidenceIds.at(-1), scoreableIdsBefore); assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 2); assert.match(value.narrativePrompts.at(-1) ?? "", /2012年12月正式退学/); }); @@ -1745,7 +2058,7 @@ test("receipt-first delayed retries replay the original answer, pause, abandon, caseId: startActionId, actionId: answerActionId, turnVersion: 0, - answer: "2018年6月毕业,2020年3月去外地工作,2022年8月结婚", + answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚", }; return { command, first: await value.service.answer(userId, command) }; }, @@ -1782,7 +2095,7 @@ test("receipt-first delayed retries replay the original answer, pause, abandon, caseId: startActionId, actionId: answerActionId, turnVersion: 0, - answer: "2018年6月毕业,2020年3月去外地工作,2022年8月结婚", + answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚", }); const command = { type: "confirm" as const, @@ -1838,7 +2151,7 @@ test("confirm delegates to the atomic store call, preserves the old baseline unt await start(value, "请继续回答原来的事业问题"); const ready = await value.service.answer(userId, { type: "answer", caseId: startActionId, actionId: answerActionId, - turnVersion: 0, answer: "2018年6月毕业,2020年3月去外地工作,2022年8月结婚", + turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚", }); assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58"); const before = value.mutations.length; diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index 9bf8d288..dff6b017 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -233,7 +233,7 @@ function packetEngine(options: { }, async score() { throw new Error("unexpected questionnaire score"); }, async scoreEvents(input) { - assert.ok(input.events.length >= 3); + assert.ok(input.events.length >= 1); for (const event of input.events) { const birthBoundary = event.precision === "year" ? input.birthDate.slice(0, 4) @@ -824,7 +824,7 @@ test("production unknown-time adapter covers the declared full day with bounded assert.deepEqual(sampleTimes, [...sampleTimes].sort((left, right) => minute(left) - minute(right))); }); -test("production packet waits for three supported events and then scores the accumulated evidence", async () => { +test("production packet rescored after every supported event", async () => { const scoreCalls: LifeEvent[][] = []; const differenceCalls: DifferencePacketInput[] = []; const engine = packetEngine({ scoreCalls, differenceCalls }); @@ -852,16 +852,16 @@ test("production packet waits for three supported events and then scores the acc evidence: evidence.slice(0, count), }); assert.equal(built.packet.candidate.status, "pending_validation"); - assert.equal( - built.resultId, - count < 3 ? null : "00000000-0000-4000-8000-000000000899", - ); + assert.equal(built.resultId, "00000000-0000-4000-8000-000000000899"); } - assert.equal(scoreCalls.length, 1); - assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id)); + assert.equal(scoreCalls.length, 3); assert.deepEqual( - scoreCalls[0]?.map((event) => event.summary), + scoreCalls.map((events) => events.map((event) => event.id)), + [evidence.slice(0, 1), evidence.slice(0, 2), evidence].map((items) => items.map((item) => item.id)), + ); + assert.deepEqual( + scoreCalls[2]?.map((event) => event.summary), evidence.map((item) => item.eventSummary), "event scoring must retain the concrete user-reported fact, not only domain and date", ); @@ -872,12 +872,13 @@ test("production packet waits for three supported events and then scores the acc ); }); -test("production keeps the prior candidate range when a scored segment loses technical discrimination", async () => { +test("production preserves a single-minute winning segment without falling back to the prior range", async () => { const scanCalls: Array<{ readonly birthTime: string; readonly uncertaintyMinutes: number }> = []; const evidence = [ syntheticEvidence(11, "education"), syntheticEvidence(12, "relocation"), syntheticEvidence(13, "career"), + syntheticEvidence(14, "relationship"), ]; const overNarrowed: CandidateResult = { resultId: "00000000-0000-4000-8000-000000000897", @@ -889,15 +890,15 @@ test("production keeps the prior candidate range when a scored segment loses tec representativeTime: "05:20", widthMinutes: 1, }, - eventCount: 3, - domainCount: 3, + eventCount: 4, + domainCount: 4, topScore: 10, secondScore: 1, marginPercent: 90, reasons: ["synthetic over-narrowed segment"], evidence: evidence.map((item) => ({ eventId: item.id, - domain: item.domain as "career" | "education" | "relocation", + domain: item.domain as "career" | "education" | "relocation" | "relationship", candidateTime: "05:20", ruleIds: ["synthetic-rule"], points: 1, @@ -928,13 +929,10 @@ test("production keeps the prior candidate range when a scored segment loses tec assert.deepEqual(scanCalls, [{ birthTime: "1990-01-01 05:20", uncertaintyMinutes: 1, - }, { - birthTime: "1990-01-01 05:20", - uncertaintyMinutes: 30, }]); - assert.deepEqual(built.packet.candidate.range, { startTime: "04:50", endTime: "05:50" }); - assert.equal(built.packet.candidate.status, "pending_validation"); - assert.equal(built.resultId, null); + assert.deepEqual(built.packet.candidate.range, { startTime: "05:20", endTime: "05:20" }); + assert.equal(built.packet.candidate.status, "ready_for_confirmation"); + assert.equal(built.resultId, overNarrowed.resultId); assert.deepEqual( built.packet.scoredHistoricalEvidence.map((item) => item.evidenceId), evidence.map((item) => item.id), @@ -1274,7 +1272,7 @@ test("a single period-only scan filters duplicate and out-of-range samples from assert.deepEqual(built.packet.sensitivityScope.sampleTimes, ["08:00", "08:01", "10:00"]); }); -test("year-precision evidence before birth waits while the birth year can become the valid third event", async () => { +test("year-precision evidence before birth is excluded while every valid accumulated event scores", async () => { const scoreCalls: LifeEvent[][] = []; const engine = packetEngine({ scoreCalls }); const valid = [ @@ -1303,17 +1301,19 @@ test("year-precision evidence before birth waits while the birth year can become ...input, evidence: [...valid, beforeBirth], }); - assert.equal(waiting.resultId, null); - assert.equal(scoreCalls.length, 0); + assert.equal(waiting.resultId, "00000000-0000-4000-8000-000000000899"); + assert.deepEqual(scoreCalls.map((events) => events.map((event) => event.id)), [[ + ...valid.map((item) => item.id), + ]]); await buildProductionConversationalRectificationPacket(engine, { ...input, evidence: [...valid, beforeBirth, birthYear], }); - assert.deepEqual(scoreCalls.map((events) => events.map((event) => event.id)), [[ - ...valid.map((item) => item.id), - birthYear.id, - ]]); + assert.deepEqual(scoreCalls.map((events) => events.map((event) => event.id)), [ + valid.map((item) => item.id), + [...valid.map((item) => item.id), birthYear.id], + ]); }); test("month-precision evidence excludes the month before birth and accepts the birth month", async () => { diff --git a/frontend/tests/conversational-technical-packet.test.ts b/frontend/tests/conversational-technical-packet.test.ts index badd2173..91a7552b 100644 --- a/frontend/tests/conversational-technical-packet.test.ts +++ b/frontend/tests/conversational-technical-packet.test.ts @@ -332,6 +332,50 @@ test("does not claim scan-wide 05:10-05:30 differences inside a 05:16-05:24 cand && error.reason === "insufficient_samples"); }); +test("accepts a single-minute winning segment when no further discriminating domain remains", () => { + const packet = buildRectificationTechnicalPacket({ + scan, + candidateDifferences, + eventScore: { + ...eventScore, + confidence: "high", + canApply: true, + winningSegment: { + startTime: "05:16", + endTime: "05:16", + representativeTime: "05:16", + widthMinutes: 1, + }, + eventCount: 4, + domainCount: 3, + marginPercent: 30, + }, + consultation: { + source: "server_consultation_workflow", + calculationVersion: "rectification-technical-v1", + availableLayers: ["D1", "D9", "D10"], + layerReferences: { + D1: ["consult-d1-ascendant"], + D9: ["consult-d9-candidate-difference"], + D10: ["consult-d10-candidate-difference"], + }, + timeLinkedScanSamples: [ + { sampleIndex: 0, time: "05:10" }, + { sampleIndex: 1, time: "05:16" }, + { sampleIndex: 2, time: "05:17" }, + { sampleIndex: 3, time: "05:30" }, + ], + boundaryDistanceMinutes: 0, + futureWindows: [], + }, + }); + + assert.deepEqual(packet.sensitivityScope.sampleTimes, ["05:16"]); + assert.equal(packet.suggestedDomains.length, 0); + assert.equal(packet.candidate.status, "ready_for_confirmation"); + assert.equal(projectRectificationTechnicalPacket(packet).evidenceRequest, null); +}); + test("uses sparse in-range differences without describing them as adjacent-minute switches", () => { const sparseScan = { ...scan, @@ -422,7 +466,7 @@ test("public projection strips weights, partition identifiers, and private finge rangeEnd: "05:24", sampleTimes: ["05:16", "05:17"], }); - assert.deepEqual(projected.evidenceRequest.domains, ["relationship", "career"]); + assert.deepEqual(projected.evidenceRequest?.domains, ["relationship", "career"]); assert.equal(projected.futureWindows[0]?.scoreable, false); }); diff --git a/references/rectification_policy.v1.json b/references/rectification_policy.v1.json new file mode 100644 index 00000000..e494ae57 --- /dev/null +++ b/references/rectification_policy.v1.json @@ -0,0 +1,10 @@ +{ + "version": "rectification-policy-v1", + "minScoringEvents": 1, + "minConfirmationEvents": 4, + "minConfirmationDomains": 3, + "maxExternalValidationWidthMinutes": 15, + "maxConfirmationWidthMinutes": 5, + "minConfirmationMarginPercent": 20, + "maxPlateauRounds": 2 +} diff --git a/scripts/active_rectification_events.py b/scripts/active_rectification_events.py index fdbb49d3..dd09f046 100644 --- a/scripts/active_rectification_events.py +++ b/scripts/active_rectification_events.py @@ -12,6 +12,21 @@ from collections.abc import Sequence from typing import Any, Final, Literal, NotRequired, TypedDict, assert_never from uuid import NAMESPACE_URL, uuid5 +try: + from scripts.rectification_policy import ( + MAX_CONFIRMATION_WIDTH_MINUTES, + MIN_CONFIRMATION_DOMAINS, + MIN_CONFIRMATION_EVENTS, + MIN_CONFIRMATION_MARGIN_PERCENT, + ) +except ModuleNotFoundError: # pragma: no cover - direct script execution + from rectification_policy import ( + MAX_CONFIRMATION_WIDTH_MINUTES, + MIN_CONFIRMATION_DOMAINS, + MIN_CONFIRMATION_EVENTS, + MIN_CONFIRMATION_MARGIN_PERCENT, + ) + EventPrecision = Literal["year", "month", "day"] EventDomain = Literal[ "education", @@ -253,11 +268,11 @@ def adjudicate_candidate_rows( if reasons: confidence: Confidence = "low" elif ( - event_count >= 4 - and domain_count >= 3 + event_count >= MIN_CONFIRMATION_EVENTS + and domain_count >= MIN_CONFIRMATION_DOMAINS and segment is not None - and segment["width_minutes"] <= 5 - and margin >= 20 + and segment["width_minutes"] <= MAX_CONFIRMATION_WIDTH_MINUTES + and margin >= MIN_CONFIRMATION_MARGIN_PERCENT ): confidence = "high" else: diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 77ca1491..9eaff422 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -41,9 +41,25 @@ if SCRIPTS_DIR not in sys.path: try: from scripts.local_env import load_local_env + from scripts.rectification_policy import ( + MAX_CONFIRMATION_WIDTH_MINUTES, + MAX_EXTERNAL_VALIDATION_WIDTH_MINUTES, + MIN_CONFIRMATION_DOMAINS, + MIN_CONFIRMATION_EVENTS, + MIN_CONFIRMATION_MARGIN_PERCENT, + MIN_SCORING_EVENTS, + ) from scripts.vedastro_runtime_context import temporary_timeout_seconds except ModuleNotFoundError: # pragma: no cover - script execution path from local_env import load_local_env + from rectification_policy import ( + MAX_CONFIRMATION_WIDTH_MINUTES, + MAX_EXTERNAL_VALIDATION_WIDTH_MINUTES, + MIN_CONFIRMATION_DOMAINS, + MIN_CONFIRMATION_EVENTS, + MIN_CONFIRMATION_MARGIN_PERCENT, + MIN_SCORING_EVENTS, + ) from vedastro_runtime_context import temporary_timeout_seconds try: from scripts.unified_consultation_orchestrator import UnifiedConsultationOrchestrator @@ -321,15 +337,39 @@ def _rectification_candidate_ready_for_external_validation(result): except (TypeError, ValueError): return False return ( - int(result.get('event_count') or 0) >= 3 - and int(result.get('domain_count') or 0) >= 2 - and 1 <= width_minutes <= 15 + int(result.get('event_count') or 0) >= MIN_CONFIRMATION_EVENTS + and int(result.get('domain_count') or 0) >= MIN_CONFIRMATION_DOMAINS + and 1 <= width_minutes <= MAX_EXTERNAL_VALIDATION_WIDTH_MINUTES and bool(segment.get('representative_time')) and top_score > second_score and 'missing_mandatory_layers' not in (result.get('reasons') or []) ) +def _rectification_candidate_ready_for_confirmation(result): + """Keep final confirmation stricter than the external-validation entry gate.""" + segment = result.get('winning_segment') + ranking = result.get('candidate_ranking_summary') + if not isinstance(segment, dict) or not isinstance(ranking, list) or len(ranking) < 2: + return False + try: + width_minutes = int(segment.get('width_minutes') or 0) + top_score = float(result.get('top_score')) + second_score = float(result.get('second_score')) + margin_percent = float(result.get('margin_percent') or 0) + except (TypeError, ValueError): + return False + return ( + int(result.get('event_count') or 0) >= MIN_CONFIRMATION_EVENTS + and int(result.get('domain_count') or 0) >= MIN_CONFIRMATION_DOMAINS + and 1 <= width_minutes <= MAX_CONFIRMATION_WIDTH_MINUTES + and bool(segment.get('representative_time')) + and top_score > second_score + and margin_percent >= MIN_CONFIRMATION_MARGIN_PERCENT + and 'missing_mandatory_layers' not in (result.get('reasons') or []) + ) + + def build_evidence_packet_view(job_record: dict | None) -> dict: """Public, token-protected job view. Excludes prompt internals and raw input.""" job_record = job_record or {} @@ -7183,8 +7223,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): lon = self._get_float(body, 'lon', 0, -180, 180) tz = self._get_float(body, 'tz', 0, -14, 14) events = body.get('events') - if not isinstance(events, list) or len(events) < 3: - raise BadRequest('events must contain at least 3 items') + if not isinstance(events, list) or len(events) < MIN_SCORING_EVENTS: + raise BadRequest(f'events must contain at least {MIN_SCORING_EVENTS} item') normalized_events = [] allowed_domains = {'education', 'relocation', 'relationship', 'career', 'finance', 'health_pressure'} formats = {'year': '%Y', 'month': '%Y-%m', 'day': '%Y-%m-%d'} @@ -7238,8 +7278,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'vedastro_reason': 'official_vedastro_runs_after_local_scoring_produces_a_narrow_candidate', 'blockers': [], } - local_candidate_ready = _rectification_candidate_ready_for_external_validation(result) - if high_rigor and local_candidate_ready: + external_validation_candidate_ready = _rectification_candidate_ready_for_external_validation(result) + if high_rigor and external_validation_candidate_ready: from scripts.rectification_three_engine_packet import build_packet representative_time = result['winning_segment']['representative_time'] @@ -7430,6 +7470,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): } from scripts.rectification_technique_contract import build_rectification_technique_contract + local_candidate_ready = _rectification_candidate_ready_for_confirmation(result) result['technique_contract'] = build_rectification_technique_contract( event_count=result.get('event_count', 0), domain_count=result.get('domain_count', 0), diff --git a/scripts/rectification_policy.py b/scripts/rectification_policy.py new file mode 100644 index 00000000..4f6558f3 --- /dev/null +++ b/scripts/rectification_policy.py @@ -0,0 +1,18 @@ +"""Shared birth-time rectification convergence policy.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Final + +_POLICY_PATH = Path(__file__).resolve().parents[1] / "references" / "rectification_policy.v1.json" +POLICY: Final[dict[str, int | str]] = json.loads(_POLICY_PATH.read_text(encoding="utf-8")) + +MIN_SCORING_EVENTS: Final = int(POLICY["minScoringEvents"]) +MIN_CONFIRMATION_EVENTS: Final = int(POLICY["minConfirmationEvents"]) +MIN_CONFIRMATION_DOMAINS: Final = int(POLICY["minConfirmationDomains"]) +MAX_EXTERNAL_VALIDATION_WIDTH_MINUTES: Final = int(POLICY["maxExternalValidationWidthMinutes"]) +MAX_CONFIRMATION_WIDTH_MINUTES: Final = int(POLICY["maxConfirmationWidthMinutes"]) +MIN_CONFIRMATION_MARGIN_PERCENT: Final = int(POLICY["minConfirmationMarginPercent"]) +MAX_PLATEAU_ROUNDS: Final = int(POLICY["maxPlateauRounds"]) diff --git a/scripts/rectification_technique_contract.py b/scripts/rectification_technique_contract.py index 31701ebb..51badb69 100644 --- a/scripts/rectification_technique_contract.py +++ b/scripts/rectification_technique_contract.py @@ -3,6 +3,14 @@ from __future__ import annotations from typing import Any +try: + from scripts.rectification_policy import ( + MIN_CONFIRMATION_DOMAINS, + MIN_CONFIRMATION_EVENTS, + ) +except ModuleNotFoundError: # pragma: no cover - direct script execution + from rectification_policy import MIN_CONFIRMATION_DOMAINS, MIN_CONFIRMATION_EVENTS + def _gate(status: str, reason: str) -> dict[str, str]: return {"status": status, "reason": reason} @@ -21,9 +29,9 @@ def build_rectification_technique_contract( external_validation: dict[str, Any] | None = None, ) -> dict[str, Any]: blockers: list[str] = [] - if event_count < 3: + if event_count < MIN_CONFIRMATION_EVENTS: blockers.append("insufficient_events") - if domain_count < 2: + if domain_count < MIN_CONFIRMATION_DOMAINS: blockers.append("insufficient_domains") neighbor = (stability_diagnostics or {}).get("neighbor_stability") or {} leave_one_out = (stability_diagnostics or {}).get("leave_one_event_out") or {} @@ -38,17 +46,17 @@ def build_rectification_technique_contract( elif external_status != "pass": blockers.extend(external.get("blockers") or ["vedastro_validation_not_passed"]) confirmation_allowed = ( - event_count >= 3 - and domain_count >= 2 + event_count >= MIN_CONFIRMATION_EVENTS + and domain_count >= MIN_CONFIRMATION_DOMAINS and local_candidate_ready and required_layers_complete and high_rigor and external_status == "pass" ) gates = { - "event_quality": _gate("pass" if event_count >= 3 else "fail", "requires_at_least_three_dated_events"), - "cross_domain_coverage": _gate("pass" if domain_count >= 2 else "fail", "requires_at_least_two_event_domains"), - "local_candidate": _gate("pass" if local_candidate_ready else "fail", "requires_a_unique_leading_candidate_range_no_wider_than_fifteen_minutes"), + "event_quality": _gate("pass" if event_count >= MIN_CONFIRMATION_EVENTS else "fail", "requires_confirmation_event_count"), + "cross_domain_coverage": _gate("pass" if domain_count >= MIN_CONFIRMATION_DOMAINS else "fail", "requires_confirmation_domain_count"), + "local_candidate": _gate("pass" if local_candidate_ready else "fail", "requires_final_confirmation_width_and_margin_policy"), "required_layers": _gate("pass" if required_layers_complete else "fail", "all_event_required_layers_must_compute"), "neighbor_stability": _gate("pass" if neighbor.get("all_required_passed") else "diagnostic_fail", "diagnostic_only_unique_lead_at_plus_minus_1_2_5_minutes"), "leave_one_event_out": _gate("pass" if leave_one_out.get("status") == "pass" else "diagnostic_fail", "diagnostic_only_leader_survival_after_removing_each_event"), diff --git a/tests/test_active_rectification_api.py b/tests/test_active_rectification_api.py index 3f8eda0f..72fb4af2 100644 --- a/tests/test_active_rectification_api.py +++ b/tests/test_active_rectification_api.py @@ -537,6 +537,7 @@ def test_high_rigor_event_rectification_requires_real_vedastro_candidate_discrim }, {"id": "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", "domain": "career", "date": "2019-07-01", "precision": "day"}, {"id": "0ef52e51-ab5f-453b-81e5-adb44a929224", "domain": "relationship", "date": "2021", "precision": "year"}, + {"id": "300c1c47-c223-4b40-8e27-47b3f6902795", "domain": "finance", "date": "2022-06", "precision": "month"}, ], } ) @@ -701,9 +702,11 @@ def test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow(mon assert result["three_engine_packet"]["vedastro"]["search_events_role"] == "background_only" assert result["technique_contract"]["gates"]["vedastro_minute_sensitive_validation"]["status"] == "pass" assert validation["minute_sensitive_validation"]["discriminated"] is True - assert result["technique_contract"]["confirmation_allowed"] is True - assert result["technique_contract"]["decision"] == "confirm_minute" - assert result["can_apply"] is True + assert result["margin_percent"] < 20 + assert result["technique_contract"]["confirmation_allowed"] is False + assert result["technique_contract"]["decision"] == "continue_rectification" + assert result["can_apply"] is False + assert "local_candidate_not_ready" in result["technique_contract"]["hard_blockers"] assert "neighbor_stability_not_passed" not in result["technique_contract"]["hard_blockers"] assert "leave_one_event_out_not_passed" not in result["technique_contract"]["hard_blockers"] @@ -838,6 +841,7 @@ def test_search_events_difference_cannot_override_identical_minute_snapshots(mon {"id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", "domain": "education", "date": "2011-09", "precision": "month"}, {"id": "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", "domain": "career", "date": "2019-07-01", "precision": "day"}, {"id": "0ef52e51-ab5f-453b-81e5-adb44a929224", "domain": "relationship", "date": "2021", "precision": "year"}, + {"id": "300c1c47-c223-4b40-8e27-47b3f6902795", "domain": "finance", "date": "2022-06", "precision": "month"}, ], } )