diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 657c8eac..329fcca0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1553,28 +1553,28 @@ - 状态:investigating - 首次发现:2026-07-27 -- 最近更新:2026-07-27 -- 影响面:生时校正 V4 聊天界面、历史恢复、模型选择、下一问规划与 staging 验收 +- 最近更新:2026-07-28 +- 影响面:生时校正聊天 Surface、事件语义、后台 Job、候选计算、诊断、Reasoner、Renderer 与持久化主链 - 用户现象:进入生时校正后看到独立的校正面板、证据区域和固定问题;交互不像普通 session,领域也不再根据用户刚讲的经历动态选择。 -- 触发条件:V4 页面入口渲染旧式 `RectificationV4Panel` 视觉结构,页面给会话容器添加 `is-rectification`,同时问题规划器按硬编码领域顺序和模板生成下一问。 -- 根因:组件 wrapper 无条件绕过原普通聊天 Surface;普通 session CSS 又显式排除 `is-rectification`;`question-planner.ts` 把教育、迁移、关系、事业、财务、健康压力和家庭写成固定顺序与固定文案,测试还把这些实现细节当成产品合同。 -- 修复:V4 复用普通 session 的消息列表、输入框和模型选择器,并从持久化 turns 恢复完整对话;回答时原子保存所选模型 ID,Worker 将完整 turns、事件台账、日期精度、已追问事件与候选范围交给模型动态生成下一问。确定性 planner 只保留日期修订和开放叙述降级,不再轮询领域或输出固定问卷;候选范围仍不得表述为已确认出生分钟。 -- 验证:聚焦 V4/domain/service/replay/handoff/migration、普通 session UI 合同和 consultation entrypoint 共 59 个测试通过;staging 构建、迁移和登录态 smoke 完成后更新为 resolved 并填写精确提交与部署 SHA。 -- 防复发:可见生时校正必须复用普通聊天 Surface;测试应锁定自然语言消息、turn 恢复、模型 ID 传递和无固定领域控件,不得锁定领域顺序或问题模板。模型只负责选择和表达下一条高信息量问题,证据修订、评分、稳定性门、范围接受、handoff 与扣费继续由确定性后端负责。 -- 相关记录:BUG-020、BUG-075、BUG-080、BUG-081、BUG-082、BUG-083、BUG-084 -- 修复版本:待提交(staging 验收中) +- 触发条件:旧 V4 既在界面层使用独立校正结构,又让 `question-planner.ts` 和 `question-author.ts` 直接决定领域顺序与问题文案;模型只负责写下一问,后台没有形成完整 Agent 决策闭环。 +- 根因:产品状态被压缩成“下一问字符串”,事件语义、候选特征、诊断结果、问题机会、模型决策和公开消息之间没有受约束的 durable contract;因此即使替换提示词,系统仍会沿用问卷式控制流,且无法审计模型为何选题或安全重放已完成 Job。 +- 修复:删除旧 `question-planner.ts` 与 `question-author.ts`,将回答处理重构为完整 V5 主链:保存回答并创建后台 Job → Evidence Reconciliation → Candidate Engine / Feature Snapshot → Diagnostics → Opportunity Builder → Bounded Reasoner → Decision Validator → Renderer → Atomic Job Completion。可见层继续复用普通 session 聊天 Surface;Reasoner 只能选择服务端生成的 opportunity 或受约束动作,不能注入分钟、分数、事件或任意问题;Renderer 只表达已验证决定,候选范围不得表述为已确认出生分钟。Agent Run、Public Message、Diagnostics、Feature Snapshot、Pending Evidence 和事件修订均作为一等产物持久化。 +- 验证:67 个 TypeScript 聚焦合同全部通过,覆盖普通 session UI、完整 V5 artifact chain、Reasoner 单次诊断预算、Opportunity 选择、shadow/legacy 隔离和 range-only 输出;7 个 Python 服务合同通过。真实 PostgreSQL 14 已按 V4 → V5 顺序完成 migration dry-run,并跑通 `processing → reasoning → rendering → complete`、五类 artifact 各一条落库和 completed Job 幂等重放。`tsc --noEmit` 未出现 V5 新错误,只剩 `birth-time-journey-engine`、`identity-auth-integration`、`onboarding-route` 三处无关基线错误。当前完成边界为本地可测,尚未提交、推送、迁移 staging 或执行登录态 smoke。 +- 防复发:生时校正不得再次把模型降级为“问题文案生成器”;所有可见动作必须来自 server-owned opportunity,经 bounded reasoner、decision validator 和 renderer 后原子持久化。测试必须同时锁定 legacy/shadow 隔离、artifact 完整性、候选范围边界和 completed-job replay 指纹。 +- 相关记录:BUG-020、BUG-075、BUG-080、BUG-081、BUG-082、BUG-083、BUG-084、BUG-086 +- 修复版本:本地 V5 重构,待提交与 staging 验收 ## BUG-086 | 模型下一问可绕过当前事件而跳成领域问卷 - 状态:investigating - 首次发现:2026-07-27 -- 最近更新:2026-07-27 -- 影响面:生时校正 V4 的模型提问规划、事件日期补全和 staging 对话体验 +- 最近更新:2026-07-28 +- 影响面:生时校正 V5 的当前事件延续、问题机会构建、诊断工具预算、模型决策验证和 Job replay - 用户现象:用户回答“2016 年离家去外地上大学”后,下一问直接变成“请说一次影响较大的搬家或长期迁居”,看起来仍按“升学 → 搬家”模板轮询,而没有承接刚才的具体经历。 -- 触发条件:最新可评分事件只有年份精度,但模型返回新的领域和空 `targetEventId`;Worker 直接接受格式合法的模型结果。 -- 根因:模型提示虽然要求优先延续当前事件,但 Worker 只校验了输出结构,没有把确定性 planner 识别出的必要日期补全当作服务端路由约束;因此模型可越过仍缺月份的当前事件。旧测试只证明模型拿到了完整上下文,没有覆盖模型违反路由建议的情况。 -- 修复:planner 将月份视为足够的首选精度;年份、季度或范围精度仍产生必要的当前事件补全。问题作者收到 `requiredContinuation`,必须围绕该事件自然追问月份或日期;Worker 在信任边界拒绝模型切换事件或领域,并回退到同一事件的开放式日期追问。当前事件达到月份精度后,模型才可根据上下文自由选择下一条高信息量问题,不设领域顺序。 -- 验证:新增用户原句回归,模拟模型错误返回搬家问题,断言 Worker 仍追问“离家去外地上大学”的月份且不出现搬家模板;同时锁定月份精度后模型可自由选题。聚焦 domain/service/replay 共 18 个测试通过;staging 部署与真实登录态 smoke 完成后更新状态。 -- 防复发:模型可以表达和选择下一题,但不能绕过服务端判定的当前事件必要补全;测试必须包含“模型输出合法但路由错误”的对抗用例,不能只测 happy path。 +- 触发条件:当前事件仍缺必要精度,但旧 Worker 只校验模型返回结构;只要模型输出一个格式合法的新领域问题,就可以绕过当前事件和服务端已知证据缺口。 +- 根因:旧方案把“required continuation”作为给模型的提示,而不是服务器拥有的候选动作和最终决策约束;诊断结果也没有独立工具预算、持久化产物和可回放选择依据,无法阻止合法 JSON 携带错误业务路由。 +- 修复:Opportunity Builder 将未解决的当前目标设为独占路由,并只发布带稳定 ID、目标事件、效用分解和隐私成本的问题机会;Bounded Reasoner 最多执行一次只读诊断,最终只能选择活动 opportunity 或受限状态动作;Decision Validator 拒绝不存在、跨 Case、非活动或越权的机会,也禁止模型直接写问题、分钟、分数和事件。Reasoner 不可用、返回非最终诊断或耗尽预算时走同一确定性 fallback policy;Renderer 根据 validated decision 生成自然语言承接,Worker 再通过单一 completion RPC 原子保存全部产物。 +- 验证:对抗合同覆盖“当前目标独占下一问”“只能选择服务端活动 opportunity”“诊断预算耗尽 fail closed”“模型不得注入问题/分钟/事件/分数”和“Reasoner/Renderer 不可用时确定性降级”。真实 PostgreSQL completed-job replay 已验证:相同完整 payload 指纹返回既有 Case;任一 artifact 改变且指纹不同会抛出 `rectification_v5_replay_payload_mismatch`,不会二次写入或接受漂移结果。当前仅完成本地验证,staging 行为仍待发布后验收。 +- 防复发:当前事件延续必须是服务端 opportunity 所有权规则,而不是 prompt 建议;模型输出即使结构合法,也必须经过 bounded tool budget、active-opportunity lookup、decision validation 和 completion payload hash 四层门控。 - 相关记录:BUG-075、BUG-085 -- 修复版本:待提交 +- 修复版本:本地 V5 重构,待提交与 staging 验收 diff --git a/frontend/scripts/rectification-v4-worker.mts b/frontend/scripts/rectification-v4-worker.mts index 93cf685a..72cac9cd 100644 --- a/frontend/scripts/rectification-v4-worker.mts +++ b/frontend/scripts/rectification-v4-worker.mts @@ -1,7 +1,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import { createRectificationV4CandidateEngine } from "../src/lib/rectification-v4/candidate-engine.ts"; import { createRectificationV4SupabaseStore } from "../src/lib/rectification-v4/supabase-store.ts"; -import { authorRectificationV4Question } from "../src/lib/rectification-v4/question-author.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; import { createAdminSupabaseClient } from "../src/lib/supabase/admin-client-core.ts"; @@ -15,7 +14,6 @@ const worker = createRectificationV4Worker({ engine: createRectificationV4CandidateEngine({ apiBase: process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200", }), - questionAuthor: authorRectificationV4Question, }); do { diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts index 23690572..31955439 100644 --- a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts @@ -18,6 +18,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ cas eventId, domain: body.domain, eventKind: body.eventKind, + subject: body.subject, + relatedPerson: body.relatedPerson, summary: body.summary, rawText: body.rawText, dateRange: body.dateRange, diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index 55596b55..d7ecd277 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -5,10 +5,14 @@ export type ExtractedLifeEventEvidence = { readonly id: string; readonly rawText: string; readonly domain: RectificationEvidenceDomain; + readonly eventKind: string; + readonly subject: "self" | "family" | "partner" | "other"; + readonly relatedPerson: "father" | "mother" | "grandparent" | "sibling" | "partner" | null; readonly eventSummary: string; readonly dateValue: string | null; readonly datePrecision: "day" | "month" | "year" | "unknown"; readonly extractionStatus: "clear" | "needs_clarification" | "corrected"; + readonly scoreability: "scoreable" | "context_only" | "pending_review" | "unsupported"; readonly scoreable: boolean; readonly correctsEvidenceIds: readonly string[]; }; @@ -86,15 +90,59 @@ function eventSummary(fragment: string): string { : missingEventSummary; } -function classifyDomain(summary: string): RectificationEvidenceDomain { - if (/确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|去世|离世|死亡|丧亲|健康/.test(summary)) return "health_pressure"; - if (/毕业|入学|升学|转学|学校|大学|专业|考试|留学|学业|学习/.test(summary)) return "education"; - if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) return "relocation"; - if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) return "relationship"; - if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) return "family"; - if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) return "finance"; - if (/工作|入职|离职|辞职|升职|创业|职业|职位|任职|管理职责|公司|项目/.test(summary)) return "career"; - return "other"; +type EventSemantics = Readonly<{ + domain: RectificationEvidenceDomain; + eventKind: string; + subject: "self" | "family" | "partner" | "other"; + relatedPerson: "father" | "mother" | "grandparent" | "sibling" | "partner" | null; + scoreability: "scoreable" | "context_only" | "pending_review" | "unsupported"; +}>; + +function classifyEvent(summary: string): EventSemantics { + const familyPerson = summary.match(/(父亲|爸爸|母亲|妈妈|爷爷|奶奶|外公|外婆|祖父|祖母|外祖父|外祖母|兄弟|姐妹|伴侣|配偶|丈夫|妻子|老公|老婆|男友|女友|儿子|女儿|孩子)/); + if (familyPerson && /确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|重病|去世|离世|死亡|丧亲|葬礼/.test(summary)) { + const relatedPerson = /父亲|爸爸/.test(familyPerson[1]) + ? "father" + : /母亲|妈妈/.test(familyPerson[1]) + ? "mother" + : /爷爷|奶奶|外公|外婆|祖父|祖母|外祖父|外祖母/.test(familyPerson[1]) + ? "grandparent" + : /兄弟|姐妹/.test(familyPerson[1]) + ? "sibling" + : /伴侣|配偶|丈夫|妻子|老公|老婆|男友|女友/.test(familyPerson[1]) + ? "partner" + : null; + const bereavement = /去世|离世|死亡|丧亲|葬礼/.test(summary); + return { + domain: "family", + eventKind: bereavement ? "family_bereavement" : "family_health_event", + subject: "family", + relatedPerson, + scoreability: "context_only", + }; + } + if (/确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|健康/.test(summary)) { + return { domain: "health_pressure", eventKind: "self_health_event", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + if (/毕业|入学|升学|转学|学校|大学|专业|考试|考(?:了)?(?:一)?次?研|研究生(?:入学)?考试|留学|学业|学习/.test(summary)) { + return { domain: "education", eventKind: "education_milestone", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) { + return { domain: "relocation", eventKind: "relocation", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) { + return { domain: "relationship", eventKind: "relationship_change", subject: /伴侣|配偶/.test(summary) ? "partner" : "self", relatedPerson: /伴侣|配偶/.test(summary) ? "partner" : null, scoreability: "scoreable" }; + } + if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) { + return { domain: "family", eventKind: "family_event", subject: "family", relatedPerson: null, scoreability: "context_only" }; + } + if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) { + return { domain: "finance", eventKind: "finance_change", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + if (/工作|入职|离职|辞职|升职|创业|职业|职位|任职|管理职责|公司|项目/.test(summary)) { + return { domain: "career", eventKind: "career_change", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + return { domain: "other", eventKind: "other", subject: "other", relatedPerson: null, scoreability: "unsupported" }; } function dateIsFuture(date: ParsedDate, asOfDate: string): boolean { @@ -155,7 +203,11 @@ function coalesceSameEventDetails( && previous.dateValue === event.dateValue && previous.datePrecision === event.datePrecision && previous.domain === event.domain + && previous.eventKind === event.eventKind + && previous.subject === event.subject + && previous.relatedPerson === event.relatedPerson && previous.extractionStatus === event.extractionStatus + && previous.scoreability === event.scoreability && previous.scoreable === event.scoreable && previous.correctsEvidenceIds.join("\0") === event.correctsEvidenceIds.join("\0"); if (!canMerge) { @@ -192,19 +244,25 @@ export function extractLifeEventEvidence( ? ownDates[0] ?? null : ownDates.length === 0 && !unresolvedRelativeTime ? sharedDate : null; const summary = eventSummary(fragment); + const semantics = classifyEvent(summary); const complete = summary !== missingEventSummary && date !== null && !unresolvedRelativeTime; const extractionStatus = !complete ? "needs_clarification" : correctionTargets.length > 0 ? "corrected" : "clear"; + const scoreable = complete && !dateIsFuture(date, input.asOfDate) && semantics.scoreability === "scoreable"; events.push({ id: evidenceId(input, events.length, summary), rawText: input.rawText, - domain: classifyDomain(summary), + domain: semantics.domain, + eventKind: semantics.eventKind, + subject: semantics.subject, + relatedPerson: semantics.relatedPerson, eventSummary: summary, dateValue: date?.value ?? null, datePrecision: date?.precision ?? "unknown", extractionStatus, - scoreable: complete && !dateIsFuture(date, input.asOfDate), + scoreability: complete ? semantics.scoreability : "pending_review", + scoreable, correctsEvidenceIds: correctionTargets, }); } diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 19ce389d..26b4b1c1 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -132,6 +132,7 @@ const transitionValidatorVersion = "conversational-rectification-orchestrator-v1 const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/; const genericUncertaintyPattern = /(?:不知道|不确定)/; const contextualRelativeMonthPattern = /(?:来年|次年|第二年|翌年|同年|当年|那年)\s*(\d{1,2})\s*月份?/; +const contextualRelativeEventMonthPattern = /(来年|次年|第二年|翌年|同年|当年|那年)([^。!?!?;;]{0,80}?)(\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*$/u; @@ -332,7 +333,9 @@ function evidenceRecap(evidence: ReadonlyArray) { id: item.id, summary: visibleEvidenceSummary(item.eventSummary), dateLabel: item.dateValue - ? item.scoreable === false && item.extractionStatus !== "needs_clarification" + ? item.scoreable === false + && (item.scoreability === undefined || item.scoreability === "scoreable") + && item.extractionStatus !== "needs_clarification" ? `${item.dateValue}(未来,仅作背景)` : item.dateValue : "日期待补充", @@ -653,30 +656,45 @@ function nonScoringTurn(input: { }>; }): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } { const allEvidence = [...input.current.eventEvidence, ...input.newEvidence]; + const latestIncomplete = input.newEvidence + .filter((item) => item.extractionStatus === "needs_clarification") + .at(-1); const authoredNarrative = input.authoredNarrative; const latestSummary = input.newEvidence.at(-1)?.eventSummary; const fallbackSubject = latestSummary && latestSummary !== "事件内容待补充" ? latestSummary : input.latestUserText.trim().slice(0, 80); const narrative = authoredNarrative?.narrative - ?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,内容已经保留。你可以继续讲这段经历,也可以按自己的节奏说下一件想到的事。`; + ?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,但这次分析暂时没有完成。内容已经保留,你可以按自己的节奏继续补充它的时间和经过,或直接说下一件已经发生的经历。`; const status = input.correctionReset ? "active" as const : input.current.status === "confirming" ? "confirming" as const : "active" as const; const actions = actionsFor(status); - const authoredRequest = authoredNarrative?.output.evidenceRequest; - const priorRequest = input.current.latestTurn.evidenceRequest; - const evidenceRequest = authoredRequest - ? { - domains: authoredRequest.domains, - datePrecision: authoredRequest.datePrecision, - freeTextAllowed: true as const, - prompt: authoredRequest.prompt, - followUp: input.followUpOverride ?? authoredRequest.followUp, - } - : input.followUpOverride && priorRequest - ? { ...priorRequest, followUp: input.followUpOverride } + const clarificationFollowUp = latestIncomplete?.dateValue === null + && latestIncomplete.eventSummary !== "事件内容待补充" + ? { kind: "event_date" as const, evidenceId: latestIncomplete.id } + : latestIncomplete?.dateValue + && latestIncomplete.eventSummary === "事件内容待补充" + ? { kind: "event_detail" as const, evidenceId: latestIncomplete.id } : null; + const authoredRequest = authoredNarrative?.output.evidenceRequest; + const priorRequest = input.current.latestTurn.evidenceRequest; + const evidenceRequest = status === "confirming" && priorRequest === null + ? null + : authoredRequest + ? { + domains: authoredRequest.domains, + datePrecision: authoredRequest.datePrecision, + freeTextAllowed: true as const, + prompt: authoredRequest.prompt, + followUp: input.followUpOverride ?? authoredRequest.followUp, + } + : priorRequest + ? { + ...priorRequest, + followUp: input.followUpOverride ?? clarificationFollowUp ?? priorRequest.followUp, + } + : null; const parsed = conversationalRectificationTurnSchema.safeParse({ ...input.current.latestTurn, status, @@ -901,7 +919,7 @@ export function createConversationalRectificationService( current: LoadedConversationalRectificationCase, ): string { const followUp = current.latestTurn.evidenceRequest?.followUp; - if (followUp?.kind !== "event_date" && followUp?.kind !== "event_detail") { + if (!followUp || !["new_event", "event_date", "event_detail"].includes(followUp.kind)) { return command.answer; } const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence); @@ -914,6 +932,17 @@ export function createConversationalRectificationService( const anchorYear = Number(anchor?.dateValue?.slice(0, 4)); if (!Number.isInteger(anchorYear)) return command.answer; + const relativeEventMonth = followUp.kind === "new_event" + ? command.answer.match(contextualRelativeEventMonthPattern) + : null; + if (relativeEventMonth) { + const month = Number(relativeEventMonth[3]); + if (month >= 1 && month <= 12) { + const sameYear = /(?:同年|当年|那年)/.test(relativeEventMonth[1] ?? ""); + return `${sameYear ? anchorYear : anchorYear + 1}年${month}月${relativeEventMonth[2] ?? ""}${relativeEventMonth[4] ?? ""}`; + } + } + const bareMonthDay = followUp.kind === "event_date" ? command.answer.match(contextualBareMonthDayPattern) : null; @@ -1014,6 +1043,10 @@ export function createConversationalRectificationService( rawText: `${target.rawText}\n确认:${command.answer}`, eventSummary: target.eventSummary, domain: target.domain, + eventKind: target.eventKind ?? item.eventKind, + subject: target.subject ?? item.subject, + relatedPerson: target.relatedPerson ?? item.relatedPerson, + scoreability: target.scoreability ?? item.scoreability, correctsEvidenceIds: [target.id], })), }; @@ -1063,7 +1096,19 @@ export function createConversationalRectificationService( })), }, { signal: AbortSignal.timeout(8_000) }); if (domain && domain !== "other") { - return [{ ...ambiguous, domain }]; + const scoreability = domain === "family" ? "context_only" : "scoreable"; + return [{ + ...ambiguous, + domain, + eventKind: `${domain}_event`, + subject: domain === "family" ? "family" : "self", + relatedPerson: null, + scoreability, + scoreable: scoreability === "scoreable" + && ambiguous.dateValue !== null + && ambiguous.extractionStatus !== "needs_clarification" + && !evidencePostdatesAsOfDate(ambiguous, ports.asOfDate()), + }]; } } catch { // Semantic classification is advisory. Keep the deterministic fallback @@ -1142,6 +1187,10 @@ export function createConversationalRectificationService( rawText: `${pending.rawText}\n补充:${input.command.answer}`, eventSummary: summary, domain: pending.domain === "other" ? item.domain : pending.domain, + eventKind: pending.eventKind ?? item.eventKind, + subject: pending.subject ?? item.subject, + relatedPerson: pending.relatedPerson ?? item.relatedPerson, + scoreability: pending.scoreability ?? item.scoreability, correctsEvidenceIds: [...item.correctsEvidenceIds], })); } diff --git a/frontend/src/lib/conversational-rectification/persistence-contracts.ts b/frontend/src/lib/conversational-rectification/persistence-contracts.ts index 996102b5..84142002 100644 --- a/frontend/src/lib/conversational-rectification/persistence-contracts.ts +++ b/frontend/src/lib/conversational-rectification/persistence-contracts.ts @@ -193,14 +193,26 @@ export type ValidationReceipt = z.infer; const correctionEvidenceIdsSchema = boundedJson(z.array(uuidSchema).max(1), 64); +const eventSubjectSchema = z.enum(["self", "family", "partner", "other"]); +const relatedPersonSchema = z.enum([ + "father", "mother", "grandparent", "sibling", "partner", +]); +const eventScoreabilitySchema = z.enum([ + "scoreable", "context_only", "pending_review", "unsupported", +]); + export const lifeEventEvidenceSchema = boundedJson(z.object({ id: uuidSchema, rawText: boundedText(4_000), domain: evidenceDomainSchema, + eventKind: boundedText(120).optional(), + subject: eventSubjectSchema.optional(), + relatedPerson: relatedPersonSchema.nullable().optional(), eventSummary: boundedText(1_000), dateValue: boundedText(80).nullable(), datePrecision: z.enum(["day", "month", "year", "range", "unknown"]), extractionStatus: z.enum(["clear", "needs_clarification", "corrected"]), + scoreability: eventScoreabilitySchema.optional(), scoreable: z.boolean().optional(), // Optional only for rows written before durable correction lineage existed. correctsEvidenceIds: correctionEvidenceIdsSchema.optional(), diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts new file mode 100644 index 00000000..840fe28d --- /dev/null +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; +import { clockTimeSchema, evidenceDomainSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts"; + +const uuid = z.string().uuid(); +const hash = z.string().regex(/^[a-f0-9]{64}$/); +const nonblank = (max: number) => z.string().trim().min(1).max(max); + +export const rectificationDiagnosticSchema = z.enum([ + "leave_one_event_out", + "leave_one_domain_out", + "date_sensitivity", + "neighbor_stability", + "candidate_split", +]); +export type RectificationDiagnostic = z.infer; + +export const rectificationDecisionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("ask_question"), + opportunityId: uuid, + narrativeFocus: z.array(z.enum(["latest_event", "candidate_change", "date_precision", "uncertainty"])).max(3), + }).strict(), + z.object({ action: z.literal("run_diagnostic"), diagnostic: rectificationDiagnosticSchema }).strict(), + z.object({ action: z.literal("offer_candidate_range"), snapshotId: uuid }).strict(), + z.object({ action: z.literal("stop_low_confidence"), reasonCodes: z.array(nonblank(80)).min(1).max(8) }).strict(), +]); +export type RectificationDecision = z.infer; + +export const questionOpportunitySchema = z.object({ + opportunityId: uuid, + kind: z.enum([ + "clarify_intake", + "clarify_event_subject", + "refine_event_date", + "pair_related_event", + "ask_new_event", + "resolve_event_conflict", + "disambiguate_candidate_split", + ]), + domain: evidenceDomainSchema, + targetEventId: uuid.nullable(), + prompt: nonblank(1_000), + reason: nonblank(240), + expectedInformationGain: z.number().finite().min(0).max(1), + dateSensitivity: z.number().finite().min(0).max(1), + candidateSplitRelevance: z.number().finite().min(0).max(1), + domainCoverageGain: z.number().finite().min(0).max(1), + recallEase: z.number().finite().min(0).max(1), + novelty: z.number().finite().min(0).max(1), + repetitionPenalty: z.number().finite().min(0).max(1), + privacyCost: z.number().finite().min(0).max(1), + utility: z.number().finite(), + active: z.boolean(), +}).strict(); +export type QuestionOpportunity = z.infer; + +export const eventDateSensitivitySchema = z.object({ + eventId: uuid, + declaredDateRange: z.object({ start: nonblank(10), end: nonblank(10), precision: nonblank(20) }).strict(), + sampleDates: z.array(nonblank(10)).min(1).max(12), + winnerRetentionRate: z.number().finite().min(0).max(1), + scoreVariance: z.number().finite().nonnegative(), + candidateClusterRetentionRate: z.number().finite().min(0).max(1), +}).strict(); + +export const candidateSplitSchema = z.object({ + leftCluster: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(), + rightCluster: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(), + techniqueLayers: z.array(nonblank(80)).max(40), + eventIds: z.array(uuid).max(100), +}).strict(); + +export const diagnosticsSummarySchema = z.object({ + id: uuid, + caseId: uuid, + snapshotId: uuid, + primaryClusterRetentionRate: z.number().finite().min(0).max(1), + leaveOneEventOutRetentionRate: z.number().finite().min(0).max(1), + leaveOneDomainOutRetentionRate: z.number().finite().min(0).max(1), + dateSensitivityRetentionRate: z.number().finite().min(0).max(1), + neighborSupportMinutes: z.number().int().min(0).max(1_440), + primarySecondaryMarginPercent: z.number().finite().min(0).max(100), + clusterMassRatio: z.number().finite().min(0).max(1), + unstableEventIds: z.array(uuid).max(100), + mostDiscriminatingLayers: z.array(nonblank(80)).max(40), + eventDateSensitivity: z.array(eventDateSensitivitySchema).max(100), + candidateSplits: z.array(candidateSplitSchema).max(20), + calculationHash: hash, + createdAt: z.string().datetime({ offset: true }), +}).strict(); +export type DiagnosticsSummary = z.infer; + +export const candidateFeatureSnapshotSchema = z.object({ + id: uuid, + caseId: uuid, + calculationSpecHash: hash, + algorithmVersion: nonblank(120), + candidateCount: z.number().int().positive().max(1_440), + featureHash: hash, + features: z.array(z.object({ + time: clockTimeSchema, + ascendantDegree: z.number().finite().min(0).max(360).nullable(), + ascendantSignIndex: z.number().int().min(0).max(11).nullable(), + vargaAscendants: z.record(z.string(), z.number().int().min(0).max(11)), + arudhaSigns: z.object({ A7: z.number().int().min(0).max(11).nullable(), A10: z.number().int().min(0).max(11).nullable(), UL: z.number().int().min(0).max(11).nullable() }).strict(), + availableLayers: z.array(nonblank(80)).max(80), + blockedLayers: z.array(nonblank(80)).max(80), + fingerprints: z.record(z.string(), z.string()), + }).strict()).max(1_440), + createdAt: z.string().datetime({ offset: true }), +}).strict(); +export type CandidateFeatureSnapshot = z.infer; + +export const toolCallTraceSchema = z.object({ + tool: nonblank(120), + diagnostic: rectificationDiagnosticSchema.nullable(), + outcome: z.enum(["succeeded", "failed", "rejected"]), + durationMs: z.number().int().min(0).max(300_000), + errorCode: nonblank(120).nullable(), +}).strict(); +export type ToolCallTrace = z.infer; + +export const validatedDecisionSchema = z.object({ + decision: rectificationDecisionSchema, + mode: z.enum(["agent", "deterministic_fallback"]), + validationIssues: z.array(nonblank(120)).max(20), + selectedOpportunity: questionOpportunitySchema.nullable(), +}).strict(); +export type ValidatedDecision = z.infer; + +export const publicMessageSchema = z.object({ + acknowledgement: nonblank(1_000), + candidateUpdate: nonblank(1_000).nullable(), + limitation: nonblank(1_000).nullable(), + question: nonblank(1_000).nullable(), +}).strict(); +export type PublicMessage = z.infer; + +export const agentRunSchema = z.object({ + id: uuid, + caseId: uuid, + jobId: uuid, + caseVersion: z.number().int().nonnegative(), + modelId: nonblank(120).nullable(), + skillVersion: nonblank(120), + promptVersion: nonblank(120), + deploymentSha: nonblank(80).nullable(), + deploymentMode: rectificationDeploymentModeSchema, + decision: rectificationDecisionSchema.nullable(), + validatedDecision: validatedDecisionSchema, + toolCalls: z.array(toolCallTraceSchema).max(8), + fallbackReason: nonblank(120).nullable(), + inputTokenCount: z.number().int().nonnegative().nullable(), + outputTokenCount: z.number().int().nonnegative().nullable(), + latencyMs: z.number().int().nonnegative().max(300_000), + createdAt: z.string().datetime({ offset: true }), +}).strict(); +export type AgentRun = z.infer; + +export type RectificationDecisionValidation = Readonly<{ + valid: boolean; + decision: RectificationDecision | null; + issues: readonly string[]; +}>; + +export function validateRectificationDecision(input: Readonly<{ + decision: unknown; + caseId?: string; + snapshotId?: string | null; + opportunities: readonly QuestionOpportunity[]; + diagnostics: DiagnosticsSummary; + candidateRangeOfferAllowed: boolean; + usedDiagnostics?: readonly RectificationDiagnostic[]; + toolCallCount?: number; + maxToolCalls?: number; +}>): RectificationDecisionValidation { + const parsed = rectificationDecisionSchema.safeParse(input.decision); + if (!parsed.success) return { valid: false, decision: null, issues: ["decision_schema_invalid"] }; + const decision = parsed.data; + const issues: string[] = []; + if (input.caseId && input.diagnostics.caseId !== input.caseId) issues.push("diagnostics_case_mismatch"); + if ((input.toolCallCount ?? 0) > (input.maxToolCalls ?? 2)) issues.push("tool_call_budget_exceeded"); + if (decision.action === "ask_question") { + const opportunity = input.opportunities.find((item) => item.opportunityId === decision.opportunityId && item.active); + if (!opportunity) issues.push("opportunity_not_active"); + if (opportunity?.kind === "clarify_event_subject" && !opportunity.targetEventId) issues.push("subject_clarification_requires_target_event"); + } + if (decision.action === "offer_candidate_range") { + if (!input.candidateRangeOfferAllowed) issues.push("candidate_range_gate_failed"); + if (!input.snapshotId || decision.snapshotId !== input.snapshotId || input.diagnostics.snapshotId !== input.snapshotId) issues.push("snapshot_not_current"); + } + if (decision.action === "run_diagnostic" && input.usedDiagnostics?.includes(decision.diagnostic)) issues.push("diagnostic_already_run"); + return { valid: issues.length === 0, decision: issues.length === 0 ? decision : null, issues }; +} diff --git a/frontend/src/lib/rectification-agent/fallback-policy.ts b/frontend/src/lib/rectification-agent/fallback-policy.ts new file mode 100644 index 00000000..b6e873d4 --- /dev/null +++ b/frontend/src/lib/rectification-agent/fallback-policy.ts @@ -0,0 +1,13 @@ +import type { CandidateSnapshot } from "../rectification-v4/contracts.ts"; +import type { DiagnosticsSummary, QuestionOpportunity, RectificationDecision } from "./contracts.ts"; + +export function deterministicDecision(input: Readonly<{ + snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary | null; + opportunities: readonly QuestionOpportunity[]; +}>): RectificationDecision { + if (input.snapshot?.canAcceptRange) return { action: "offer_candidate_range", snapshotId: input.snapshot.id }; + const top = input.opportunities.find((item) => item.active); + if (top) return { action: "ask_question", opportunityId: top.opportunityId, narrativeFocus: ["latest_event", ...(top.kind === "refine_event_date" ? ["date_precision" as const] : [])] }; + return { action: "stop_low_confidence", reasonCodes: input.diagnostics ? ["no_high_value_question", "diagnostics_not_stable"] : ["insufficient_scoreable_evidence"] }; +} diff --git a/frontend/src/lib/rectification-agent/feature-policy.ts b/frontend/src/lib/rectification-agent/feature-policy.ts new file mode 100644 index 00000000..4c59ec22 --- /dev/null +++ b/frontend/src/lib/rectification-agent/feature-policy.ts @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { rectificationDeploymentModeSchema, type RectificationDeploymentMode } from "../rectification-v4/contracts.ts"; + +type RectificationFeatureEnv = Readonly<{ + RECTIFICATION_AGENT_V5_ENABLED?: string; + RECTIFICATION_AGENT_V5_SHADOW?: string; + RECTIFICATION_AGENT_V5_CANARY_PERCENT?: string; +}>; + +function enabled(value: string | undefined): boolean { + return /^(1|true|yes|on)$/i.test(value?.trim() ?? ""); +} + +function percentage(value: string | undefined): number { + if (!value?.trim()) return 100; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(0, Math.min(100, parsed)); +} + +export function rectificationCanaryBucket(stableId: string): number { + const prefix = createHash("sha256").update(stableId).digest().readUInt32BE(0); + return prefix / 0x1_0000_0000 * 100; +} + +export function selectRectificationDeploymentMode( + stableId: string, + env: RectificationFeatureEnv = { + RECTIFICATION_AGENT_V5_ENABLED: process.env.RECTIFICATION_AGENT_V5_ENABLED, + RECTIFICATION_AGENT_V5_SHADOW: process.env.RECTIFICATION_AGENT_V5_SHADOW, + RECTIFICATION_AGENT_V5_CANARY_PERCENT: process.env.RECTIFICATION_AGENT_V5_CANARY_PERCENT, + }, +): RectificationDeploymentMode { + if (!enabled(env.RECTIFICATION_AGENT_V5_ENABLED)) return "v4_legacy"; + if (rectificationCanaryBucket(stableId) >= percentage(env.RECTIFICATION_AGENT_V5_CANARY_PERCENT)) return "v4_legacy"; + return rectificationDeploymentModeSchema.parse( + enabled(env.RECTIFICATION_AGENT_V5_SHADOW) ? "v5_shadow" : "v5_agent", + ); +} diff --git a/frontend/src/lib/rectification-agent/opportunity-builder.ts b/frontend/src/lib/rectification-agent/opportunity-builder.ts new file mode 100644 index 00000000..8dd73b4f --- /dev/null +++ b/frontend/src/lib/rectification-agent/opportunity-builder.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts"; +import type { DiagnosticsSummary, QuestionOpportunity } from "./contracts.ts"; + +const domains: readonly EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure"]; + +function stableUuid(value: string): string { + const hex = createHash("sha256").update(value).digest("hex").slice(0, 32).split(""); + hex[12] = "4"; + hex[16] = ((Number.parseInt(hex[16]!, 16) & 3) | 8).toString(16); + return `${hex.slice(0, 8).join("")}-${hex.slice(8, 12).join("")}-${hex.slice(12, 16).join("")}-${hex.slice(16, 20).join("")}-${hex.slice(20).join("")}`; +} + +const routingValue: Record = { + clarify_intake: .18, + resolve_event_conflict: .16, + clarify_event_subject: .14, + refine_event_date: .08, + pair_related_event: .05, + disambiguate_candidate_split: .04, + ask_new_event: 0, +}; + +function utility(value: Omit): number { + return Number(( + .35 * value.expectedInformationGain + .20 * value.dateSensitivity + .15 * value.candidateSplitRelevance + + .10 * value.domainCoverageGain + .10 * value.recallEase + .10 * value.novelty + + routingValue[value.kind] - value.repetitionPenalty - value.privacyCost + ).toFixed(6)); +} + +function opportunity(caseId: string, input: Omit): QuestionOpportunity { + const result = { ...input, opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.prompt}`), utility: utility(input), active: true }; + return result; +} + +export function buildQuestionOpportunities(input: Readonly<{ + caseId: string; + events: readonly LifeEventRevision[]; + turns: readonly RectificationV4Turn[]; + snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary | null; + retryTargetEventIds?: readonly string[]; +}>): readonly QuestionOpportunity[] { + const attempted = new Set(input.turns.flatMap((turn) => turn.questionTargetEventId ? [turn.questionTargetEventId] : [])); + const retryTargets = new Set(input.retryTargetEventIds ?? []); + const scoreableDomains = new Set(input.events.filter((event) => event.scoreability === "scoreable").map((event) => event.domain)); + const opportunities: QuestionOpportunity[] = []; + for (const eventId of retryTargets) { + const event = input.events.find((value) => value.eventId === eventId); + if (!event) continue; + opportunities.push(opportunity(input.caseId, { + kind: "resolve_event_conflict", domain: event.domain, targetEventId: event.eventId, + prompt: `你刚才补充的新经历已经另行保存。关于“${event.summary}”的时间仍没有确定;如果记不清,可以直接说不知道。`, + reason: "用户补充了另一件事,原事件的日期或主体仍待确认。", + expectedInformationGain: .85, dateSensitivity: .75, candidateSplitRelevance: .6, domainCoverageGain: 0, recallEase: .8, novelty: .7, repetitionPenalty: .15, privacyCost: .05, + })); + } + if (opportunities.length > 0) { + return opportunities.sort((left, right) => + right.utility - left.utility + || left.opportunityId.localeCompare(right.opportunityId)); + } + for (const event of input.events) { + if (retryTargets.has(event.eventId)) continue; + if ((event.scoreability === "pending_review" || event.subject === "other") && !attempted.has(event.eventId)) { + opportunities.push(opportunity(input.caseId, { + kind: "clarify_event_subject", domain: event.domain, targetEventId: event.eventId, + prompt: `你刚才提到“${event.summary}”,这件事主要发生在你本人,还是家人或伴侣身上?`, reason: "事件主体决定是否允许进入个人分盘评分。", + expectedInformationGain: .9, dateSensitivity: .2, candidateSplitRelevance: .3, domainCoverageGain: .2, recallEase: .95, novelty: .9, repetitionPenalty: 0, privacyCost: .05, + })); + } + if (event.scoreability === "scoreable" && event.dateRange.precision !== "day" && !attempted.has(event.eventId)) { + const sensitivity = input.diagnostics?.eventDateSensitivity.find((item) => item.eventId === event.eventId); + opportunities.push(opportunity(input.caseId, { + kind: "refine_event_date", domain: event.domain, targetEventId: event.eventId, + prompt: `关于“${event.summary}”,你还记得更具体的月份或日期吗?不确定也可以只说大概范围。`, reason: "日期采样显示这件事的时间精度可能影响候选排序。", + expectedInformationGain: sensitivity ? 1 - sensitivity.winnerRetentionRate : .72, + dateSensitivity: sensitivity ? 1 - sensitivity.candidateClusterRetentionRate : .7, + candidateSplitRelevance: .55, domainCoverageGain: 0, recallEase: .72, novelty: .8, repetitionPenalty: 0, privacyCost: .05, + })); + } + } + const split = input.diagnostics?.candidateSplits[0]; + if (split) { + const target = input.events.find((event) => split.eventIds.includes(event.eventId)); + opportunities.push(opportunity(input.caseId, { + kind: "disambiguate_candidate_split", domain: target?.domain ?? "other", targetEventId: target?.eventId ?? null, + prompt: target ? `围绕“${target.summary}”,当时最明显的转折是事情开始、达到高峰,还是正式结束?` : "剩余候选在同一事件的阶段上有差异:你记得当时更接近开始、达到高峰,还是正式结束吗?", + reason: `候选簇在 ${split.techniqueLayers.slice(0, 3).join("、") || "技术层"} 上出现可检验分歧。`, + expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0, recallEase: .65, novelty: .9, repetitionPenalty: target && attempted.has(target.eventId) ? .35 : 0, privacyCost: .1, + })); + } + const missingDomain = domains.find((domain) => !scoreableDomains.has(domain)); + if (missingDomain) { + const prompts: Record = { + education: "你人生中有没有一次入学、毕业、考试或专业变化,时间大致在什么时候?", + relocation: "你有没有一次印象深刻的搬家、离乡或长期迁居?大致在什么时候?", + relationship: "你有没有一段关系正式开始、结束或进入婚姻的明确时间点?", + career: "你有没有一次入职、离职、升职、转行或创业的明确时间点?", + finance: "你有没有一次收入、投资、负债或资产状况明显改变的时间点?", + health_pressure: "你本人有没有一次住院、手术、事故或明显健康转折?大致在什么时候?", + family: "请补充一个家庭事件。", other: "请补充一个有明确时间的重要人生事件。", + }; + opportunities.push(opportunity(input.caseId, { + kind: "ask_new_event", domain: missingDomain, targetEventId: null, prompt: prompts[missingDomain], reason: "当前证据领域覆盖不足。", + expectedInformationGain: .7, dateSensitivity: .45, candidateSplitRelevance: .5, domainCoverageGain: 1, recallEase: .7, novelty: 1, repetitionPenalty: 0, privacyCost: missingDomain === "health_pressure" ? .2 : .08, + })); + } + return opportunities.sort((left, right) => + right.utility - left.utility + || left.opportunityId.localeCompare(right.opportunityId)); +} diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts new file mode 100644 index 00000000..29e2085c --- /dev/null +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -0,0 +1,302 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { RectificationV4CandidateEngine } from "../rectification-v4/candidate-engine.ts"; +import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.ts"; +import type { CandidateSnapshot, RectificationV4Question } from "../rectification-v4/contracts.ts"; +import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts"; +import { reconcileV4Evidence } from "../rectification-v4/extraction.ts"; +import { evidenceSetHash } from "../rectification-v4/fingerprints.ts"; +import { latestEventRevisions, scoreableEvents } from "../rectification-v4/evidence-ledger.ts"; +import { projectLegacyV4Turn } from "../rectification-v4/legacy-projector.ts"; +import type { ClaimedRectificationV4Job } from "../rectification-v4/store.ts"; +import { deterministicDecision } from "./fallback-policy.ts"; +import { buildQuestionOpportunities } from "./opportunity-builder.ts"; +import { renderPublicTurn } from "./renderer-agent.ts"; +import { runBoundedReasoner } from "./reasoner-agent.ts"; +import { recordRectificationAgentTelemetry } from "./telemetry.ts"; +import { + candidateFeatureSnapshotSchema, + diagnosticsSummarySchema, + validateRectificationDecision, + type AgentRun, + type CandidateFeatureSnapshot, + type DiagnosticsSummary, + type PublicMessage, + type ValidatedDecision, +} from "./contracts.ts"; + +function hash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +export async function processRectificationAgentTurn(input: Readonly<{ + claimed: ClaimedRectificationV4Job; + engine: RectificationV4CandidateEngine; + now: Date; + onPhase?: (phase: "extracting_evidence" | "scoring_candidates" | "checking_robustness" | "planning_question" | "reasoning" | "rendering") => Promise; +}>): Promise> { + const { claimed, now } = input; + await input.onPhase?.("extracting_evidence"); + const reconciliation = claimed.turn.answer ? reconcileV4Evidence({ + caseId: claimed.case.id, + answer: claimed.turn.answer, + sourceTurnId: claimed.turn.id, + asOfDate: now.toISOString().slice(0, 10), + existing: claimed.events, + targetEventId: claimed.turn.questionTargetEventId, + now, + }) : { revisions: [], pending: [], unansweredTargetEventId: null }; + const extracted = reconciliation.revisions; + const events = latestEventRevisions([...claimed.events, ...extracted]); + const scoreable = scoreableEvents(events); + const domains = new Set(scoreable.map((event) => event.domain)); + let snapshot: CandidateSnapshot | null = null; + let diagnostics: DiagnosticsSummary | null = null; + let featureSnapshot: CandidateFeatureSnapshot | null = null; + + if (scoreable.length >= 3 && domains.size >= 2) { + await input.onPhase?.("scoring_candidates"); + const scored = await input.engine.score({ calculationSpec: claimed.case.calculationSpec, events: scoreable }); + await input.onPhase?.("checking_robustness"); + const clusters = buildCandidateClusters(scored.candidates); + const robustness = { + neighborSupportMinutes: scored.robustness.neighborSupportMinutes, + leaveOneOutRetentionRate: scored.robustness.leaveOneOutRetentionRate, + dateSensitivityRetentionRate: scored.robustness.dateSensitivityRetentionRate, + calculationSpecHashMatched: scored.calculationSpecHash === claimed.case.calculationSpecHash, + }; + const gate = evaluateDecisionGate({ + clusters, + robustness, + scoreableEventCount: scoreable.length, + scoreableDomainCount: domains.size, + }); + snapshot = { + id: scored.resultId, + caseId: claimed.case.id, + caseVersion: claimed.case.version, + evidenceSetHash: evidenceSetHash(events), + calculationSpecHash: claimed.case.calculationSpecHash, + algorithmVersion: scored.featureSnapshot.algorithm_version, + candidates: [...scored.candidates], + clusters: [...clusters], + robustness, + canConfirmExactMinute: false, + canAcceptRange: gate.canAcceptRange, + gateReasons: [...gate.reasons, ...scored.missingLayers.map((layer) => `missing_layer:${layer}`)], + createdAt: now.toISOString(), + }; + diagnostics = diagnosticsSummarySchema.parse({ + id: randomUUID(), + caseId: claimed.case.id, + snapshotId: snapshot.id, + primaryClusterRetentionRate: scored.diagnostics.primary_cluster_retention_rate, + leaveOneEventOutRetentionRate: scored.diagnostics.leave_one_event_out_retention_rate, + leaveOneDomainOutRetentionRate: scored.diagnostics.leave_one_domain_out_retention_rate, + dateSensitivityRetentionRate: scored.diagnostics.date_sensitivity_retention_rate, + neighborSupportMinutes: scored.diagnostics.neighbor_support_minutes, + primarySecondaryMarginPercent: scored.diagnostics.primary_secondary_margin_percent, + clusterMassRatio: scored.diagnostics.cluster_mass_ratio, + unstableEventIds: scored.diagnostics.unstable_event_ids, + mostDiscriminatingLayers: scored.diagnostics.most_discriminating_layers, + eventDateSensitivity: scored.diagnostics.event_date_sensitivity.map((item) => ({ + eventId: item.event_id, + declaredDateRange: item.declared_date_range, + sampleDates: item.sample_dates, + winnerRetentionRate: item.winner_retention_rate, + scoreVariance: item.score_variance, + candidateClusterRetentionRate: item.candidate_cluster_retention_rate, + })), + candidateSplits: scored.diagnostics.candidate_splits.map((item) => ({ + leftCluster: item.left_cluster, + rightCluster: item.right_cluster, + techniqueLayers: item.technique_layers, + eventIds: item.event_ids, + })), + calculationHash: hash(scored.diagnostics), + createdAt: now.toISOString(), + }); + featureSnapshot = candidateFeatureSnapshotSchema.parse({ + id: randomUUID(), + caseId: claimed.case.id, + calculationSpecHash: scored.featureSnapshot.calculation_spec_hash, + algorithmVersion: scored.featureSnapshot.algorithm_version, + candidateCount: scored.featureSnapshot.candidate_count, + featureHash: scored.featureSnapshot.feature_hash, + features: scored.featureSnapshot.features.map((item) => ({ + time: item.time, + ascendantDegree: item.ascendant_degree, + ascendantSignIndex: item.ascendant_sign_index, + vargaAscendants: item.varga_ascendants, + arudhaSigns: item.arudha_signs, + availableLayers: item.available_layers, + blockedLayers: item.blocked_layers, + fingerprints: item.fingerprints, + })), + createdAt: now.toISOString(), + }); + } + + const safeDiagnostics = diagnostics ?? diagnosticsSummarySchema.parse({ + id: randomUUID(), + caseId: claimed.case.id, + snapshotId: randomUUID(), + primaryClusterRetentionRate: 0, + leaveOneEventOutRetentionRate: 0, + leaveOneDomainOutRetentionRate: 0, + dateSensitivityRetentionRate: 0, + neighborSupportMinutes: 0, + primarySecondaryMarginPercent: 0, + clusterMassRatio: 0, + unstableEventIds: [], + mostDiscriminatingLayers: [], + eventDateSensitivity: [], + candidateSplits: [], + calculationHash: hash(events), + createdAt: now.toISOString(), + }); + + await input.onPhase?.("planning_question"); + const opportunities = buildQuestionOpportunities({ + caseId: claimed.case.id, + events, + turns: claimed.turns, + snapshot, + diagnostics, + retryTargetEventIds: reconciliation.unansweredTargetEventId ? [reconciliation.unansweredTargetEventId] : [], + }); + await input.onPhase?.("reasoning"); + const reasoned = await runBoundedReasoner({ + caseValue: claimed.case, + snapshot, + diagnostics: safeDiagnostics, + opportunities, + enabled: claimed.case.deploymentMode !== "v4_legacy", + }); + const rawDecision = reasoned.decision; + let validation = validateRectificationDecision({ + decision: rawDecision, + caseId: claimed.case.id, + snapshotId: snapshot?.id ?? null, + opportunities, + diagnostics: safeDiagnostics, + candidateRangeOfferAllowed: snapshot?.canAcceptRange ?? false, + toolCallCount: reasoned.toolCalls.length, + maxToolCalls: 1, + }); + let fallbackReason = reasoned.fallbackReason; + if (!validation.decision) { + recordRectificationAgentTelemetry({ + caseId: claimed.case.id, phase: "fallback", outcome: "rejected", + modelId: claimed.case.orchestrationModelId, toolName: null, + decisionAction: rawDecision.action, durationMs: reasoned.latencyMs, + errorCode: "policy_validator_rejected", deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null, + }); + validation = validateRectificationDecision({ + decision: deterministicDecision({ snapshot, diagnostics, opportunities }), + caseId: claimed.case.id, + snapshotId: snapshot?.id ?? null, + opportunities, + diagnostics: safeDiagnostics, + candidateRangeOfferAllowed: snapshot?.canAcceptRange ?? false, + }); + fallbackReason = `validator_rejected:${validation.issues.join(",") || "unknown"}`; + } + const finalDecision = validation.decision; + if (!finalDecision) throw new Error("rectification_v5_fallback_validation_failed"); + const selectedOpportunity = finalDecision.action === "ask_question" + ? opportunities.find((item) => item.opportunityId === finalDecision.opportunityId) ?? null + : null; + const validatedDecision: ValidatedDecision = { + decision: finalDecision, + mode: fallbackReason ? "deterministic_fallback" : reasoned.mode, + validationIssues: [...validation.issues], + selectedOpportunity, + }; + + await input.onPhase?.("rendering"); + const legacyProjection = projectLegacyV4Turn({ + events, + newEvents: extracted, + attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, + latestAnswer: claimed.turn.answer, + snapshot, + }); + const agentVisible = claimed.case.deploymentMode === "v5_agent"; + const publicMessage = agentVisible + ? await renderPublicTurn({ + caseValue: claimed.case, + latestAnswer: claimed.turn.answer, + acceptedEvents: extracted, + pendingEvidence: reconciliation.pending, + snapshot, + validated: validatedDecision, + }) + : legacyProjection.publicMessage; + const nextQuestion = agentVisible && selectedOpportunity ? { + id: randomUUID(), + domain: selectedOpportunity.domain, + targetEventId: selectedOpportunity.targetEventId, + prompt: selectedOpportunity.prompt, + recallCost: selectedOpportunity.privacyCost >= .2 + ? "high" as const + : selectedOpportunity.recallEase < .6 + ? "medium" as const + : "low" as const, + reason: selectedOpportunity.reason, + } : agentVisible ? null : legacyProjection.nextQuestion; + const status = agentVisible + ? finalDecision.action === "offer_candidate_range" + ? "range_ready" as const + : finalDecision.action === "stop_low_confidence" + ? "paused" as const + : "awaiting_answer" as const + : legacyProjection.status; + const phase = agentVisible + ? status === "awaiting_answer" ? "collecting_evidence" as const : "complete" as const + : legacyProjection.phase; + + const agentRun: AgentRun = { + id: randomUUID(), + caseId: claimed.case.id, + jobId: claimed.job.id, + caseVersion: claimed.case.version, + modelId: claimed.case.orchestrationModelId, + skillVersion: claimed.case.skillVersion, + promptVersion: claimed.case.promptVersion, + deploymentMode: claimed.case.deploymentMode, + deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null, + decision: rawDecision, + validatedDecision, + toolCalls: [...reasoned.toolCalls], + fallbackReason, + inputTokenCount: reasoned.inputTokenCount, + outputTokenCount: reasoned.outputTokenCount, + latencyMs: reasoned.latencyMs, + createdAt: now.toISOString(), + }; + return { + newEventRevisions: extracted, + pendingEvidence: [...reconciliation.pending], + snapshot, + diagnostics, + featureSnapshot, + validatedDecision, + publicMessage, + nextQuestion, + agentRun, + status, + phase, + }; +} diff --git a/frontend/src/lib/rectification-agent/reasoner-agent.ts b/frontend/src/lib/rectification-agent/reasoner-agent.ts new file mode 100644 index 00000000..18eec113 --- /dev/null +++ b/frontend/src/lib/rectification-agent/reasoner-agent.ts @@ -0,0 +1,177 @@ +import path from "node:path"; +import { Agent } from "@mastra/core/agent"; +import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; +import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; +import type { CandidateSnapshot, RectificationV4Case } from "../rectification-v4/contracts.ts"; +import { deterministicDecision } from "./fallback-policy.ts"; +import { recordRectificationAgentTelemetry } from "./telemetry.ts"; +import { + rectificationDecisionSchema, + rectificationDiagnosticSchema, + type DiagnosticsSummary, + type QuestionOpportunity, + type RectificationDecision, + type RectificationDiagnostic, + type ToolCallTrace, +} from "./contracts.ts"; + +const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification"); +type Usage = Readonly<{ inputTokens?: number; outputTokens?: number }>; +type GeneratedDecision = Readonly<{ object: unknown; totalUsage?: Usage | Promise }>; +export type RectificationReasonerGenerator = ( + prompt: string, + phase: "initial" | "after_diagnostic", +) => Promise; + +function diagnosticPayload(diagnostic: RectificationDiagnostic, summary: DiagnosticsSummary) { + switch (diagnostic) { + case "leave_one_event_out": return { retentionRate: summary.leaveOneEventOutRetentionRate, unstableEventIds: summary.unstableEventIds }; + case "leave_one_domain_out": return { retentionRate: summary.leaveOneDomainOutRetentionRate }; + case "date_sensitivity": return { retentionRate: summary.dateSensitivityRetentionRate, events: summary.eventDateSensitivity }; + case "neighbor_stability": return { supportMinutes: summary.neighborSupportMinutes, clusterMassRatio: summary.clusterMassRatio }; + case "candidate_split": return { marginPercent: summary.primarySecondaryMarginPercent, splits: summary.candidateSplits }; + } +} + +export async function runBoundedReasoner(input: Readonly<{ + caseValue: RectificationV4Case; + snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary; + opportunities: readonly QuestionOpportunity[]; + maxToolCalls?: number; + timeoutMs?: number; + enabled?: boolean; + generateDecision?: RectificationReasonerGenerator; +}>): Promise> { + const started = Date.now(); + const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null; + const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel(); + const modelId = model?.id ?? input.caseValue.orchestrationModelId; + const toolCalls: ToolCallTrace[] = []; + let inputTokenCount = 0; + let outputTokenCount = 0; + let usageObserved = false; + const fallback = (reason: string) => { + recordRectificationAgentTelemetry({ + caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId, + toolName: null, decisionAction: null, durationMs: Date.now() - started, + errorCode: reason, deploymentSha, + }); + return { + decision: deterministicDecision(input), mode: "deterministic_fallback" as const, + fallbackReason: reason, toolCalls: [...toolCalls], + inputTokenCount: usageObserved ? inputTokenCount : null, + outputTokenCount: usageObserved ? outputTokenCount : null, + latencyMs: Date.now() - started, + }; + }; + if (input.enabled === false) return fallback("deployment_mode_legacy"); + if (!model && !input.generateDecision) return fallback("reasoner_model_unavailable"); + + const maxToolCalls = input.maxToolCalls ?? 1; + const used = new Set(); + const readDiagnostic = async (diagnostic: RectificationDiagnostic) => { + const toolStarted = Date.now(); + if (used.size >= maxToolCalls || used.has(diagnostic)) { + const trace = { tool: "run_rectification_diagnostics", diagnostic, outcome: "rejected" as const, durationMs: Date.now() - toolStarted, errorCode: "diagnostic_budget_exhausted" }; + toolCalls.push(trace); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "tool", outcome: "rejected", modelId, toolName: trace.tool, decisionAction: "run_diagnostic", durationMs: trace.durationMs, errorCode: trace.errorCode, deploymentSha }); + throw new Error("diagnostic_budget_exhausted"); + } + used.add(diagnostic); + try { + const result = diagnosticPayload(diagnostic, input.diagnostics); + const trace = { tool: "run_rectification_diagnostics", diagnostic, outcome: "succeeded" as const, durationMs: Date.now() - toolStarted, errorCode: null }; + toolCalls.push(trace); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "tool", outcome: "succeeded", modelId, toolName: trace.tool, decisionAction: "run_diagnostic", durationMs: trace.durationMs, errorCode: null, deploymentSha }); + return result; + } catch (error) { + const trace = { tool: "run_rectification_diagnostics", diagnostic, outcome: "failed" as const, durationMs: Date.now() - toolStarted, errorCode: "diagnostic_read_failed" }; + toolCalls.push(trace); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "tool", outcome: "failed", modelId, toolName: trace.tool, decisionAction: "run_diagnostic", durationMs: trace.durationMs, errorCode: trace.errorCode, deploymentSha }); + throw error; + } + }; + const diagnosticsTool = createTool({ + id: "run_rectification_diagnostics", + description: "Read one server-owned diagnostic for the current rectification snapshot. Inputs cannot contain case data, dates, candidates, or scores.", + inputSchema: z.object({ diagnostic: rectificationDiagnosticSchema }).strict(), + outputSchema: z.object({ diagnostic: rectificationDiagnosticSchema, result: z.unknown() }).strict(), + execute: async ({ diagnostic }) => ({ diagnostic, result: await readDiagnostic(diagnostic) }), + }); + const agent = model ? new Agent({ + id: `rectification-v5-reasoner-${model.id}`, + name: "Bounded Birth Time Rectification Reasoner", + model: model.model, + skills: [skillPath], + tools: { run_rectification_diagnostics: diagnosticsTool }, + instructions: "Choose one server-owned action. Never create an event id, candidate, score, date, question, calculation input, or birth minute. Ask only by opportunityId. Candidate ranges may only use currentSnapshotId. You may request or call one diagnostic, then must return a final non-diagnostic action. Return strict structured output.", + }) : null; + const generate: RectificationReasonerGenerator = input.generateDecision ?? (async (prompt) => { + if (!agent) throw new Error("reasoner_model_unavailable"); + return agent.generate(prompt, { + abortSignal: AbortSignal.timeout(input.timeoutMs ?? 20_000), + maxSteps: maxToolCalls + 2, + structuredOutput: { schema: rectificationDecisionSchema, jsonPromptInjection: "inline" }, + }); + }); + const addUsage = async (result: GeneratedDecision) => { + if (!result.totalUsage) return; + const usage = await result.totalUsage; + inputTokenCount += Math.max(0, Math.trunc(usage.inputTokens ?? 0)); + outputTokenCount += Math.max(0, Math.trunc(usage.outputTokens ?? 0)); + usageObserved = true; + }; + const baseState = { + task: "Choose the next bounded rectification action.", + currentSnapshotId: input.snapshot?.id ?? null, + canOfferCandidateRange: input.snapshot?.canAcceptRange ?? false, + compactDiagnostics: { + primaryClusterRetentionRate: input.diagnostics.primaryClusterRetentionRate, + mostDiscriminatingLayers: input.diagnostics.mostDiscriminatingLayers, + }, + opportunities: input.opportunities.map(({ opportunityId, kind, targetEventId, utility, reason }) => ({ opportunityId, kind, targetEventId, utility, reason })), + }; + + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "started", modelId, toolName: null, decisionAction: null, durationMs: null, errorCode: null, deploymentSha }); + try { + const first = await generate(JSON.stringify(baseState), "initial"); + await addUsage(first); + let decision = rectificationDecisionSchema.parse(first.object); + if (decision.action === "run_diagnostic") { + const result = await readDiagnostic(decision.diagnostic); + const second = await generate(JSON.stringify({ + ...baseState, + requiredFinalAction: true, + diagnosticResult: { diagnostic: decision.diagnostic, result }, + }), "after_diagnostic"); + await addUsage(second); + decision = rectificationDecisionSchema.parse(second.object); + if (decision.action === "run_diagnostic") return fallback("reasoner_returned_nonfinal_diagnostic"); + } + const latencyMs = Date.now() - started; + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "succeeded", modelId, toolName: null, decisionAction: decision.action, durationMs: latencyMs, errorCode: null, deploymentSha }); + return { + decision, mode: "agent", fallbackReason: null, toolCalls, + inputTokenCount: usageObserved ? inputTokenCount : null, + outputTokenCount: usageObserved ? outputTokenCount : null, + latencyMs, + }; + } catch (error) { + const reason = error instanceof DOMException && error.name === "TimeoutError" ? "reasoner_timeout" + : error instanceof Error && error.message === "diagnostic_budget_exhausted" ? "diagnostic_budget_exhausted" + : error instanceof Error && error.message === "reasoner_model_unavailable" ? "reasoner_model_unavailable" + : "reasoner_failed"; + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "failed", modelId, toolName: null, decisionAction: null, durationMs: Date.now() - started, errorCode: reason, deploymentSha }); + return fallback(reason); + } +} diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts new file mode 100644 index 00000000..553c779c --- /dev/null +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -0,0 +1,71 @@ +import path from "node:path"; +import { Agent } from "@mastra/core/agent"; +import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; +import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts"; +import { publicMessageSchema, type PublicMessage, type ValidatedDecision } from "./contracts.ts"; +import { recordRectificationAgentTelemetry } from "./telemetry.ts"; + +const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification"); +const agents = new Map(); +function agentFor(modelId: string | null): { id: string; agent: Agent } | null { + const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel(); + if (!selected) return null; + const cached = agents.get(selected.id); + if (cached) return { id: selected.id, agent: cached }; + const agent = new Agent({ + id: `rectification-v5-renderer-${selected.id}`, name: "Birth Time Rectification Response Renderer", model: selected.model, skills: [skillPath], + instructions: "Write concise natural Simplified Chinese. Acknowledge the latest experience, state uncertainty honestly, and never expose ids, scores, internal domains, representative minutes, model/tool details, or claim an exact birth minute. Return strict JSON only.", + }); + agents.set(selected.id, agent); + return { id: selected.id, agent }; +} + +function deterministic(input: { latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision }): PublicMessage { + const latest = input.acceptedEvents.at(-1); + const acknowledgement = latest + ? `我记下了你提到的“${latest.summary}”,并保留了你给出的时间精度。` + : input.pendingEvidence.length + ? "我保留了你刚才的原始描述;其中的日期或事件关系还不能安全进入评分。" + : input.latestAnswer + ? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。" + : "我会继续根据已确认的人生事件比较候选范围。"; + const primary = input.snapshot?.clusters[0]; + const candidateUpdate = primary ? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。` : null; + const limitation = input.validated.decision.action === "stop_low_confidence" ? "现有证据不足以安全缩小范围,我不会把不稳定结果包装成确定时间。" : null; + return { acknowledgement, candidateUpdate, limitation, question: input.validated.selectedOpportunity?.prompt ?? null }; +} + +export function enforceServerQuestion(value: unknown, question: string | null): PublicMessage { + return { ...publicMessageSchema.parse(value), question }; +} + +export async function renderPublicTurn(input: Readonly<{ + caseValue: RectificationV4Case; latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; + pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision; timeoutMs?: number; +}>): Promise { + const started = Date.now(); + const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null; + const fallback = deterministic(input); + const selected = agentFor(input.caseValue.narrationModelId); + if (!selected) { + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: input.caseValue.narrationModelId, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_model_unavailable", deploymentSha }); + return fallback; + } + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "started", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: null, errorCode: null, deploymentSha }); + try { + const result = await selected.agent.generate(JSON.stringify({ + task: "Render the public turn. The server-owned question must not be changed.", latestAnswer: input.latestAnswer, + acceptedEvents: input.acceptedEvents.slice(-3).map((event) => ({ summary: event.summary, date: event.dateRange.label, subject: event.subject })), + pendingEvidence: input.pendingEvidence.slice(-3).map((event) => ({ rawText: event.rawText, reasonCode: event.reasonCode })), + candidateRange: input.snapshot?.clusters[0] ? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime } : null, + action: input.validated.decision.action, exactQuestion: input.validated.selectedOpportunity?.prompt ?? null, + }), { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), structuredOutput: { schema: publicMessageSchema, jsonPromptInjection: "inline" } }); + const message = enforceServerQuestion(result.object, input.validated.selectedOpportunity?.prompt ?? null); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: null, deploymentSha }); + return message; + } catch { + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "failed", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_failed", deploymentSha }); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_failed", deploymentSha }); + return fallback; + } +} diff --git a/frontend/src/lib/rectification-agent/telemetry.ts b/frontend/src/lib/rectification-agent/telemetry.ts new file mode 100644 index 00000000..ae372731 --- /dev/null +++ b/frontend/src/lib/rectification-agent/telemetry.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +const telemetryEventSchema = z.object({ + caseId: z.string().uuid().nullable(), + phase: z.enum(["reasoner", "renderer", "tool", "fallback"]), + outcome: z.enum(["started", "succeeded", "failed", "rejected"]), + modelId: z.string().trim().min(1).max(120).nullable(), + toolName: z.string().trim().min(1).max(120).nullable(), + decisionAction: z.string().trim().min(1).max(80).nullable(), + durationMs: z.number().int().min(0).max(300_000).nullable(), + errorCode: z.string().trim().min(1).max(120).nullable(), + deploymentSha: z.string().trim().min(1).max(80).nullable(), +}).strict(); + +export type RectificationAgentTelemetryEvent = z.infer; + +export function recordRectificationAgentTelemetry( + event: RectificationAgentTelemetryEvent, +): void { + const parsed = telemetryEventSchema.safeParse(event); + if (!parsed.success) return; + const line = JSON.stringify({ + ...parsed.data, + component: "rectification-agent", + at: new Date().toISOString(), + }); + if (parsed.data.outcome === "failed" || parsed.data.outcome === "rejected") { + console.warn(`[rectification-agent] ${line}`); + } else { + console.info(`[rectification-agent] ${line}`); + } +} diff --git a/frontend/src/lib/rectification-v4/candidate-engine.ts b/frontend/src/lib/rectification-v4/candidate-engine.ts index 62101c3c..499cfed3 100644 --- a/frontend/src/lib/rectification-v4/candidate-engine.ts +++ b/frontend/src/lib/rectification-v4/candidate-engine.ts @@ -2,21 +2,68 @@ import { z } from "zod"; import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "./contracts.ts"; import { rectificationV4AlgorithmVersion } from "./contracts.ts"; -const responseSchema = z.object({ - result_id: z.string().uuid(), +const uuid = z.string().uuid(); +const hash = z.string().regex(/^[a-f0-9]{64}$/); +const dateSensitivitySchema = z.object({ + event_id: uuid, + declared_date_range: z.object({ start: z.string(), end: z.string(), precision: z.string() }), + sample_dates: z.array(z.string()).min(1).max(12), + winner_retention_rate: z.number().min(0).max(1), + score_variance: z.number().nonnegative(), + candidate_cluster_retention_rate: z.number().min(0).max(1), +}).passthrough(); +const diagnosticsSchema = z.object({ + primary_cluster_retention_rate: z.number().min(0).max(1), + leave_one_event_out_retention_rate: z.number().min(0).max(1), + leave_one_domain_out_retention_rate: z.number().min(0).max(1), + date_sensitivity_retention_rate: z.number().min(0).max(1), + neighbor_support_minutes: z.number().int().nonnegative(), + primary_secondary_margin_percent: z.number().min(0).max(100), + cluster_mass_ratio: z.number().min(0).max(1), + unstable_event_ids: z.array(uuid), + most_discriminating_layers: z.array(z.string()), + event_date_sensitivity: z.array(dateSensitivitySchema), + candidate_splits: z.array(z.object({ + left_cluster: z.object({ start: z.string(), end: z.string() }), + right_cluster: z.object({ start: z.string(), end: z.string() }), + technique_layers: z.array(z.string()), + event_ids: z.array(uuid), + }).passthrough()), +}).passthrough(); +const featureSchema = z.object({ + calculation_spec_hash: hash, algorithm_version: z.literal(rectificationV4AlgorithmVersion), - calculation_spec_hash: z.string().regex(/^[a-f0-9]{64}$/), + candidate_count: z.number().int().positive(), + feature_hash: hash, + features: z.array(z.object({ + time: z.string(), + ascendant_degree: z.number().nullable(), + ascendant_sign_index: z.number().int().min(0).max(11).nullable(), + varga_ascendants: z.record(z.string(), z.number().int().min(0).max(11)), + arudha_signs: z.object({ A7: z.number().int().min(0).max(11).nullable(), A10: z.number().int().min(0).max(11).nullable(), UL: z.number().int().min(0).max(11).nullable() }), + available_layers: z.array(z.string()), blocked_layers: z.array(z.string()), + fingerprints: z.record(z.string(), z.string()), + }).passthrough()), +}).passthrough(); +const responseSchema = z.object({ + result_id: uuid, + algorithm_version: z.literal(rectificationV4AlgorithmVersion), + calculation_spec_hash: hash, candidate_scores: z.array(z.object({ - time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), - score: z.number().finite(), - supporting_event_ids: z.array(z.string().uuid()), - conflicting_event_ids: z.array(z.string().uuid()), + time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), score: z.number().finite(), + supporting_event_ids: z.array(uuid), conflicting_event_ids: z.array(uuid), }).strict()).min(1).max(1_440), robustness: z.object({ neighbor_support_minutes: z.number().int().nonnegative(), - leave_one_out_retention_rate: z.number().finite().min(0).max(1), - date_sensitivity_retention_rate: z.number().finite().min(0).max(1), + leave_one_out_retention_rate: z.number().min(0).max(1), + leave_one_domain_out_retention_rate: z.number().min(0).max(1), + date_sensitivity_retention_rate: z.number().min(0).max(1), }).passthrough(), + diagnostics: diagnosticsSchema, + candidate_feature_snapshot: featureSchema, + event_contribution_matrix: z.record(z.string(), z.record(z.string(), z.object({ + points: z.number(), rule_ids: z.array(z.string()), technique_layers: z.array(z.string()), + }).passthrough())), missing_layers: z.array(z.string()), can_confirm_exact_minute: z.literal(false), }).passthrough(); @@ -25,11 +72,10 @@ export type CandidateEngineResult = Readonly<{ resultId: string; calculationSpecHash: string; candidates: readonly CandidateMinute[]; - robustness: { - readonly neighborSupportMinutes: number; - readonly leaveOneOutRetentionRate: number; - readonly dateSensitivityRetentionRate: number; - }; + robustness: { neighborSupportMinutes: number; leaveOneOutRetentionRate: number; leaveOneDomainOutRetentionRate: number; dateSensitivityRetentionRate: number }; + diagnostics: z.infer; + featureSnapshot: z.infer; + contributionMatrix: z.infer["event_contribution_matrix"]; missingLayers: readonly string[]; }>; @@ -37,54 +83,37 @@ export interface RectificationV4CandidateEngine { score(input: { readonly calculationSpec: CalculationSpec; readonly events: readonly LifeEventRevision[] }): Promise; } -export function createRectificationV4CandidateEngine(options: { - readonly apiBase: string; - readonly fetchImpl?: typeof fetch; -}): RectificationV4CandidateEngine { +export function createRectificationV4CandidateEngine(options: { readonly apiBase: string; readonly fetchImpl?: typeof fetch }): RectificationV4CandidateEngine { const fetchImpl = options.fetchImpl ?? fetch; - return { - async score({ calculationSpec, events }) { - const response = await fetchImpl(`${options.apiBase}/api/active_rectification_events_v4`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - birth_date: calculationSpec.birthDate, - start_time: calculationSpec.candidateRange.start, - end_time: calculationSpec.candidateRange.end, - lat: calculationSpec.latitude, - lon: calculationSpec.longitude, - tz: calculationSpec.timezoneOffsetHours, - events: events.map((event) => ({ - id: event.eventId, - domain: event.domain, - event_kind: event.eventKind, - date_start: event.dateRange.start, - date_end: event.dateRange.end, - precision: event.dateRange.precision, - summary: event.summary, - })), - }), - signal: AbortSignal.timeout(5 * 60_000), - }); - const payload: unknown = await response.json(); - if (!response.ok) throw new Error(`rectification_v4_engine_${response.status}`); - const parsed = responseSchema.parse(payload); - return { - resultId: parsed.result_id, - calculationSpecHash: parsed.calculation_spec_hash, - candidates: parsed.candidate_scores.map((candidate) => ({ - time: candidate.time, - score: candidate.score, - supportingEventIds: candidate.supporting_event_ids, - conflictingEventIds: candidate.conflicting_event_ids, + return { async score({ calculationSpec, events }) { + const response = await fetchImpl(`${options.apiBase}/api/rectification/v5/score`, { + method: "POST", headers: { "content-type": "application/json" }, signal: AbortSignal.timeout(5 * 60_000), + body: JSON.stringify({ + birth_date: calculationSpec.birthDate, start_time: calculationSpec.candidateRange.start, end_time: calculationSpec.candidateRange.end, + lat: calculationSpec.latitude, lon: calculationSpec.longitude, tz: calculationSpec.timezoneOffsetHours, + events: events.map((event) => ({ + id: event.eventId, domain: event.domain, event_kind: event.eventKind, + date_start: event.dateRange.start, date_end: event.dateRange.end, precision: event.dateRange.precision, summary: event.summary, })), - robustness: { - neighborSupportMinutes: parsed.robustness.neighbor_support_minutes, - leaveOneOutRetentionRate: parsed.robustness.leave_one_out_retention_rate, - dateSensitivityRetentionRate: parsed.robustness.date_sensitivity_retention_rate, - }, - missingLayers: parsed.missing_layers, - }; - }, - }; + }), + }); + const payload: unknown = await response.json(); + if (!response.ok) throw new Error(`rectification_v5_engine_${response.status}`); + const parsed = responseSchema.parse(payload); + return { + resultId: parsed.result_id, + calculationSpecHash: parsed.calculation_spec_hash, + candidates: parsed.candidate_scores.map((candidate) => ({ time: candidate.time, score: candidate.score, supportingEventIds: candidate.supporting_event_ids, conflictingEventIds: candidate.conflicting_event_ids })), + robustness: { + neighborSupportMinutes: parsed.robustness.neighbor_support_minutes, + leaveOneOutRetentionRate: parsed.robustness.leave_one_out_retention_rate, + leaveOneDomainOutRetentionRate: parsed.robustness.leave_one_domain_out_retention_rate, + dateSensitivityRetentionRate: parsed.robustness.date_sensitivity_retention_rate, + }, + diagnostics: parsed.diagnostics, + featureSnapshot: parsed.candidate_feature_snapshot, + contributionMatrix: parsed.event_contribution_matrix, + missingLayers: parsed.missing_layers, + }; + }}; } diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index 8814aefc..886c63c9 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -5,9 +5,10 @@ import type { RectificationV4ApiResponse, RectificationV4Case, } from "./contracts.ts"; -import { rectificationV4Protocol } from "./contracts.ts"; +import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts"; +import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts"; import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; -import { openingQuestion } from "./question-planner.ts"; +import { openingQuestion } from "./opening-question.ts"; import type { RectificationV4Store } from "./store.ts"; export function createRectificationV4CaseService(store: RectificationV4Store, options: { readonly now?: () => Date } = {}) { @@ -29,10 +30,11 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op return { async createCase(input: { readonly userId: string; readonly actionId: string; readonly calculationSpec: CalculationSpec }) { const timestamp = now().toISOString(); + const deploymentMode = selectRectificationDeploymentMode(input.userId); const caseValue: RectificationV4Case = { id: randomUUID(), userId: input.userId, - protocol: rectificationV4Protocol, + protocol: deploymentMode === "v4_legacy" ? rectificationV4Protocol : rectificationAgentV5Protocol, version: 0, status: "awaiting_answer", phase: "collecting_evidence", @@ -41,6 +43,15 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op evidenceSetHash: evidenceSetHash([]), currentQuestion: openingQuestion(input.calculationSpec.candidateRange), latestSnapshot: null, + orchestrationModelId: process.env.RECTIFICATION_ORCHESTRATION_MODEL_ID?.trim() || null, + narrationModelId: process.env.RECTIFICATION_NARRATION_MODEL_ID?.trim() || null, + skillVersion: "birth-time-rectification-v5", + promptVersion: "rectification-agent-v5-1", + algorithmVersion: rectificationV4AlgorithmVersion, + deploymentMode, + agentMode: "deterministic_fallback", + featureSnapshotId: null, + latestDiagnosticsId: null, acceptedRange: null, createdAt: timestamp, updatedAt: timestamp, diff --git a/frontend/src/lib/rectification-v4/contracts.ts b/frontend/src/lib/rectification-v4/contracts.ts index cf6a761b..4d18165c 100644 --- a/frontend/src/lib/rectification-v4/contracts.ts +++ b/frontend/src/lib/rectification-v4/contracts.ts @@ -1,7 +1,10 @@ import { z } from "zod"; export const rectificationV4Protocol = "rectification-evidence-v4" as const; -export const rectificationV4AlgorithmVersion = "rectification-v4-range-scoring-1" as const; +export const rectificationAgentV5Protocol = "rectification-evidence-v5" as const; +export const rectificationDeploymentModeSchema = z.enum(["v4_legacy", "v5_shadow", "v5_agent"]); +export type RectificationDeploymentMode = z.infer; +export const rectificationV4AlgorithmVersion = "rectification-v5-matrix-scoring-1" as const; export const rectificationV4CaseStatusSchema = z.enum([ "awaiting_answer", @@ -18,6 +21,8 @@ export const rectificationV4PhaseSchema = z.enum([ "scoring_candidates", "checking_robustness", "planning_question", + "reasoning", + "rendering", "complete", ]); export type RectificationV4Phase = z.infer; @@ -41,7 +46,10 @@ export const eventKindSchema = z.enum([ "relationship_end", "career_change", "finance_change", - "health_event", + "self_health_event", + "family_health_event", + "family_bereavement", + "relationship_change", "family_event", "other", ]); @@ -65,7 +73,13 @@ export const eventDateRangeSchema = z.object({ }); export type EventDateRange = z.infer; -export const scoreabilitySchema = z.enum(["scoreable", "context_only"]); +export const eventSubjectSchema = z.enum(["self", "family", "partner", "other"]); +export type EventSubject = z.infer; + +export const relatedPersonSchema = z.enum(["father", "mother", "grandparent", "sibling", "partner"]); +export type RelatedPerson = z.infer; + +export const scoreabilitySchema = z.enum(["scoreable", "context_only", "pending_review", "unsupported"]); export type Scoreability = z.infer; export const lifeEventRevisionSchema = z.object({ @@ -74,6 +88,8 @@ export const lifeEventRevisionSchema = z.object({ revision: z.number().int().positive(), domain: evidenceDomainSchema, eventKind: eventKindSchema, + subject: eventSubjectSchema, + relatedPerson: relatedPersonSchema.nullable(), summary: z.string().trim().min(1).max(1_000), rawText: z.string().trim().min(1).max(4_000), dateRange: eventDateRangeSchema, @@ -83,6 +99,19 @@ export const lifeEventRevisionSchema = z.object({ }).strict(); export type LifeEventRevision = z.infer; +export const pendingEvidenceSchema = z.object({ + id: z.string().uuid(), + caseId: z.string().uuid(), + turnId: z.string().uuid(), + rawText: z.string().trim().min(1).max(4_000), + reasonCode: z.enum(["date_unresolved", "event_unparsed"]), + targetEventId: z.string().uuid().nullable(), + resolvedEventId: z.string().uuid().nullable(), + createdAt: z.string().datetime({ offset: true }), + resolvedAt: z.string().datetime({ offset: true }).nullable(), +}).strict(); +export type PendingEvidence = z.infer; + export const calculationSpecSchema = z.object({ version: z.literal("rectification-calculation-spec-v4"), birthDate: calendarDateSchema, @@ -168,7 +197,7 @@ export type RectificationV4Turn = z.infer; export const rectificationV4CaseSchema = z.object({ id: z.string().uuid(), userId: z.string().uuid(), - protocol: z.literal(rectificationV4Protocol), + protocol: z.union([z.literal(rectificationV4Protocol), z.literal(rectificationAgentV5Protocol)]), version: z.number().int().nonnegative(), status: rectificationV4CaseStatusSchema, phase: rectificationV4PhaseSchema, @@ -177,6 +206,15 @@ export const rectificationV4CaseSchema = z.object({ evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/), currentQuestion: rectificationV4QuestionSchema.nullable(), latestSnapshot: candidateSnapshotSchema.nullable(), + orchestrationModelId: z.string().trim().min(1).max(120).nullable(), + narrationModelId: z.string().trim().min(1).max(120).nullable(), + skillVersion: z.string().trim().min(1).max(120), + promptVersion: z.string().trim().min(1).max(120), + algorithmVersion: z.string().trim().min(1).max(120), + deploymentMode: rectificationDeploymentModeSchema, + agentMode: z.enum(["agent", "deterministic_fallback"]), + featureSnapshotId: z.string().uuid().nullable(), + latestDiagnosticsId: z.string().uuid().nullable(), acceptedRange: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict().nullable(), createdAt: z.string().datetime({ offset: true }), updatedAt: z.string().datetime({ offset: true }), @@ -200,6 +238,8 @@ export const reviseEventRequestSchema = z.object({ expectedCaseVersion: z.number().int().nonnegative(), domain: evidenceDomainSchema, eventKind: eventKindSchema, + subject: eventSubjectSchema, + relatedPerson: relatedPersonSchema.nullable(), summary: z.string().trim().min(1).max(1_000), rawText: z.string().trim().min(1).max(4_000), dateRange: eventDateRangeSchema, @@ -246,7 +286,7 @@ export const rectificationV4HandoffStatusSchema = z.enum([ ]); export const rectificationV4HandoffSchema = z.object({ - protocol: z.literal(rectificationV4Protocol), + protocol: z.union([z.literal(rectificationV4Protocol), z.literal(rectificationAgentV5Protocol)]), caseId: z.string().uuid(), caseVersion: z.number().int().nonnegative(), question: z.string().trim().min(1).max(500), diff --git a/frontend/src/lib/rectification-v4/domain-scorers.ts b/frontend/src/lib/rectification-v4/domain-scorers.ts index c77fc702..60cbb7ac 100644 --- a/frontend/src/lib/rectification-v4/domain-scorers.ts +++ b/frontend/src/lib/rectification-v4/domain-scorers.ts @@ -10,24 +10,22 @@ export type DomainScorerPolicy = Readonly<{ export const domainScorerRegistry: Readonly> = { education: { domain: "education", defaultScoreability: "scoreable", supportedKinds: ["education_milestone"], techniqueLayers: ["D24", "vimshottari", "narayana"] }, relocation: { domain: "relocation", defaultScoreability: "scoreable", supportedKinds: ["relocation"], techniqueLayers: ["D4", "vimshottari", "narayana"] }, - relationship: { domain: "relationship", defaultScoreability: "scoreable", supportedKinds: ["relationship_start", "relationship_end"], techniqueLayers: ["D9", "UL", "vimshottari", "narayana"] }, + relationship: { domain: "relationship", defaultScoreability: "scoreable", supportedKinds: ["relationship_start", "relationship_end", "relationship_change"], techniqueLayers: ["D9", "UL", "vimshottari", "narayana"] }, career: { domain: "career", defaultScoreability: "scoreable", supportedKinds: ["career_change"], techniqueLayers: ["D10", "A10", "vimshottari", "narayana"] }, finance: { domain: "finance", defaultScoreability: "scoreable", supportedKinds: ["finance_change"], techniqueLayers: ["D2", "D11", "vimshottari", "narayana"] }, - health_pressure: { domain: "health_pressure", defaultScoreability: "scoreable", supportedKinds: ["health_event"], techniqueLayers: ["D30", "vimshottari", "narayana"] }, - family: { domain: "family", defaultScoreability: "context_only", supportedKinds: ["family_event"], techniqueLayers: [] }, - other: { domain: "other", defaultScoreability: "context_only", supportedKinds: ["other"], techniqueLayers: [] }, + health_pressure: { domain: "health_pressure", defaultScoreability: "scoreable", supportedKinds: ["self_health_event"], techniqueLayers: ["D30", "vimshottari", "narayana"] }, + family: { domain: "family", defaultScoreability: "context_only", supportedKinds: ["family_health_event", "family_bereavement", "family_event"], techniqueLayers: [] }, + other: { domain: "other", defaultScoreability: "pending_review", supportedKinds: ["other"], techniqueLayers: [] }, }; export function scoreabilityFor(domain: EvidenceDomain): Scoreability { return domainScorerRegistry[domain].defaultScoreability; } -export function assertScorerSupports(event: Pick): void { +export function assertScorerSupports(event: Pick): void { const policy = domainScorerRegistry[event.domain]; - if (event.scoreability === "scoreable" && !policy.supportedKinds.includes(event.eventKind)) { - throw new Error("unsupported_event_kind_for_domain"); - } - if (event.scoreability === "scoreable" && policy.techniqueLayers.length === 0) { - throw new Error("domain_not_validated_for_scoring"); - } + if (event.scoreability !== "scoreable") return; + if (event.subject !== "self" && !(event.domain === "relationship" && event.subject === "partner")) throw new Error("non_self_event_not_scoreable"); + if (!policy.supportedKinds.includes(event.eventKind)) throw new Error("unsupported_event_kind_for_domain"); + if (policy.techniqueLayers.length === 0) throw new Error("domain_not_validated_for_scoring"); } diff --git a/frontend/src/lib/rectification-v4/extraction.ts b/frontend/src/lib/rectification-v4/extraction.ts index 217dfe81..e6ecb0c2 100644 --- a/frontend/src/lib/rectification-v4/extraction.ts +++ b/frontend/src/lib/rectification-v4/extraction.ts @@ -1,65 +1,180 @@ -import { extractLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts"; -import type { EventKind, EvidenceDomain, LifeEventRevision } from "./contracts.ts"; +import { randomUUID } from "node:crypto"; +import { extractLifeEventEvidence, type ExtractedLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts"; +import type { + EventKind, + EvidenceDomain, + EventSubject, + LifeEventRevision, + PendingEvidence, + RelatedPerson, + Scoreability, +} from "./contracts.ts"; import { dateRangeFromDeclared } from "./date-range.ts"; import { appendEventRevision, latestEventRevisions } from "./evidence-ledger.ts"; -function eventKind(domain: EvidenceDomain, summary: string): EventKind { - if (domain === "relationship") { - return /分手|离婚|结束|断联|分开|破裂/.test(summary) ? "relationship_end" : "relationship_start"; - } - switch (domain) { - case "education": return "education_milestone"; - case "relocation": return "relocation"; - case "career": return "career_change"; - case "finance": return "finance_change"; - case "health_pressure": return "health_event"; - case "family": return "family_event"; - case "other": return "other"; - } +const allowedKinds = new Set([ + "education_milestone", "relocation", "relationship_start", "relationship_end", "relationship_change", + "career_change", "finance_change", "self_health_event", "family_health_event", "family_bereavement", "family_event", "other", +]); +const missingEventSummary = "事件内容待补充"; + +function normalizeKind(domain: EvidenceDomain, value: string, summary: string): EventKind { + if (allowedKinds.has(value as EventKind)) return value as EventKind; + if (domain === "relationship") return /分手|离婚|结束|断联|分开|破裂/.test(summary) ? "relationship_end" : "relationship_start"; + return ({ education: "education_milestone", relocation: "relocation", career: "career_change", finance: "finance_change", health_pressure: "self_health_event", family: "family_event", other: "other" } as const)[domain]; } -export function extractV4EventRevisions(input: { +function pendingEvidence(input: { + caseId: string; + turnId: string; + rawText: string; + reasonCode: PendingEvidence["reasonCode"]; + targetEventId: string | null; + now?: Date; +}): PendingEvidence { + return { + id: randomUUID(), + caseId: input.caseId, + turnId: input.turnId, + rawText: input.rawText.trim(), + reasonCode: input.reasonCode, + targetEventId: input.targetEventId, + resolvedEventId: null, + createdAt: (input.now ?? new Date()).toISOString(), + resolvedAt: null, + }; +} + +function newRevision(event: ExtractedLifeEventEvidence, existing: readonly LifeEventRevision[], now?: Date): LifeEventRevision | null { + if (!event.dateValue || event.datePrecision === "unknown") return null; + const domain = event.domain as EvidenceDomain; + return appendEventRevision(existing, { + eventId: event.id, + domain, + eventKind: normalizeKind(domain, event.eventKind, event.eventSummary), + subject: event.subject as EventSubject, + relatedPerson: event.relatedPerson as RelatedPerson | null, + summary: event.eventSummary, + rawText: event.rawText, + dateRange: dateRangeFromDeclared(event.dateValue, event.datePrecision), + scoreability: event.scoreability as Scoreability, + }, { id: event.id, now }); +} + +function describesTarget(event: ExtractedLifeEventEvidence, target: LifeEventRevision): boolean { + if (event.eventSummary === missingEventSummary) return true; + const eventKind = normalizeKind(event.domain as EvidenceDomain, event.eventKind, event.eventSummary); + if (event.domain !== target.domain || eventKind !== target.eventKind) return false; + return event.rawText.includes(target.summary) + || event.eventSummary.includes(target.summary) + || target.summary.includes(event.eventSummary); +} + +function subjectRevision(answer: string, target: LifeEventRevision, existing: readonly LifeEventRevision[], now?: Date): LifeEventRevision | null { + const compact = answer.trim().replace(/[。!!,,;;\s]/g, ""); + let subject: EventSubject | null = null; + if (/^(我|本人|我本人|我自己|是我|发生在我身上)$/.test(compact)) subject = "self"; + else if (/^(家人|我的家人|父亲|母亲|爸爸|妈妈|祖父母|爷爷|奶奶|外公|外婆)$/.test(compact)) subject = "family"; + else if (/^(伴侣|配偶|对象|男友|女友|丈夫|妻子|老公|老婆)$/.test(compact)) subject = "partner"; + if (!subject) return null; + + const healthEvent = target.eventKind === "self_health_event" || target.eventKind === "family_health_event"; + const domain: EvidenceDomain = healthEvent ? (subject === "self" ? "health_pressure" : "family") : target.domain; + const eventKind: EventKind = healthEvent ? (subject === "self" ? "self_health_event" : "family_health_event") : target.eventKind; + const scoreability: Scoreability = subject === "self" && domain !== "family" && domain !== "other" ? "scoreable" : "context_only"; + const relatedPerson: RelatedPerson | null = subject === "partner" ? "partner" : subject === "family" ? target.relatedPerson : null; + return appendEventRevision(existing, { + eventId: target.eventId, + domain, + eventKind, + subject, + relatedPerson, + summary: target.summary, + rawText: answer, + dateRange: target.dateRange, + scoreability, + }, { now }); +} + +export type ReconciledV4Evidence = Readonly<{ + revisions: readonly LifeEventRevision[]; + pending: readonly PendingEvidence[]; + unansweredTargetEventId: string | null; +}>; + +export function reconcileV4Evidence(input: { + readonly caseId: string; readonly answer: string; readonly sourceTurnId: string; readonly asOfDate: string; readonly existing: readonly LifeEventRevision[]; readonly targetEventId?: string | null; readonly now?: Date; -}): readonly LifeEventRevision[] { - const extracted = extractLifeEventEvidence({ - rawText: input.answer, - sourceTurnId: input.sourceTurnId, - asOfDate: input.asOfDate, - }); - const target = input.targetEventId - ? latestEventRevisions(input.existing).find((event) => event.eventId === input.targetEventId) ?? null - : null; - if (input.targetEventId) { - if (!target) throw new Error("rectification_v4_target_event_not_found"); - const event = extracted.find((value) => value.dateValue && value.datePrecision !== "unknown"); - if (!event?.dateValue || event.datePrecision === "unknown") return []; - const dateRange = dateRangeFromDeclared(event.dateValue, event.datePrecision); - if (dateRange.start > input.asOfDate) return []; - return [appendEventRevision(input.existing, { - eventId: target.eventId, - domain: target.domain, - eventKind: target.eventKind, - summary: target.summary, - rawText: input.answer, - dateRange, - scoreability: target.scoreability, - }, { id: event.id, now: input.now })]; +}): ReconciledV4Evidence { + const extracted = extractLifeEventEvidence({ rawText: input.answer, sourceTurnId: input.sourceTurnId, asOfDate: input.asOfDate }); + const target = input.targetEventId ? latestEventRevisions(input.existing).find((event) => event.eventId === input.targetEventId) ?? null : null; + if (input.targetEventId && !target) throw new Error("rectification_v4_target_event_not_found"); + + const revisions: LifeEventRevision[] = []; + let unresolvedReason: PendingEvidence["reasonCode"] | null = null; + let targetResolved = !target; + + if (target) { + const clarified = subjectRevision(input.answer, target, input.existing, input.now); + if (clarified) { + revisions.push(clarified); + targetResolved = true; + } else { + const targetAnswer = extracted.find((event) => event.dateValue && event.datePrecision !== "unknown" && describesTarget(event, target)); + if (targetAnswer?.dateValue && targetAnswer.datePrecision !== "unknown") { + const dateRange = dateRangeFromDeclared(targetAnswer.dateValue, targetAnswer.datePrecision); + if (dateRange.start <= input.asOfDate) { + revisions.push(appendEventRevision(input.existing, { + eventId: target.eventId, + domain: target.domain, + eventKind: target.eventKind, + subject: target.subject, + relatedPerson: target.relatedPerson, + summary: target.summary, + rawText: input.answer, + dateRange, + scoreability: target.scoreability, + }, { id: targetAnswer.id, now: input.now })); + targetResolved = true; + } + } + } } - return extracted.flatMap((event) => { - if (!event.dateValue || event.datePrecision === "unknown") return []; - const domain = event.domain as EvidenceDomain; - return [appendEventRevision(input.existing, { - eventId: event.id, - domain, - eventKind: eventKind(domain, event.eventSummary), - summary: event.eventSummary, - rawText: event.rawText, - dateRange: dateRangeFromDeclared(event.dateValue, event.datePrecision), - }, { id: event.id, now: input.now })]; - }); + + for (const event of extracted) { + if (revisions.some((revision) => revision.id === event.id)) continue; + const revision = newRevision(event, [...input.existing, ...revisions], input.now); + if (revision && revision.dateRange.start <= input.asOfDate) { + revisions.push(revision); + continue; + } + unresolvedReason = event.datePrecision === "unknown" ? "date_unresolved" : "event_unparsed"; + } + + if (extracted.length === 0) unresolvedReason = "event_unparsed"; + const pending = unresolvedReason ? [ + pendingEvidence({ + caseId: input.caseId, + turnId: input.sourceTurnId, + rawText: input.answer, + reasonCode: unresolvedReason, + targetEventId: target?.eventId ?? null, + now: input.now, + }), + ] : []; + + return { + revisions, + pending, + unansweredTargetEventId: target && !targetResolved ? target.eventId : null, + }; +} + +export function extractV4EventRevisions(input: Omit[0], "caseId"> & { readonly caseId?: string }): readonly LifeEventRevision[] { + return reconcileV4Evidence({ ...input, caseId: input.caseId ?? "00000000-0000-4000-8000-000000000000" }).revisions; } diff --git a/frontend/src/lib/rectification-v4/fingerprints.ts b/frontend/src/lib/rectification-v4/fingerprints.ts index cb5f00d7..2912313c 100644 --- a/frontend/src/lib/rectification-v4/fingerprints.ts +++ b/frontend/src/lib/rectification-v4/fingerprints.ts @@ -11,20 +11,22 @@ function canonical(value: unknown): unknown { return value; } -function hash(value: unknown): string { +export function rectificationFingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex"); } export function calculationSpecHash(spec: CalculationSpec): string { - return hash(spec); + return rectificationFingerprint(spec); } export function evidenceSetHash(revisions: readonly LifeEventRevision[]): string { - return hash(latestEventRevisions(revisions).map((event) => ({ + return rectificationFingerprint(latestEventRevisions(revisions).map((event) => ({ eventId: event.eventId, revision: event.revision, domain: event.domain, eventKind: event.eventKind, + subject: event.subject, + relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, }))); diff --git a/frontend/src/lib/rectification-v4/legacy-projector.ts b/frontend/src/lib/rectification-v4/legacy-projector.ts new file mode 100644 index 00000000..a959a231 --- /dev/null +++ b/frontend/src/lib/rectification-v4/legacy-projector.ts @@ -0,0 +1,70 @@ +import { randomUUID } from "node:crypto"; +import type { PublicMessage } from "../rectification-agent/contracts.ts"; +import type { CandidateSnapshot, LifeEventRevision, RectificationV4Question } from "./contracts.ts"; +import { scoreableEvents } from "./evidence-ledger.ts"; + +export function projectLegacyV4Question(input: Readonly<{ + events: readonly LifeEventRevision[]; + attemptedRefinementEventIds: readonly string[]; + latestAnswer: string; + snapshot: CandidateSnapshot | null; +}>): RectificationV4Question | null { + if (input.snapshot?.canAcceptRange) return null; + const attempted = new Set(input.attemptedRefinementEventIds); + const target = scoreableEvents(input.events) + .filter((event) => !["day", "month"].includes(event.dateRange.precision) && !attempted.has(event.eventId)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || left.eventId.localeCompare(right.eventId))[0]; + if (target) return { + id: randomUUID(), + domain: target.domain, + targetEventId: target.eventId, + prompt: `你刚才提到的“${target.summary.slice(0, 120)}”很重要。你目前记得的时间是${target.dateRange.label};如果还能想起更具体的月份或日期,可以继续说,不确定也没关系。`, + recallCost: "medium", + reason: "V4 legacy projector:细化已有事件日期。", + }; + return { + id: randomUUID(), + domain: "other", + targetEventId: null, + prompt: input.latestAnswer + ? "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。" + : "请从你自己最确定、时间也比较清楚的一段人生经历开始说。你可以一次讲几件连续发生的事,不需要按固定领域回答。", + recallCost: "low", + reason: "V4 legacy projector:保持开放叙述。", + }; +} + +export function projectLegacyV4Turn(input: Readonly<{ + events: readonly LifeEventRevision[]; + newEvents: readonly LifeEventRevision[]; + attemptedRefinementEventIds: readonly string[]; + latestAnswer: string; + snapshot: CandidateSnapshot | null; +}>): Readonly<{ + nextQuestion: RectificationV4Question | null; + publicMessage: PublicMessage; + status: "awaiting_answer" | "range_ready"; + phase: "collecting_evidence" | "complete"; +}> { + const nextQuestion = projectLegacyV4Question(input); + const latest = input.newEvents.at(-1); + const primary = input.snapshot?.clusters[0]; + const rangeReady = Boolean(input.snapshot?.canAcceptRange && primary); + return { + nextQuestion, + publicMessage: { + acknowledgement: latest + ? `我记下了你提到的“${latest.summary}”,并保留了你给出的时间精度。` + : input.latestAnswer + ? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。" + : "我会继续根据已确认的人生事件比较候选范围。", + candidateUpdate: primary + ? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。` + : null, + limitation: null, + question: nextQuestion?.prompt ?? null, + }, + status: rangeReady ? "range_ready" : "awaiting_answer", + phase: rangeReady ? "complete" : "collecting_evidence", + }; +} diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts index 9211bb54..aef2c457 100644 --- a/frontend/src/lib/rectification-v4/memory-store.ts +++ b/frontend/src/lib/rectification-v4/memory-store.ts @@ -1,5 +1,7 @@ +import type { AgentRun, CandidateFeatureSnapshot, DiagnosticsSummary, PublicMessage, ValidatedDecision } from "../rectification-agent/contracts.ts"; import type { LifeEventRevision, + PendingEvidence, RectificationV4Case, RectificationV4Job, } from "./contracts.ts"; @@ -15,12 +17,24 @@ import { evidenceSetHash } from "./fingerprints.ts"; export function createRectificationV4MemoryStore(): RectificationV4Store & { readonly cases: Map; readonly jobs: Map; + readonly diagnostics: Map; + readonly featureSnapshots: Map; + readonly agentRuns: Map; + readonly publicMessages: Map; + readonly validatedDecisions: Map; + readonly pendingEvidence: Map; } { const cases = new Map(); const events = new Map(); const turns = new Map(); const jobs = new Map(); const actionResults = new Map(); + const diagnostics = new Map(); + const featureSnapshots = new Map(); + const agentRuns = new Map(); + const publicMessages = new Map(); + const validatedDecisions = new Map(); + const pendingEvidence = new Map(); function owned(userId: string, caseId: string): RectificationV4Case { const value = cases.get(caseId); @@ -31,6 +45,12 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { return { cases, jobs, + diagnostics, + featureSnapshots, + agentRuns, + publicMessages, + validatedDecisions, + pendingEvidence, async findActiveCase(userId) { return [...cases.values()].find((value) => value.userId === userId && value.status !== "abandoned" && value.acceptedRange === null) ?? null; @@ -206,11 +226,20 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { || current.calculationSpecHash !== input.calculationSpecHash) throw new RectificationV4StoreError("stale_job"); const nextEvents = [...(events.get(current.id) ?? []), ...input.newEventRevisions]; events.set(current.id, nextEvents); + if (input.diagnostics) diagnostics.set(input.diagnostics.id, input.diagnostics); + if (input.featureSnapshot) featureSnapshots.set(input.featureSnapshot.id, input.featureSnapshot); + agentRuns.set(input.agentRun.id, input.agentRun); + publicMessages.set(input.jobId, input.publicMessage); + validatedDecisions.set(input.jobId, input.validatedDecision); + for (const item of input.pendingEvidence) pendingEvidence.set(item.id, item); const updated: RectificationV4Case = { ...current, version: current.version + 1, evidenceSetHash: input.outputEvidenceSetHash, latestSnapshot: input.snapshot, + agentMode: input.validatedDecision.mode, + featureSnapshotId: input.featureSnapshot?.id ?? current.featureSnapshotId, + latestDiagnosticsId: input.diagnostics?.id ?? current.latestDiagnosticsId, currentQuestion: input.nextQuestion, status: input.status, phase: input.phase, diff --git a/frontend/src/lib/rectification-v4/opening-question.ts b/frontend/src/lib/rectification-v4/opening-question.ts new file mode 100644 index 00000000..01e6ccc7 --- /dev/null +++ b/frontend/src/lib/rectification-v4/opening-question.ts @@ -0,0 +1,16 @@ +import { randomUUID } from "node:crypto"; +import type { RectificationV4Question } from "./contracts.ts"; + +export function openingQuestion( + candidateRange: Readonly<{ start: string; end: string }>, + id?: string, +): RectificationV4Question { + return { + id: id ?? randomUUID(), + domain: "other", + targetEventId: null, + prompt: `我会先在 ${candidateRange.start}–${candidateRange.end} 这个范围内核对,它还不是已确认的出生分钟。请从你自己最确定、时间也比较清楚的一段人生经历开始说;可以一次讲几件连续发生的事,不需要按固定领域回答。`, + recallCost: "low", + reason: "首轮允许开放叙述,由后续系统根据真实经历选择高信息量问题。", + }; +} diff --git a/frontend/src/lib/rectification-v4/question-author.ts b/frontend/src/lib/rectification-v4/question-author.ts deleted file mode 100644 index e0e63ee6..00000000 --- a/frontend/src/lib/rectification-v4/question-author.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { randomUUID } from "node:crypto"; -import path from "node:path"; -import { Agent } from "@mastra/core/agent"; -import { z } from "zod"; -import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; -import { - evidenceDomainSchema, - type CandidateSnapshot, - type LifeEventRevision, - type RectificationV4Question, - type RectificationV4Turn, -} from "./contracts.ts"; -import { planNextQuestion } from "./question-planner.ts"; - -const outputSchema = z.object({ - domain: evidenceDomainSchema, - targetEventId: z.string().uuid().nullable(), - prompt: z.string().trim().min(1).max(1_000), - recallCost: z.enum(["low", "medium", "high"]), - reason: z.string().trim().min(1).max(240), -}).strict(); - -const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim() - || path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology"); -const agents = new Map(); -const internalCopyPattern = /(?:候选分数|内部(?:领域|路由|状态)|评分权重|\b(?:education|relocation|relationship|career|finance|health_pressure|family|other)\b)/iu; - -function agentFor(modelId: string | null) { - const model = modelId ? resolveLanguageModel(modelId) : defaultLanguageModel(); - const selected = model ?? defaultLanguageModel(); - if (!selected) return null; - const cached = agents.get(selected.id); - if (cached) return cached; - const agent = new Agent({ - id: `rectification-v4-question-${selected.id}`, - name: "Rectification V4 Conversational Question Author", - model: selected.model, - skills: [jyotishSkillPath], - instructions: "Return only the requested JSON. Act as a birth-time rectification conversation partner, not a questionnaire. Respond to the user's latest concrete experience, then ask at most one natural open question that can materially improve evidence quality or distinguish the remaining candidate range. Choose the next evidence domain from context; never follow a fixed domain order. Never expose domain labels, event ids, scores, routing metadata, gate reasons, or implementation status in the visible prompt.", - }); - agents.set(selected.id, agent); - return agent; -} - -function latestByEvent(events: readonly LifeEventRevision[]) { - const latest = new Map(); - for (const event of events) { - const current = latest.get(event.eventId); - if (!current || current.revision < event.revision) latest.set(event.eventId, event); - } - return [...latest.values()]; -} - -export async function authorRectificationV4Question(input: Readonly<{ - modelId: string | null; - candidateRange: Readonly<{ start: string; end: string }>; - snapshot: CandidateSnapshot | null; - turns: readonly RectificationV4Turn[]; - events: readonly LifeEventRevision[]; - attemptedRefinementEventIds: readonly string[]; -}>): Promise { - const plannedQuestion = planNextQuestion({ - events: input.events, - attemptedRefinementEventIds: input.attemptedRefinementEventIds, - latestAnswer: input.turns.at(-1)?.answer, - }); - const fallback = () => plannedQuestion; - const agent = agentFor(input.modelId); - if (!agent) return fallback(); - - const events = latestByEvent(input.events); - const allowedTargets = new Map(events.map((event) => [event.eventId, event])); - const requiredContinuation = plannedQuestion.targetEventId - ? allowedTargets.get(plannedQuestion.targetEventId) ?? null - : null; - const recentTurns = input.turns.slice(-6).flatMap((turn) => [ - { role: "assistant", text: turn.question }, - ...(turn.answer ? [{ role: "user", text: turn.answer }] : []), - ]); - const prompt = JSON.stringify({ - task: "Write the next assistant message for an open-ended birth-time rectification conversation.", - constraints: [ - "First acknowledge or connect to the latest user experience; do not say merely that an answer is complete or recorded.", - "Ask zero or one question, never a checklist, form, domain menu, or fixed sequence.", - "When requiredContinuation is present, continue that exact event and ask naturally for a more precise month or date; do not switch to another event or domain.", - "When requiredContinuation is absent, choose the highest-information next question from context rather than following a domain order.", - "The visible prompt must not mention internal domains, ids, scores, weights, gates, processing phases, or that a model selected a route.", - "targetEventId must be null or one of allowedTargetEventIds.", - ], - candidateRange: input.candidateRange, - currentCandidateRange: input.snapshot?.clusters[0] - ? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime } - : null, - recentConversation: recentTurns, - existingEvidence: events.map((event) => ({ - eventId: event.eventId, - domain: event.domain, - summary: event.summary, - date: event.dateRange.label, - precision: event.dateRange.precision, - scoreability: event.scoreability, - })), - attemptedRefinementEventIds: input.attemptedRefinementEventIds, - requiredContinuation: requiredContinuation - ? { - eventId: requiredContinuation.eventId, - summary: requiredContinuation.summary, - currentDate: requiredContinuation.dateRange.label, - precision: requiredContinuation.dateRange.precision, - } - : null, - allowedTargetEventIds: [...allowedTargets.keys()], - allowedDomains: evidenceDomainSchema.options, - }); - - try { - const result = await agent.generate( - [{ role: "user", content: prompt }], - { - abortSignal: AbortSignal.timeout(35_000), - structuredOutput: { schema: outputSchema, jsonPromptInjection: "inline" }, - }, - ); - const parsed = outputSchema.safeParse(result.object ?? (result.text ? JSON.parse(result.text) : null)); - if (!parsed.success || internalCopyPattern.test(parsed.data.prompt)) return fallback(); - const target = parsed.data.targetEventId ? allowedTargets.get(parsed.data.targetEventId) : null; - return { - id: randomUUID(), - domain: target?.domain ?? parsed.data.domain, - targetEventId: target?.eventId ?? null, - prompt: parsed.data.prompt, - recallCost: parsed.data.recallCost, - reason: parsed.data.reason, - }; - } catch (error) { - console.warn("rectification_v4_question_author_failed", { - modelId: input.modelId, - errorName: error instanceof Error ? error.name : "UnknownError", - }); - return fallback(); - } -} diff --git a/frontend/src/lib/rectification-v4/question-planner.ts b/frontend/src/lib/rectification-v4/question-planner.ts deleted file mode 100644 index bda5d882..00000000 --- a/frontend/src/lib/rectification-v4/question-planner.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { LifeEventRevision, RectificationV4Question } from "./contracts.ts"; -import { scoreableEvents } from "./evidence-ledger.ts"; - -function refinementQuestion(event: LifeEventRevision, id?: string): RectificationV4Question { - return { - id: id ?? randomUUID(), - domain: event.domain, - targetEventId: event.eventId, - prompt: `你刚才提到的“${event.summary.slice(0, 120)}”很重要。你目前记得的时间是${event.dateRange.label};如果还能想起更具体的月份或日期,可以继续说,不确定也没关系。`, - recallCost: "medium", - reason: "缩小已有事件的日期范围,用于检验候选范围对日期误差是否稳定。", - }; -} - -export function planNextQuestion(input: { - readonly events?: readonly LifeEventRevision[]; - readonly attemptedRefinementEventIds?: readonly string[]; - readonly latestAnswer?: string; - readonly id?: string; -}): RectificationV4Question { - const attempted = new Set(input.attemptedRefinementEventIds ?? []); - const target = scoreableEvents(input.events ?? []) - .filter((event) => !["day", "month"].includes(event.dateRange.precision) && !attempted.has(event.eventId)) - .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || left.eventId.localeCompare(right.eventId))[0]; - if (target) return refinementQuestion(target, input.id); - - return { - id: input.id ?? randomUUID(), - domain: "other", - targetEventId: null, - prompt: input.latestAnswer - ? "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。" - : "请从你自己最确定、时间也比较清楚的一段人生经历开始说。你可以一次讲几件连续发生的事,不需要按固定领域回答。", - recallCost: "low", - reason: "模型不可用时保持开放叙述,不退回固定领域问卷。", - }; -} - -export function openingQuestion( - candidateRange: Readonly<{ start: string; end: string }>, - id?: string, -): RectificationV4Question { - return { - id: id ?? randomUUID(), - domain: "other", - targetEventId: null, - prompt: `我会先在 ${candidateRange.start}–${candidateRange.end} 这个范围内核对,它还不是已确认的出生分钟。请从你自己最确定、时间也比较清楚的一段人生经历开始说;可以一次讲几件连续发生的事,不需要按固定领域回答。`, - recallCost: "low", - reason: "首轮允许开放叙述,由后续模型根据真实经历选择高信息量问题。", - }; -} diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts index 00ce2db1..812d3dcc 100644 --- a/frontend/src/lib/rectification-v4/store.ts +++ b/frontend/src/lib/rectification-v4/store.ts @@ -1,6 +1,8 @@ +import type { AgentRun, CandidateFeatureSnapshot, DiagnosticsSummary, PublicMessage, ValidatedDecision } from "../rectification-agent/contracts.ts"; import type { CandidateSnapshot, LifeEventRevision, + PendingEvidence, RectificationV4Case, RectificationV4Job, RectificationV4Phase, @@ -26,7 +28,13 @@ export type CompleteRectificationV4JobInput = Readonly<{ outputEvidenceSetHash: string; calculationSpecHash: string; newEventRevisions: readonly LifeEventRevision[]; + pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary | null; + featureSnapshot: CandidateFeatureSnapshot | null; + validatedDecision: ValidatedDecision; + publicMessage: PublicMessage; + agentRun: AgentRun; nextQuestion: RectificationV4Question | null; status: RectificationV4Case["status"]; phase: RectificationV4Phase; diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index 518c764e..045bd25e 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -17,7 +17,7 @@ import type { RectificationV4Store, } from "./store.ts"; import { RectificationV4StoreError } from "./store.ts"; -import { evidenceSetHash } from "./fingerprints.ts"; +import { evidenceSetHash, rectificationFingerprint } from "./fingerprints.ts"; type Row = Record; @@ -70,6 +70,15 @@ function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): Rectific evidenceSetHash: row.evidence_set_hash, currentQuestion: row.current_question, latestSnapshot, + orchestrationModelId: row.orchestration_model_id ? String(row.orchestration_model_id) : null, + narrationModelId: row.narration_model_id ? String(row.narration_model_id) : null, + skillVersion: row.skill_version ? String(row.skill_version) : "birth-time-rectification-v5", + promptVersion: row.prompt_version ? String(row.prompt_version) : "rectification-agent-v5-1", + algorithmVersion: row.algorithm_version ? String(row.algorithm_version) : "rectification-v5-matrix-scoring-1", + deploymentMode: row.deployment_mode === "v5_agent" || row.deployment_mode === "v5_shadow" ? row.deployment_mode : "v4_legacy", + agentMode: row.agent_mode === "agent" ? "agent" : "deterministic_fallback", + featureSnapshotId: row.feature_snapshot_id ? String(row.feature_snapshot_id) : null, + latestDiagnosticsId: row.latest_diagnostics_id ? String(row.latest_diagnostics_id) : null, acceptedRange: row.accepted_range_start && row.accepted_range_end ? { start: row.accepted_range_start, end: row.accepted_range_end } : null, @@ -85,6 +94,8 @@ function eventRevision(row: Row): LifeEventRevision { revision: Number(row.revision), domain: row.domain, eventKind: row.event_kind, + subject: row.subject ?? "self", + relatedPerson: row.related_person ?? null, summary: row.summary, rawText: row.raw_text, dateRange: { @@ -189,7 +200,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re loadEvents: loadEventsByCase, loadTurns: loadTurnsByCase, async createCase(input) { - const id = String(await rpc("create_birth_time_rectification_v4_case", { + const id = String(await rpc("create_birth_time_rectification_v5_case", { p_user_id: input.case.userId, p_case_id: input.case.id, p_action_id: input.actionId, @@ -199,6 +210,12 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re p_calculation_spec_hash: input.case.calculationSpecHash, p_evidence_set_hash: input.case.evidenceSetHash, p_current_question: input.case.currentQuestion, + p_orchestration_model_id: input.case.orchestrationModelId, + p_narration_model_id: input.case.narrationModelId, + p_skill_version: input.case.skillVersion, + p_prompt_version: input.case.promptVersion, + p_algorithm_version: input.case.algorithmVersion, + p_deployment_mode: input.case.deploymentMode, p_now: input.case.createdAt, })); const value = await loadCaseById(input.case.userId, id); @@ -300,15 +317,23 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re async completeJob(input: CompleteRectificationV4JobInput, now) { const jobRow = await loadJobRow(input.jobId); if (!jobRow) throw new RectificationV4StoreError("not_found"); - await rpc("complete_birth_time_rectification_v4_job", { + const completionPayload = { ...input, workerId: undefined }; + await rpc("complete_birth_time_rectification_v5_job", { p_worker_id: input.workerId, p_job_id: input.jobId, p_expected_case_version: input.expectedCaseVersion, p_input_evidence_set_hash: input.inputEvidenceSetHash, p_output_evidence_set_hash: input.outputEvidenceSetHash, p_calculation_spec_hash: input.calculationSpecHash, + p_completion_payload_hash: rectificationFingerprint(completionPayload), p_event_revisions: input.newEventRevisions, + p_pending_evidence: input.pendingEvidence, p_snapshot: input.snapshot, + p_diagnostics: input.diagnostics, + p_feature_snapshot: input.featureSnapshot, + p_validated_decision: input.validatedDecision, + p_public_message: input.publicMessage, + p_agent_run: input.agentRun, p_next_question: input.nextQuestion, p_status: input.status, p_phase: input.phase, diff --git a/frontend/src/lib/rectification-v4/worker.ts b/frontend/src/lib/rectification-v4/worker.ts index f064f9a4..1e100275 100644 --- a/frontend/src/lib/rectification-v4/worker.ts +++ b/frontend/src/lib/rectification-v4/worker.ts @@ -1,143 +1,55 @@ import { randomUUID } from "node:crypto"; -import type { - CandidateSnapshot, - LifeEventRevision, - RectificationV4Case, - RectificationV4Question, -} from "./contracts.ts"; -import { rectificationV4AlgorithmVersion } from "./contracts.ts"; +import { processRectificationAgentTurn } from "../rectification-agent/orchestrator.ts"; import type { RectificationV4CandidateEngine } from "./candidate-engine.ts"; -import { buildCandidateClusters } from "./candidate-clusters.ts"; -import { evaluateDecisionGate } from "./decision-gate.ts"; +import type { RectificationV4Question } from "./contracts.ts"; import { evidenceSetHash } from "./fingerprints.ts"; -import { extractV4EventRevisions } from "./extraction.ts"; -import { latestEventRevisions, scoreableEvents } from "./evidence-ledger.ts"; -import { planNextQuestion } from "./question-planner.ts"; -import type { ClaimedRectificationV4Job, RectificationV4Store } from "./store.ts"; +import type { RectificationV4Store } from "./store.ts"; export function createRectificationV4Worker(input: { readonly store: RectificationV4Store; readonly engine: RectificationV4CandidateEngine; readonly workerId?: string; readonly now?: () => Date; - readonly questionAuthor?: (context: Readonly<{ - modelId: string | null; - candidateRange: RectificationV4Case["calculationSpec"]["candidateRange"]; - snapshot: CandidateSnapshot | null; - turns: ClaimedRectificationV4Job["turns"]; - events: readonly LifeEventRevision[]; - attemptedRefinementEventIds: readonly string[]; - }>) => Promise; }) { const workerId = input.workerId ?? randomUUID(); const now = input.now ?? (() => new Date()); - - return { - async runOnce(): Promise { - const claimed = await input.store.claimNextJob(workerId, now().toISOString()); - if (!claimed) return false; - try { - const extracted = claimed.turn.answer - ? extractV4EventRevisions({ - answer: claimed.turn.answer, - sourceTurnId: claimed.turn.id, - asOfDate: now().toISOString().slice(0, 10), - existing: claimed.events, - targetEventId: claimed.turn.questionTargetEventId, - now: now(), - }) - : []; - const events = latestEventRevisions([...claimed.events, ...extracted]); - await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "scoring_candidates", now: now().toISOString() }); - const scoreable = scoreableEvents(events); - const domains = new Set(scoreable.map((event) => event.domain)); - let snapshot: CandidateSnapshot | null = null; - if (scoreable.length >= 3 && domains.size >= 2) { - const scored = await input.engine.score({ calculationSpec: claimed.case.calculationSpec, events: scoreable }); - await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "checking_robustness", now: now().toISOString() }); - const clusters = buildCandidateClusters(scored.candidates); - const robustness = { - ...scored.robustness, - calculationSpecHashMatched: scored.calculationSpecHash === claimed.case.calculationSpecHash, - }; - const gate = evaluateDecisionGate({ - clusters, - robustness, - scoreableEventCount: scoreable.length, - scoreableDomainCount: domains.size, - }); - snapshot = { - id: scored.resultId, - caseId: claimed.case.id, - caseVersion: claimed.case.version, - evidenceSetHash: evidenceSetHash(events), - calculationSpecHash: claimed.case.calculationSpecHash, - algorithmVersion: rectificationV4AlgorithmVersion, - candidates: [...scored.candidates], - clusters: [...clusters], - robustness, - canConfirmExactMinute: false, - canAcceptRange: gate.canAcceptRange, - gateReasons: [...gate.reasons, ...scored.missingLayers.map((layer) => `missing_layer:${layer}`)], - createdAt: now().toISOString(), - }; - } - await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "planning_question", now: now().toISOString() }); - let nextQuestion: RectificationV4Question | null = null; - if (!snapshot?.canAcceptRange) { - const plannedQuestion = planNextQuestion({ - events, - attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, - latestAnswer: claimed.turn.answer, - }); - const authoredQuestion = input.questionAuthor - ? await input.questionAuthor({ - modelId: claimed.turn.modelId, - candidateRange: claimed.case.calculationSpec.candidateRange, - snapshot, - turns: claimed.turns, - events, - attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, - }) - : plannedQuestion; - nextQuestion = plannedQuestion.targetEventId !== null - && (authoredQuestion.targetEventId !== plannedQuestion.targetEventId - || authoredQuestion.domain !== plannedQuestion.domain) - ? plannedQuestion - : authoredQuestion; - } - await input.store.completeJob({ - workerId, - jobId: claimed.job.id, - expectedCaseVersion: claimed.case.version, - inputEvidenceSetHash: claimed.case.evidenceSetHash, - outputEvidenceSetHash: evidenceSetHash(events), - calculationSpecHash: claimed.case.calculationSpecHash, - newEventRevisions: extracted, - snapshot, - nextQuestion, - status: snapshot?.canAcceptRange ? "range_ready" : "awaiting_answer", - phase: snapshot?.canAcceptRange ? "complete" : "collecting_evidence", - }, now().toISOString()); - return true; - } catch (error) { - await input.store.failJob({ - workerId, - jobId: claimed.job.id, - expectedCaseVersion: claimed.case.version, - errorCode: error instanceof Error ? error.message.slice(0, 120) : "unknown_worker_error", - restoreQuestion: claimed.turn.questionId && claimed.turn.questionDomain ? { - id: claimed.turn.questionId, - domain: claimed.turn.questionDomain, - targetEventId: claimed.turn.questionTargetEventId, - prompt: claimed.turn.question, - recallCost: "low", - reason: "上一轮处理没有完成,请重新提交这段经历。", - } : null, - now: now().toISOString(), - }); - return true; - } - }, - }; + return { async runOnce(): Promise { + const claimed = await input.store.claimNextJob(workerId, now().toISOString()); + if (!claimed) return false; + try { + const result = await processRectificationAgentTurn({ + claimed, engine: input.engine, now: now(), + onPhase: (phase) => input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase, now: now().toISOString() }), + }); + await input.store.completeJob({ + workerId, jobId: claimed.job.id, expectedCaseVersion: claimed.case.version, + inputEvidenceSetHash: claimed.case.evidenceSetHash, + outputEvidenceSetHash: evidenceSetHash([...claimed.events, ...result.newEventRevisions]), + calculationSpecHash: claimed.case.calculationSpecHash, + newEventRevisions: result.newEventRevisions, + pendingEvidence: result.pendingEvidence, + snapshot: result.snapshot, + diagnostics: result.diagnostics, + featureSnapshot: result.featureSnapshot, + validatedDecision: result.validatedDecision, + publicMessage: result.publicMessage, + agentRun: result.agentRun, + nextQuestion: result.nextQuestion, + status: result.status, + phase: result.phase, + }, now().toISOString()); + return true; + } catch (error) { + const restoreQuestion: RectificationV4Question | null = claimed.turn.questionId && claimed.turn.questionDomain ? { + id: claimed.turn.questionId, domain: claimed.turn.questionDomain, targetEventId: claimed.turn.questionTargetEventId, + prompt: claimed.turn.question, recallCost: "low", reason: "上一轮处理没有完成,请重新提交这段经历。", + } : null; + await input.store.failJob({ + workerId, jobId: claimed.job.id, expectedCaseVersion: claimed.case.version, + errorCode: error instanceof Error ? error.message.slice(0, 120) : "unknown_worker_error", + restoreQuestion, now: now().toISOString(), + }); + return true; + } + }}; } diff --git a/frontend/supabase/migrations/20260728010000_conversational_event_semantics.sql b/frontend/supabase/migrations/20260728010000_conversational_event_semantics.sql new file mode 100644 index 00000000..6b357b92 --- /dev/null +++ b/frontend/supabase/migrations/20260728010000_conversational_event_semantics.sql @@ -0,0 +1,132 @@ +begin; + +alter table public.birth_time_rectification_event_evidence + add column if not exists event_kind text check ( + event_kind is null or ( + public.conversational_rectification_text_utf16_length(event_kind) between 1 and 120 + and public.conversational_rectification_text_is_nonblank(event_kind) + ) + ), + add column if not exists subject text check ( + subject is null or subject in ('self', 'family', 'partner', 'other') + ), + add column if not exists related_person text check ( + related_person is null or related_person in ( + 'father', 'mother', 'grandparent', 'sibling', 'partner' + ) + ), + add column if not exists scoreability text check ( + scoreability is null or scoreability in ( + 'scoreable', 'context_only', 'pending_review', 'unsupported' + ) + ); + +-- Keep old rows valid while persisting the richer optional semantics emitted by +-- the application. Patch the deployed function bodies because these RPCs were +-- created by earlier immutable migrations. +do $migration$ +declare + v_definition text; + v_updated text; + v_signature text; +begin + select pg_catalog.pg_get_functiondef( + 'public.conversational_rectification_valid_life_event_evidence(jsonb)'::regprocedure + ) into v_definition; + v_updated := pg_catalog.replace( + v_definition, + '''datePrecision'', ''extractionStatus'', ''scoreable'', ''correctsEvidenceIds''', + '''datePrecision'', ''extractionStatus'', ''eventKind'', ''subject'', ''relatedPerson'', ''scoreability'', ''scoreable'', ''correctsEvidenceIds''' + ); + v_updated := pg_catalog.replace( + v_updated, + $$ or pg_catalog.jsonb_typeof(p_value -> 'eventSummary') is distinct from 'string'$$, + $$ or ( + p_value ? 'eventKind' + and ( + pg_catalog.jsonb_typeof(p_value -> 'eventKind') is distinct from 'string' + or public.conversational_rectification_text_utf16_length( + p_value ->> 'eventKind' + ) not between 1 and 120 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'eventKind' + ) is not true + ) + ) + or ( + p_value ? 'subject' + and ( + pg_catalog.jsonb_typeof(p_value -> 'subject') is distinct from 'string' + or p_value ->> 'subject' not in ('self', 'family', 'partner', 'other') + ) + ) + or ( + p_value ? 'relatedPerson' + and p_value -> 'relatedPerson' <> 'null'::jsonb + and ( + pg_catalog.jsonb_typeof(p_value -> 'relatedPerson') is distinct from 'string' + or p_value ->> 'relatedPerson' not in ( + 'father', 'mother', 'grandparent', 'sibling', 'partner' + ) + ) + ) + or ( + p_value ? 'scoreability' + and ( + pg_catalog.jsonb_typeof(p_value -> 'scoreability') is distinct from 'string' + or p_value ->> 'scoreability' not in ( + 'scoreable', 'context_only', 'pending_review', 'unsupported' + ) + ) + ) + or pg_catalog.jsonb_typeof(p_value -> 'eventSummary') is distinct from 'string'$$ + ); + if v_updated is not distinct from v_definition then + raise exception 'event semantics migration could not update evidence validator'; + end if; + execute v_updated; + + foreach v_signature in array array[ + 'public.save_conversational_rectification_turn(uuid,uuid,bigint,uuid,jsonb,jsonb,jsonb,jsonb,text)', + 'public.import_legacy_conversational_rectification_case(uuid,uuid,uuid,bigint,uuid,integer,text,jsonb,jsonb,jsonb,jsonb,jsonb)' + ] loop + select pg_catalog.pg_get_functiondef(v_signature::regprocedure) into v_definition; + v_updated := pg_catalog.replace( + v_definition, + 'date_value, date_precision, extraction_status, corrects_evidence_ids, scoreable', + 'date_value, date_precision, extraction_status, corrects_evidence_ids, event_kind, subject, related_person, scoreability, scoreable' + ); + v_updated := pg_catalog.replace( + v_updated, + $$date_value, date_precision, extraction_status, scoreable, + corrects_evidence_ids$$, + $$date_value, date_precision, extraction_status, event_kind, subject, + related_person, scoreability, scoreable, corrects_evidence_ids$$ + ); + v_updated := pg_catalog.replace( + v_updated, + $$ ) else '{}'::uuid[] end, + case when item ? 'scoreable' then (item ->> 'scoreable')::boolean$$, + $$ ) else '{}'::uuid[] end, + item ->> 'eventKind', item ->> 'subject', item ->> 'relatedPerson', + item ->> 'scoreability', + case when item ? 'scoreable' then (item ->> 'scoreable')::boolean$$ + ); + v_updated := pg_catalog.replace( + v_updated, + $$ item ->> 'extractionStatus', (item ->> 'scoreable')::boolean, + array(select value::uuid$$, + $$ item ->> 'extractionStatus', item ->> 'eventKind', item ->> 'subject', + item ->> 'relatedPerson', item ->> 'scoreability', + (item ->> 'scoreable')::boolean, + array(select value::uuid$$ + ); + if v_updated is not distinct from v_definition then + raise exception 'event semantics migration could not update %', v_signature; + end if; + execute v_updated; + end loop; +end; +$migration$; + +commit; diff --git a/frontend/supabase/migrations/20260728020000_rectification_agent_v5.sql b/frontend/supabase/migrations/20260728020000_rectification_agent_v5.sql new file mode 100644 index 00000000..4ebc621d --- /dev/null +++ b/frontend/supabase/migrations/20260728020000_rectification_agent_v5.sql @@ -0,0 +1,883 @@ +begin; + +alter table public.birth_time_rectification_v4_cases + add column if not exists orchestration_model_id text, + add column if not exists narration_model_id text, + add column if not exists skill_version text not null default 'birth-time-rectification-v5', + add column if not exists prompt_version text not null default 'rectification-agent-v5-1', + add column if not exists algorithm_version text not null default 'rectification-v5-matrix-scoring-1', + add column if not exists deployment_mode text not null default 'v4_legacy', + add column if not exists feature_snapshot_id uuid, + add column if not exists latest_diagnostics_id uuid, + add column if not exists agent_mode text not null default 'deterministic_fallback', + add column if not exists privacy_retention_until timestamptz; + +-- The original protocol check was created inline and therefore has an implementation-defined name. +do $$ +declare value record; +begin + for value in + select constraint_value.conname + from pg_catalog.pg_constraint constraint_value + where constraint_value.conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and constraint_value.contype = 'c' + and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%protocol%rectification-evidence-v4%' + loop + execute pg_catalog.format( + 'alter table public.birth_time_rectification_v4_cases drop constraint %I', + value.conname + ); + end loop; +end $$; + +alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_protocol_check + check (protocol in ('rectification-evidence-v4', 'rectification-evidence-v5')), + drop constraint if exists birth_time_rectification_v4_cases_phase_check; +alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v4_cases_phase_check + check (phase in ( + 'collecting_evidence', 'extracting_evidence', 'scoring_candidates', + 'checking_robustness', 'planning_question', 'reasoning', 'rendering', 'complete' + )); + +-- V5 owns the worker phase machine as well as the Case phase machine. +alter table public.birth_time_rectification_v4_jobs + add column if not exists completion_payload_hash text; +do $$ +declare value record; +begin + for value in + select constraint_value.conname + from pg_catalog.pg_constraint constraint_value + where constraint_value.conrelid = 'public.birth_time_rectification_v4_jobs'::regclass + and constraint_value.contype = 'c' + and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%phase%' + loop + execute pg_catalog.format( + 'alter table public.birth_time_rectification_v4_jobs drop constraint %I', + value.conname + ); + end loop; +end $$; +alter table public.birth_time_rectification_v4_jobs + add constraint birth_time_rectification_v5_jobs_phase_check + check (phase in ( + 'collecting_evidence', 'extracting_evidence', 'scoring_candidates', + 'checking_robustness', 'planning_question', 'reasoning', 'rendering', 'complete' + )), + drop constraint if exists birth_time_rectification_v5_jobs_completion_payload_hash_check; +alter table public.birth_time_rectification_v4_jobs + add constraint birth_time_rectification_v5_jobs_completion_payload_hash_check + check (completion_payload_hash is null or completion_payload_hash ~ '^[a-f0-9]{64}$'); + +-- Candidate snapshots are durable algorithm artifacts; never label a V5 matrix result as V4. +do $$ +declare value record; +begin + for value in + select constraint_value.conname + from pg_catalog.pg_constraint constraint_value + where constraint_value.conrelid = 'public.birth_time_rectification_v4_candidate_snapshots'::regclass + and constraint_value.contype = 'c' + and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%algorithm_version%' + loop + execute pg_catalog.format( + 'alter table public.birth_time_rectification_v4_candidate_snapshots drop constraint %I', + value.conname + ); + end loop; +end $$; +alter table public.birth_time_rectification_v4_candidate_snapshots + add constraint birth_time_rectification_v5_candidate_snapshots_algorithm_check + check (algorithm_version in ( + 'rectification-v4-range-scoring-1', + 'rectification-v5-matrix-scoring-1' + )); + +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and conname = 'birth_time_rectification_v5_deployment_mode_check' + ) then + alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_deployment_mode_check + check (deployment_mode in ('v4_legacy', 'v5_shadow', 'v5_agent')); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and conname = 'birth_time_rectification_v5_agent_mode_check' + ) then + alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_agent_mode_check + check (agent_mode in ('agent', 'deterministic_fallback')); + end if; +end $$; + +alter table public.birth_time_rectification_v4_event_revisions + add column if not exists subject text not null default 'self', + add column if not exists related_person text, + drop constraint if exists birth_time_rectification_v4_event_revisions_event_kind_check; +alter table public.birth_time_rectification_v4_event_revisions + add constraint birth_time_rectification_v4_event_revisions_event_kind_check + check (event_kind in ( + 'education_milestone', 'relocation', 'relationship_start', 'relationship_end', + 'relationship_change', 'career_change', 'finance_change', 'self_health_event', + 'family_health_event', 'family_bereavement', 'family_event', 'other' + )); + +-- Replace the two anonymous V4 scoreability checks and the relationship-kind check. +do $$ +declare value record; +begin + for value in + select constraint_value.conname + from pg_catalog.pg_constraint constraint_value + where constraint_value.conrelid = 'public.birth_time_rectification_v4_event_revisions'::regclass + and constraint_value.contype = 'c' + and ( + pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%scoreability%' + or ( + pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%relationship%' + and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%event_kind%' + ) + ) + loop + execute pg_catalog.format( + 'alter table public.birth_time_rectification_v4_event_revisions drop constraint %I', + value.conname + ); + end loop; +end $$; +alter table public.birth_time_rectification_v4_event_revisions + add constraint birth_time_rectification_v5_event_revisions_scoreability_check + check (scoreability in ('scoreable', 'context_only', 'pending_review', 'unsupported')), + add constraint birth_time_rect_v5_event_revision_domain_score_check + check (domain not in ('family', 'other') or scoreability <> 'scoreable'), + add constraint birth_time_rect_v5_event_revision_relationship_kind_check + check (domain <> 'relationship' or event_kind in ( + 'relationship_start', 'relationship_end', 'relationship_change' + )); + +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_event_revisions'::regclass + and conname = 'birth_time_rectification_v5_subject_check' + ) then + alter table public.birth_time_rectification_v4_event_revisions + add constraint birth_time_rectification_v5_subject_check + check (subject in ('self', 'family', 'partner', 'other')); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_event_revisions'::regclass + and conname = 'birth_time_rectification_v5_related_person_check' + ) then + alter table public.birth_time_rectification_v4_event_revisions + add constraint birth_time_rectification_v5_related_person_check + check (related_person is null or related_person in ('father', 'mother', 'grandparent', 'sibling', 'partner')); + end if; +end $$; + +create table if not exists public.birth_time_rectification_candidate_feature_snapshots ( + id uuid primary key, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + calculation_spec_hash text not null check (calculation_spec_hash ~ '^[a-f0-9]{64}$'), + algorithm_version text not null, + candidate_count integer not null check (candidate_count between 1 and 1440), + feature_hash text not null check (feature_hash ~ '^[a-f0-9]{64}$'), + features jsonb not null check (jsonb_typeof(features) = 'array'), + created_at timestamptz not null +); + +create table if not exists public.birth_time_rectification_diagnostics ( + id uuid primary key, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + snapshot_id uuid not null references public.birth_time_rectification_v4_candidate_snapshots(id) on delete cascade, + summary jsonb not null check (jsonb_typeof(summary) = 'object'), + calculation_hash text not null check (calculation_hash ~ '^[a-f0-9]{64}$'), + created_at timestamptz not null, + unique (snapshot_id) +); + +create table if not exists public.birth_time_rectification_agent_runs ( + id uuid primary key, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + job_id uuid not null unique references public.birth_time_rectification_v4_jobs(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + case_version bigint not null, + model_id text, + skill_version text not null, + prompt_version text not null, + deployment_sha text, + deployment_mode text not null check (deployment_mode in ('v4_legacy', 'v5_shadow', 'v5_agent')), + decision_json jsonb, + validated_decision_json jsonb not null check (jsonb_typeof(validated_decision_json) = 'object'), + tool_calls_json jsonb not null check (jsonb_typeof(tool_calls_json) = 'array'), + tool_call_count integer not null check (tool_call_count between 0 and 8), + fallback_reason text, + input_token_count integer, + output_token_count integer, + latency_ms integer not null check (latency_ms between 0 and 300000), + created_at timestamptz not null +); + +-- Forward-complete an earlier partial V5 draft before constraints/functions depend on it. +alter table public.birth_time_rectification_agent_runs + add column if not exists deployment_mode text, + add column if not exists validated_decision_json jsonb, + add column if not exists tool_calls_json jsonb, + add column if not exists tool_call_count integer, + add column if not exists fallback_reason text, + add column if not exists input_token_count integer, + add column if not exists output_token_count integer, + add column if not exists latency_ms integer; + +update public.birth_time_rectification_agent_runs run +set deployment_mode = coalesce( + case when run.deployment_mode in ('v4_legacy', 'v5_shadow', 'v5_agent') then run.deployment_mode end, + value.deployment_mode, + 'v4_legacy' + ), + validated_decision_json = case + when jsonb_typeof(run.validated_decision_json) = 'object' then run.validated_decision_json + else jsonb_build_object( + 'decision', coalesce(run.decision_json, jsonb_build_object( + 'action', 'stop_low_confidence', + 'reasonCodes', jsonb_build_array('legacy_run_missing_decision') + )), + 'mode', 'deterministic_fallback', + 'validationIssues', jsonb_build_array('legacy_agent_run_backfill'), + 'selectedOpportunity', null + ) + end, + tool_calls_json = case + when jsonb_typeof(run.tool_calls_json) = 'array' + and jsonb_array_length(run.tool_calls_json) between 0 and 8 then run.tool_calls_json + else '[]'::jsonb + end, + tool_call_count = case + when jsonb_typeof(run.tool_calls_json) = 'array' + and jsonb_array_length(run.tool_calls_json) between 0 and 8 + then jsonb_array_length(run.tool_calls_json) + else 0 + end, + input_token_count = case when run.input_token_count >= 0 then run.input_token_count end, + output_token_count = case when run.output_token_count >= 0 then run.output_token_count end, + latency_ms = greatest(0, least(coalesce(run.latency_ms, 0), 300000)) +from public.birth_time_rectification_v4_cases value +where value.id = run.case_id; + +alter table public.birth_time_rectification_agent_runs + alter column deployment_mode set not null, + alter column validated_decision_json set not null, + alter column tool_calls_json set not null, + alter column tool_call_count set not null, + alter column latency_ms set not null; + +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_deployment_mode_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_deployment_mode_check + check (deployment_mode in ('v4_legacy', 'v5_shadow', 'v5_agent')); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_validated_decision_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_validated_decision_check + check (jsonb_typeof(validated_decision_json) = 'object'); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_tool_calls_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_tool_calls_check + check (jsonb_typeof(tool_calls_json) = 'array'); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_tool_count_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_tool_count_check + check (tool_call_count between 0 and 8 and tool_call_count = jsonb_array_length(tool_calls_json)); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_latency_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_latency_check + check (latency_ms between 0 and 300000); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_token_count_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_token_count_check + check ( + (input_token_count is null or input_token_count >= 0) + and (output_token_count is null or output_token_count >= 0) + ); + end if; +end $$; + +create table if not exists public.birth_time_rectification_public_messages ( + job_id uuid primary key references public.birth_time_rectification_v4_jobs(id) on delete cascade, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + message jsonb not null check (jsonb_typeof(message) = 'object'), + created_at timestamptz not null +); + +create table if not exists public.birth_time_rectification_pending_evidence ( + id uuid primary key, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + turn_id uuid references public.birth_time_rectification_v4_turns(id) on delete cascade, + target_event_id uuid references public.birth_time_rectification_v4_events(id), + raw_text text not null check (length(btrim(raw_text)) between 1 and 4000), + reason_code text not null, + resolved_event_id uuid references public.birth_time_rectification_v4_events(id), + created_at timestamptz not null default now(), + resolved_at timestamptz +); + +alter table public.birth_time_rectification_pending_evidence + alter column turn_id set not null; + +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_pending_evidence'::regclass + and conname = 'birth_time_rectification_v5_pending_reason_check' + ) then + alter table public.birth_time_rectification_pending_evidence + add constraint birth_time_rectification_v5_pending_reason_check + check (reason_code in ('date_unresolved', 'event_unparsed')); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_pending_evidence'::regclass + and conname = 'birth_time_rectification_v5_pending_resolution_check' + ) then + alter table public.birth_time_rectification_pending_evidence + add constraint birth_time_rectification_v5_pending_resolution_check + check ((resolved_at is null) = (resolved_event_id is null)); + end if; +end $$; + +create index if not exists birth_time_rectification_feature_snapshots_case_created_idx + on public.birth_time_rectification_candidate_feature_snapshots(case_id, created_at desc); +create index if not exists birth_time_rectification_feature_snapshots_user_created_idx + on public.birth_time_rectification_candidate_feature_snapshots(user_id, created_at desc); +create index if not exists birth_time_rectification_diagnostics_case_created_idx + on public.birth_time_rectification_diagnostics(case_id, created_at desc); +create index if not exists birth_time_rectification_diagnostics_user_created_idx + on public.birth_time_rectification_diagnostics(user_id, created_at desc); +create index if not exists birth_time_rectification_diagnostics_snapshot_idx + on public.birth_time_rectification_diagnostics(snapshot_id); +create index if not exists birth_time_rectification_agent_runs_case_created_idx + on public.birth_time_rectification_agent_runs(case_id, created_at desc); +create index if not exists birth_time_rectification_agent_runs_user_created_idx + on public.birth_time_rectification_agent_runs(user_id, created_at desc); +create index if not exists birth_time_rectification_public_messages_case_created_idx + on public.birth_time_rectification_public_messages(case_id, created_at desc); +create index if not exists birth_time_rectification_pending_evidence_case_created_idx + on public.birth_time_rectification_pending_evidence(case_id, created_at desc); +create index if not exists birth_time_rectification_pending_evidence_target_event_idx + on public.birth_time_rectification_pending_evidence(target_event_id) + where target_event_id is not null; + +-- Add the circular Case -> latest artifact references only after the artifact tables exist. +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and conname = 'birth_time_rectification_v5_feature_snapshot_fk' + ) then + alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_feature_snapshot_fk + foreign key (feature_snapshot_id) + references public.birth_time_rectification_candidate_feature_snapshots(id); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and conname = 'birth_time_rectification_v5_latest_diagnostics_fk' + ) then + alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_latest_diagnostics_fk + foreign key (latest_diagnostics_id) + references public.birth_time_rectification_diagnostics(id); + end if; +end $$; + +alter table public.birth_time_rectification_candidate_feature_snapshots enable row level security; +alter table public.birth_time_rectification_diagnostics enable row level security; +alter table public.birth_time_rectification_agent_runs enable row level security; +alter table public.birth_time_rectification_public_messages enable row level security; +alter table public.birth_time_rectification_pending_evidence enable row level security; + +revoke all on table + public.birth_time_rectification_candidate_feature_snapshots, + public.birth_time_rectification_diagnostics, + public.birth_time_rectification_agent_runs, + public.birth_time_rectification_public_messages, + public.birth_time_rectification_pending_evidence +from public, anon, authenticated; +grant all on table + public.birth_time_rectification_candidate_feature_snapshots, + public.birth_time_rectification_diagnostics, + public.birth_time_rectification_agent_runs, + public.birth_time_rectification_public_messages, + public.birth_time_rectification_pending_evidence +to service_role; + +create or replace function public.create_birth_time_rectification_v5_case( + p_user_id uuid, + p_case_id uuid, + p_action_id uuid, + p_status text, + p_phase text, + p_calculation_spec jsonb, + p_calculation_spec_hash text, + p_evidence_set_hash text, + p_current_question jsonb, + p_orchestration_model_id text, + p_narration_model_id text, + p_skill_version text, + p_prompt_version text, + p_algorithm_version text, + p_deployment_mode text, + p_now timestamptz +) returns uuid +language plpgsql security definer set search_path = '' as $$ +declare + v_case public.birth_time_rectification_v4_cases%rowtype; + v_case_id uuid; + v_protocol text; +begin + if p_deployment_mode not in ('v4_legacy', 'v5_shadow', 'v5_agent') then + raise exception 'invalid_rectification_v5_deployment_mode'; + end if; + v_protocol := case when p_deployment_mode = 'v4_legacy' + then 'rectification-evidence-v4' else 'rectification-evidence-v5' end; + + select action.case_id into v_case_id + from public.birth_time_rectification_v4_actions action + where action.user_id = p_user_id and action.action_id = p_action_id; + if v_case_id is not null then return v_case_id; end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':rectification-v5-case', 0) + ); + select value.* into v_case + from public.birth_time_rectification_v4_cases value + where value.user_id = p_user_id + and value.status <> 'abandoned' + and value.accepted_range_start is null + order by value.created_at desc + limit 1 + for update; + + -- An in-flight Case keeps the deployment mode and protocol it was created with. + if found and v_case.calculation_spec_hash = p_calculation_spec_hash then + insert into public.birth_time_rectification_v4_actions( + user_id, action_id, case_id, created_at + ) values ( + p_user_id, p_action_id, v_case.id, p_now + ); + return v_case.id; + end if; + + if found then + update public.birth_time_rectification_v4_cases + set status = 'abandoned', phase = 'complete', current_question = null, updated_at = p_now + where id = v_case.id; + update public.birth_time_rectification_v4_jobs + set status = 'stale', lease_expires_at = null, updated_at = p_now + where case_id = v_case.id and status in ('pending', 'processing'); + end if; + + insert into public.birth_time_rectification_v4_cases ( + id, user_id, protocol, status, phase, calculation_spec, calculation_spec_hash, + evidence_set_hash, current_question, orchestration_model_id, narration_model_id, + skill_version, prompt_version, algorithm_version, deployment_mode, agent_mode, + created_at, updated_at + ) values ( + p_case_id, p_user_id, v_protocol, p_status, p_phase, p_calculation_spec, + p_calculation_spec_hash, p_evidence_set_hash, p_current_question, + nullif(btrim(p_orchestration_model_id), ''), nullif(btrim(p_narration_model_id), ''), + p_skill_version, p_prompt_version, p_algorithm_version, p_deployment_mode, + 'deterministic_fallback', p_now, p_now + ); + insert into public.birth_time_rectification_v4_actions( + user_id, action_id, case_id, created_at + ) values ( + p_user_id, p_action_id, p_case_id, p_now + ); + return p_case_id; +end; +$$; + +drop function if exists public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +); +drop function if exists public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +); + +create or replace function public.complete_birth_time_rectification_v5_job( + p_worker_id uuid, + p_job_id uuid, + p_expected_case_version bigint, + p_input_evidence_set_hash text, + p_output_evidence_set_hash text, + p_calculation_spec_hash text, + p_completion_payload_hash text, + p_event_revisions jsonb, + p_pending_evidence jsonb, + p_snapshot jsonb, + p_diagnostics jsonb, + p_feature_snapshot jsonb, + p_validated_decision jsonb, + p_public_message jsonb, + p_agent_run jsonb, + p_next_question jsonb, + p_status text, + p_phase text, + p_now timestamptz +) returns uuid +language plpgsql security definer set search_path = '' as $$ +declare + v_job public.birth_time_rectification_v4_jobs%rowtype; + v_case public.birth_time_rectification_v4_cases%rowtype; + v_existing_run public.birth_time_rectification_agent_runs%rowtype; + v_existing_message public.birth_time_rectification_public_messages%rowtype; + item jsonb; + v_snapshot_id uuid; + v_feature_id uuid; + v_diagnostics_id uuid; + v_event_id uuid; + v_supersedes_id uuid; + v_pending_count integer; +begin + if jsonb_typeof(p_event_revisions) is distinct from 'array' + or jsonb_typeof(p_pending_evidence) is distinct from 'array' + or jsonb_typeof(p_validated_decision) is distinct from 'object' + or jsonb_typeof(p_public_message) is distinct from 'object' + or jsonb_typeof(p_agent_run) is distinct from 'object' + or p_output_evidence_set_hash !~ '^[a-f0-9]{64}$' + or p_calculation_spec_hash !~ '^[a-f0-9]{64}$' + or p_completion_payload_hash !~ '^[a-f0-9]{64}$' then + raise exception 'invalid_rectification_v5_completion_payload'; + end if; + + select value.* into v_job + from public.birth_time_rectification_v4_jobs value + where value.id = p_job_id + for update; + if not found then raise exception 'rectification_v4_job_lease_lost'; end if; + + select value.* into v_case + from public.birth_time_rectification_v4_cases value + where value.id = v_job.case_id + for update; + if not found then raise exception 'rectification_v4_case_not_found'; end if; + + -- A network retry after commit is an idempotent read, never a second artifact write. + if v_job.status = 'completed' then + select value.* into v_existing_run + from public.birth_time_rectification_agent_runs value + where value.job_id = p_job_id; + select value.* into v_existing_message + from public.birth_time_rectification_public_messages value + where value.job_id = p_job_id; + select count(*) into v_pending_count + from public.birth_time_rectification_pending_evidence value + where value.turn_id = v_job.turn_id; + if v_existing_run.id is null + or v_existing_run.id is distinct from (p_agent_run->>'id')::uuid + or v_existing_run.case_id is distinct from v_case.id + or v_existing_run.case_version is distinct from p_expected_case_version + or v_existing_run.validated_decision_json is distinct from p_validated_decision + or v_existing_message.job_id is null + or v_existing_message.message is distinct from p_public_message + or v_job.completion_payload_hash is distinct from p_completion_payload_hash + or v_pending_count is distinct from pg_catalog.jsonb_array_length(p_pending_evidence) then + raise exception 'rectification_v5_replay_payload_mismatch'; + end if; + for item in select value from pg_catalog.jsonb_array_elements(p_pending_evidence) loop + if not exists ( + select 1 from public.birth_time_rectification_pending_evidence value + where value.id = (item->>'id')::uuid + and value.case_id = v_case.id + and value.user_id = v_case.user_id + and value.turn_id = v_job.turn_id + and value.target_event_id is not distinct from nullif(item->>'targetEventId', '')::uuid + and value.raw_text = item->>'rawText' + and value.reason_code = item->>'reasonCode' + and value.resolved_event_id is not distinct from nullif(item->>'resolvedEventId', '')::uuid + and value.created_at = (item->>'createdAt')::timestamptz + and value.resolved_at is not distinct from nullif(item->>'resolvedAt', '')::timestamptz + ) then + raise exception 'rectification_v5_replay_payload_mismatch'; + end if; + end loop; + return v_case.id; + end if; + + if v_job.worker_id is distinct from p_worker_id + or v_job.status <> 'processing' + or v_job.lease_expires_at <= p_now then + raise exception 'rectification_v4_job_lease_lost'; + end if; + if v_case.version is distinct from p_expected_case_version + or v_case.evidence_set_hash is distinct from p_input_evidence_set_hash + or v_case.calculation_spec_hash is distinct from p_calculation_spec_hash + or v_job.expected_case_version is distinct from p_expected_case_version + or v_job.evidence_set_hash is distinct from p_input_evidence_set_hash + or v_job.calculation_spec_hash is distinct from p_calculation_spec_hash then + raise exception 'stale_rectification_v4_job'; + end if; + + if (p_agent_run->>'caseId')::uuid is distinct from v_case.id + or (p_agent_run->>'jobId')::uuid is distinct from p_job_id + or (p_agent_run->>'caseVersion')::bigint is distinct from p_expected_case_version + or p_agent_run->>'deploymentMode' is distinct from v_case.deployment_mode + or p_agent_run->'validatedDecision' is distinct from p_validated_decision + or jsonb_typeof(p_agent_run->'toolCalls') is distinct from 'array' + or pg_catalog.jsonb_array_length(p_agent_run->'toolCalls') > 8 + or p_validated_decision->>'mode' not in ('agent', 'deterministic_fallback') then + raise exception 'invalid_rectification_v5_agent_run'; + end if; + + for item in select value from pg_catalog.jsonb_array_elements(p_event_revisions) loop + v_event_id := (item->>'eventId')::uuid; + v_supersedes_id := nullif(item->>'supersedesRevisionId', '')::uuid; + if (item->>'caseId') is not null and (item->>'caseId')::uuid is distinct from v_case.id then + raise exception 'rectification_v5_event_case_mismatch'; + end if; + insert into public.birth_time_rectification_v4_events( + id, case_id, user_id, created_at + ) values ( + v_event_id, v_case.id, v_case.user_id, (item->>'createdAt')::timestamptz + ) on conflict (id) do nothing; + if not exists ( + select 1 from public.birth_time_rectification_v4_events value + where value.id = v_event_id and value.case_id = v_case.id and value.user_id = v_case.user_id + ) then + raise exception 'rectification_v5_event_case_mismatch'; + end if; + if v_supersedes_id is not null and not exists ( + select 1 from public.birth_time_rectification_v4_event_revisions value + where value.id = v_supersedes_id and value.event_id = v_event_id and value.case_id = v_case.id + ) then + raise exception 'rectification_v5_superseded_revision_mismatch'; + end if; + insert into public.birth_time_rectification_v4_event_revisions( + id, event_id, case_id, user_id, revision, domain, event_kind, subject, + related_person, summary, raw_text, date_start, date_end, date_precision, + date_label, scoreability, supersedes_revision_id, created_at + ) values ( + (item->>'id')::uuid, v_event_id, v_case.id, v_case.user_id, + (item->>'revision')::integer, item->>'domain', item->>'eventKind', item->>'subject', + nullif(item->>'relatedPerson', ''), item->>'summary', item->>'rawText', + (item#>>'{dateRange,start}')::date, (item#>>'{dateRange,end}')::date, + item#>>'{dateRange,precision}', item#>>'{dateRange,label}', item->>'scoreability', + v_supersedes_id, (item->>'createdAt')::timestamptz + ); + end loop; + + for item in select value from pg_catalog.jsonb_array_elements(p_pending_evidence) loop + if (item->>'caseId')::uuid is distinct from v_case.id + or (item->>'turnId')::uuid is distinct from v_job.turn_id + or item->>'reasonCode' not in ('date_unresolved', 'event_unparsed') + or nullif(btrim(item->>'rawText'), '') is null + or (nullif(item->>'resolvedEventId', '') is null) is distinct from (nullif(item->>'resolvedAt', '') is null) then + raise exception 'invalid_rectification_v5_pending_evidence'; + end if; + if nullif(item->>'targetEventId', '') is not null and not exists ( + select 1 from public.birth_time_rectification_v4_events value + where value.id = (item->>'targetEventId')::uuid and value.case_id = v_case.id + ) then + raise exception 'rectification_v5_pending_target_event_mismatch'; + end if; + if nullif(item->>'resolvedEventId', '') is not null and not exists ( + select 1 from public.birth_time_rectification_v4_events value + where value.id = (item->>'resolvedEventId')::uuid and value.case_id = v_case.id + ) then + raise exception 'rectification_v5_pending_resolved_event_mismatch'; + end if; + insert into public.birth_time_rectification_pending_evidence( + id, case_id, user_id, turn_id, target_event_id, raw_text, reason_code, + resolved_event_id, created_at, resolved_at + ) values ( + (item->>'id')::uuid, v_case.id, v_case.user_id, (item->>'turnId')::uuid, + nullif(item->>'targetEventId', '')::uuid, item->>'rawText', item->>'reasonCode', + nullif(item->>'resolvedEventId', '')::uuid, (item->>'createdAt')::timestamptz, + nullif(item->>'resolvedAt', '')::timestamptz + ); + end loop; + + if p_snapshot is not null then + if jsonb_typeof(p_snapshot) is distinct from 'object' + or coalesce((p_snapshot->>'canConfirmExactMinute')::boolean, false) then + raise exception 'exact_minute_confirmation_forbidden'; + end if; + v_snapshot_id := (p_snapshot->>'id')::uuid; + if (p_snapshot->>'caseId')::uuid is distinct from v_case.id + or (p_snapshot->>'caseVersion')::bigint is distinct from p_expected_case_version + or p_snapshot->>'evidenceSetHash' is distinct from p_output_evidence_set_hash + or p_snapshot->>'calculationSpecHash' is distinct from p_calculation_spec_hash + or p_snapshot->>'algorithmVersion' is distinct from v_case.algorithm_version then + raise exception 'rectification_v5_snapshot_mismatch'; + end if; + insert into public.birth_time_rectification_v4_candidate_snapshots( + id, case_id, user_id, case_version, evidence_set_hash, calculation_spec_hash, + algorithm_version, candidates, clusters, robustness, can_confirm_exact_minute, + can_accept_range, gate_reasons, created_at + ) values ( + v_snapshot_id, v_case.id, v_case.user_id, (p_snapshot->>'caseVersion')::bigint, + p_snapshot->>'evidenceSetHash', p_snapshot->>'calculationSpecHash', + p_snapshot->>'algorithmVersion', p_snapshot->'candidates', p_snapshot->'clusters', + p_snapshot->'robustness', false, (p_snapshot->>'canAcceptRange')::boolean, + p_snapshot->'gateReasons', (p_snapshot->>'createdAt')::timestamptz + ); + end if; + + if p_feature_snapshot is not null then + if jsonb_typeof(p_feature_snapshot) is distinct from 'object' then + raise exception 'invalid_rectification_v5_feature_snapshot'; + end if; + v_feature_id := (p_feature_snapshot->>'id')::uuid; + if (p_feature_snapshot->>'caseId')::uuid is distinct from v_case.id + or p_feature_snapshot->>'calculationSpecHash' is distinct from p_calculation_spec_hash + or p_feature_snapshot->>'algorithmVersion' is distinct from v_case.algorithm_version then + raise exception 'rectification_v5_feature_snapshot_mismatch'; + end if; + insert into public.birth_time_rectification_candidate_feature_snapshots( + id, case_id, user_id, calculation_spec_hash, algorithm_version, + candidate_count, feature_hash, features, created_at + ) values ( + v_feature_id, v_case.id, v_case.user_id, + p_feature_snapshot->>'calculationSpecHash', p_feature_snapshot->>'algorithmVersion', + (p_feature_snapshot->>'candidateCount')::integer, p_feature_snapshot->>'featureHash', + p_feature_snapshot->'features', (p_feature_snapshot->>'createdAt')::timestamptz + ); + end if; + + if p_diagnostics is not null then + if jsonb_typeof(p_diagnostics) is distinct from 'object' or v_snapshot_id is null then + raise exception 'invalid_rectification_v5_diagnostics'; + end if; + v_diagnostics_id := (p_diagnostics->>'id')::uuid; + if (p_diagnostics->>'caseId')::uuid is distinct from v_case.id + or (p_diagnostics->>'snapshotId')::uuid is distinct from v_snapshot_id then + raise exception 'rectification_v5_diagnostics_mismatch'; + end if; + insert into public.birth_time_rectification_diagnostics( + id, case_id, user_id, snapshot_id, summary, calculation_hash, created_at + ) values ( + v_diagnostics_id, v_case.id, v_case.user_id, v_snapshot_id, + p_diagnostics, p_diagnostics->>'calculationHash', + (p_diagnostics->>'createdAt')::timestamptz + ); + end if; + + if (p_diagnostics is null) is distinct from (p_snapshot is null) + or (p_feature_snapshot is null) is distinct from (p_snapshot is null) then + raise exception 'rectification_v5_artifact_set_incomplete'; + end if; + + insert into public.birth_time_rectification_agent_runs( + id, case_id, job_id, user_id, case_version, model_id, skill_version, + prompt_version, deployment_sha, deployment_mode, decision_json, + validated_decision_json, tool_calls_json, tool_call_count, fallback_reason, + input_token_count, output_token_count, latency_ms, created_at + ) values ( + (p_agent_run->>'id')::uuid, v_case.id, p_job_id, v_case.user_id, + (p_agent_run->>'caseVersion')::bigint, nullif(p_agent_run->>'modelId', ''), + p_agent_run->>'skillVersion', p_agent_run->>'promptVersion', + nullif(p_agent_run->>'deploymentSha', ''), p_agent_run->>'deploymentMode', + p_agent_run->'decision', p_validated_decision, p_agent_run->'toolCalls', + pg_catalog.jsonb_array_length(p_agent_run->'toolCalls'), + nullif(p_agent_run->>'fallbackReason', ''), + nullif(p_agent_run->>'inputTokenCount', '')::integer, + nullif(p_agent_run->>'outputTokenCount', '')::integer, + (p_agent_run->>'latencyMs')::integer, + (p_agent_run->>'createdAt')::timestamptz + ); + insert into public.birth_time_rectification_public_messages( + job_id, case_id, user_id, message, created_at + ) values ( + p_job_id, v_case.id, v_case.user_id, p_public_message, p_now + ); + + update public.birth_time_rectification_v4_cases + set version = p_expected_case_version + 1, + evidence_set_hash = p_output_evidence_set_hash, + latest_snapshot_id = coalesce(v_snapshot_id, latest_snapshot_id), + feature_snapshot_id = coalesce(v_feature_id, feature_snapshot_id), + latest_diagnostics_id = coalesce(v_diagnostics_id, latest_diagnostics_id), + agent_mode = p_validated_decision->>'mode', + current_question = p_next_question, + status = p_status, + phase = p_phase, + updated_at = p_now + where id = v_case.id; + update public.birth_time_rectification_v4_jobs + set status = 'completed', phase = p_phase, result_snapshot_id = v_snapshot_id, + completion_payload_hash = p_completion_payload_hash, + lease_expires_at = null, updated_at = p_now + where id = p_job_id; + return v_case.id; +end; +$$; + +revoke all on function public.create_birth_time_rectification_v5_case( + uuid, uuid, uuid, text, text, jsonb, text, text, jsonb, text, text, text, text, text, text, timestamptz +) from public, anon, authenticated; +revoke all on function public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +) from public, anon, authenticated; +grant execute on function public.create_birth_time_rectification_v5_case( + uuid, uuid, uuid, text, text, jsonb, text, text, jsonb, text, text, text, text, text, text, timestamptz +) to service_role; +grant execute on function public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +) to service_role; + +commit; diff --git a/frontend/tests/conversational-evidence-extractor.test.ts b/frontend/tests/conversational-evidence-extractor.test.ts index 23da2750..d4116175 100644 --- a/frontend/tests/conversational-evidence-extractor.test.ts +++ b/frontend/tests/conversational-evidence-extractor.test.ts @@ -122,19 +122,35 @@ test("accepts nineteenth-century Chinese and ISO dates as scoreable historical e ); }); -for (const rawText of [ - "2003年确诊癌症并接受手术", - "2006年丈夫因交通事故去世", -]) { - test(`classifies dated health, accident, and bereavement evidence for D30 scoring: ${rawText}`, () => { - const evidence = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" }); - - assert.ok(evidence.length > 0); - assert.ok(evidence.every((item) => item.domain === "health_pressure")); - assert.ok(evidence.every((item) => item.scoreable)); - assert.ok(evidence.every((item) => lifeEventEvidenceSchema.safeParse(item).success)); +test("classifies the user's dated illness as scoreable self-health evidence", () => { + const [evidence] = extractLifeEventEvidence({ + rawText: "2003年确诊癌症并接受手术", + sourceTurnId, + asOfDate: "2026-07-20", }); -} + + assert.equal(evidence?.domain, "health_pressure"); + assert.equal(evidence?.eventKind, "self_health_event"); + assert.equal(evidence?.subject, "self"); + assert.equal(evidence?.scoreable, true); + assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true); +}); + +test("keeps a partner's bereavement as family context instead of personal-health scoring", () => { + const [evidence] = extractLifeEventEvidence({ + rawText: "2006年丈夫因交通事故去世", + sourceTurnId, + asOfDate: "2026-07-20", + }); + + assert.equal(evidence?.domain, "family"); + assert.equal(evidence?.eventKind, "family_bereavement"); + assert.equal(evidence?.subject, "family"); + assert.equal(evidence?.relatedPerson, "partner"); + assert.equal(evidence?.scoreability, "context_only"); + assert.equal(evidence?.scoreable, false); + assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true); +}); test("classifies dated income and asset changes as finance evidence", () => { const [evidence] = extractLifeEventEvidence({ @@ -144,6 +160,7 @@ test("classifies dated income and asset changes as finance evidence", () => { }); assert.equal(evidence?.domain, "finance"); + assert.equal(evidence?.eventKind, "finance_change"); assert.equal(evidence?.dateValue, "2022-08"); assert.equal(evidence?.scoreable, true); assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true); diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index a9a20504..667c1e43 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -39,6 +39,15 @@ function response(overrides: Record = {}): RectificationV4ApiRe reason: "根据对话选择下一条高信息量追问。", }, latestSnapshot: null, + orchestrationModelId: null, + narrationModelId: null, + skillVersion: "birth-time-rectification-v5", + promptVersion: "rectification-agent-v5-1", + algorithmVersion: "rectification-v5-matrix-scoring-1", + deploymentMode: "v5_agent", + agentMode: "deterministic_fallback", + featureSnapshotId: null, + latestDiagnosticsId: null, acceptedRange: null, createdAt: now, updatedAt: now, diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index b8fe3dad..13fd2f54 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -30,6 +30,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260721150000_align_conversational_finance_domain\.sql/); assert.match(migration.stdout, /applied 20260723010000_restore_conversational_message_history\.sql/); assert.match(migration.stdout, /applied 20260723020000_mark_captured_conversational_messages\.sql/); + assert.match(migration.stdout, /applied 20260728010000_conversational_event_semantics\.sql/); + assert.match(migration.stdout, /applied 20260728020000_rectification_agent_v5\.sql/); assert.equal( fixture.psql(` @@ -76,12 +78,17 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic `), [ "birth_time_rectification_action_receipts", + "birth_time_rectification_agent_runs", "birth_time_rectification_billing", + "birth_time_rectification_candidate_feature_snapshots", "birth_time_rectification_cases", + "birth_time_rectification_diagnostics", "birth_time_rectification_dynamic_state", "birth_time_rectification_event_evidence", "birth_time_rectification_handoff_attach_receipts", "birth_time_rectification_handoff_settlements", + "birth_time_rectification_pending_evidence", + "birth_time_rectification_public_messages", "birth_time_rectification_question_handoffs", "birth_time_rectification_scoring_jobs", "birth_time_rectification_turns", diff --git a/frontend/tests/rectification-agent-contracts.test.ts b/frontend/tests/rectification-agent-contracts.test.ts new file mode 100644 index 00000000..c8379eb7 --- /dev/null +++ b/frontend/tests/rectification-agent-contracts.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + validateRectificationDecision, + type DiagnosticsSummary, + type QuestionOpportunity, +} from "../src/lib/rectification-agent/contracts.ts"; +import { recordRectificationAgentTelemetry } from "../src/lib/rectification-agent/telemetry.ts"; + +const caseId = "00000000-0000-4000-8000-000000000800"; +const opportunityId = "00000000-0000-4000-8000-000000000801"; +const snapshotId = "00000000-0000-4000-8000-000000000802"; +const diagnostics: DiagnosticsSummary = { + id: "00000000-0000-4000-8000-000000000803", + caseId, + snapshotId, + primaryClusterRetentionRate: 0.8, + leaveOneEventOutRetentionRate: 0.75, + leaveOneDomainOutRetentionRate: 0.7, + dateSensitivityRetentionRate: 0.72, + neighborSupportMinutes: 8, + primarySecondaryMarginPercent: 12, + clusterMassRatio: 0.65, + unstableEventIds: [], + mostDiscriminatingLayers: ["D9"], + eventDateSensitivity: [], + candidateSplits: [], + calculationHash: "c".repeat(64), + createdAt: "2026-07-28T00:00:00.000Z", +}; +const opportunity: QuestionOpportunity = { + opportunityId, + kind: "ask_new_event", + domain: "career", + targetEventId: null, + prompt: "请补充一个有明确年月的重要事件。", + reason: "当前证据领域覆盖不足。", + expectedInformationGain: 0.8, + dateSensitivity: 0.5, + candidateSplitRelevance: 0.7, + domainCoverageGain: 0.6, + recallEase: 0.8, + novelty: 1, + repetitionPenalty: 0, + privacyCost: 0.1, + utility: 0.69, + active: true, +}; + +function validate(decision: unknown, overrides: Partial[0]> = {}) { + return validateRectificationDecision({ + decision, + caseId, + snapshotId, + opportunities: [opportunity], + diagnostics, + candidateRangeOfferAllowed: true, + ...overrides, + }); +} + +test("only an active server-owned opportunity can be selected", () => { + assert.deepEqual(validate({ + action: "ask_question", opportunityId, narrativeFocus: ["latest_event"], + }, { opportunities: [{ ...opportunity, active: false }] }).issues, ["opportunity_not_active"]); + assert.deepEqual(validate({ + action: "ask_question", opportunityId: "00000000-0000-4000-8000-000000000899", narrativeFocus: [], + }).issues, ["opportunity_not_active"]); +}); + +test("candidate ranges require both the policy gate and the current snapshot", () => { + assert.deepEqual(validate({ action: "offer_candidate_range", snapshotId }, { + candidateRangeOfferAllowed: false, + }).issues, ["candidate_range_gate_failed"]); + assert.deepEqual(validate({ action: "offer_candidate_range", snapshotId }, { + snapshotId: "00000000-0000-4000-8000-000000000898", + }).issues, ["snapshot_not_current"]); +}); + +test("diagnostic reads are bounded and cannot target another case", () => { + assert.deepEqual(validate({ action: "run_diagnostic", diagnostic: "neighbor_stability" }, { + usedDiagnostics: ["neighbor_stability"], + }).issues, ["diagnostic_already_run"]); + assert.deepEqual(validate({ action: "ask_question", opportunityId, narrativeFocus: [] }, { + caseId: "00000000-0000-4000-8000-000000000897", + toolCallCount: 2, + maxToolCalls: 1, + }).issues, ["diagnostics_case_mismatch", "tool_call_budget_exceeded"]); +}); + +test("model output cannot inject a minute, question, event, or score", () => { + for (const extra of [ + { birthMinute: "06:21" }, + { prompt: "模型自己写的问题" }, + { eventId: "00000000-0000-4000-8000-000000000896" }, + { score: 99 }, + ]) { + assert.deepEqual(validate({ action: "offer_candidate_range", snapshotId, ...extra }).issues, ["decision_schema_invalid"]); + } +}); + +test("agent telemetry rejects malformed events and warns on failures", () => { + const info: string[] = []; + const warnings: string[] = []; + const originalInfo = console.info; + const originalWarn = console.warn; + console.info = (message) => info.push(String(message)); + console.warn = (message) => warnings.push(String(message)); + try { + recordRectificationAgentTelemetry({ + caseId, phase: "reasoner", outcome: "failed", modelId: "test-model", toolName: null, + decisionAction: null, durationMs: 12, errorCode: "model_unavailable", deploymentSha: "test-sha", + }); + recordRectificationAgentTelemetry({ + caseId, phase: "reasoner", outcome: "failed", modelId: "", toolName: null, + decisionAction: null, durationMs: 12, errorCode: "model_unavailable", deploymentSha: "test-sha", + }); + } finally { + console.info = originalInfo; + console.warn = originalWarn; + } + assert.equal(info.length, 0); + assert.equal(warnings.length, 1); + assert.match(warnings[0] ?? "", /\[rectification-agent\].*"outcome":"failed"/); +}); + +test("durable event semantics migration validates and persists subject fields", () => { + const migration = readFileSync(new URL( + "../supabase/migrations/20260728010000_conversational_event_semantics.sql", + import.meta.url, + ), "utf8"); + assert.match(migration, /subject in \('self', 'family', 'partner', 'other'\)/); + assert.match(migration, /related_person/); + assert.match(migration, /save_conversational_rectification_turn/); + assert.match(migration, /import_legacy_conversational_rectification_case/); + assert.match(migration, /event_kind/); + assert.match(migration, /scoreability/); +}); diff --git a/frontend/tests/rectification-agent-v5.test.ts b/frontend/tests/rectification-agent-v5.test.ts new file mode 100644 index 00000000..96b72771 --- /dev/null +++ b/frontend/tests/rectification-agent-v5.test.ts @@ -0,0 +1,346 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { + agentRunSchema, + diagnosticsSummarySchema, + type DiagnosticsSummary, + type QuestionOpportunity, +} from "../src/lib/rectification-agent/contracts.ts"; +import { rectificationCanaryBucket, selectRectificationDeploymentMode } from "../src/lib/rectification-agent/feature-policy.ts"; +import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts"; +import { runBoundedReasoner } from "../src/lib/rectification-agent/reasoner-agent.ts"; +import { enforceServerQuestion } from "../src/lib/rectification-agent/renderer-agent.ts"; +import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts"; +import type { + CalculationSpec, + CandidateSnapshot, + LifeEventRevision, + RectificationV4Case, +} from "../src/lib/rectification-v4/contracts.ts"; +import { reconcileV4Evidence } from "../src/lib/rectification-v4/extraction.ts"; +import { calculationSpecHash, rectificationFingerprint } from "../src/lib/rectification-v4/fingerprints.ts"; +import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts"; +import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; +import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; + +const caseId = "00000000-0000-4000-8000-000000000901"; +const snapshotId = "00000000-0000-4000-8000-000000000902"; +const opportunityId = "00000000-0000-4000-8000-000000000903"; +const now = "2026-07-28T00:00:00.000Z"; + +test("completion artifact fingerprints are canonical and payload-sensitive", () => { + const left = rectificationFingerprint({ status: "complete", artifact: { b: 2, a: 1 } }); + const reordered = rectificationFingerprint({ artifact: { a: 1, b: 2 }, status: "complete" }); + const changed = rectificationFingerprint({ artifact: { a: 1, b: 3 }, status: "complete" }); + assert.equal(left, reordered); + assert.notEqual(left, changed); +}); +const spec: CalculationSpec = { + version: "rectification-calculation-spec-v4", + birthDate: "1997-08-08", + candidateRange: { start: "05:00", end: "06:00" }, + latitude: 36.419, + longitude: 114.213, + timezoneOffsetHours: 8, + ayanamsa: "lahiri", + nodeMode: "mean", + minuteStep: 1, +}; +const snapshot: CandidateSnapshot = { + id: snapshotId, + caseId, + caseVersion: 2, + evidenceSetHash: "e".repeat(64), + calculationSpecHash: calculationSpecHash(spec), + algorithmVersion: "rectification-v5-matrix-scoring-1", + candidates: [{ time: "05:13", score: 10, supportingEventIds: [], conflictingEventIds: [] }], + clusters: [{ rank: 1, startTime: "05:13", endTime: "05:15", representativeTime: "05:13", widthMinutes: 3, peakScore: 10, scoreMass: 1 }], + robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, dateSensitivityRetentionRate: 1, calculationSpecHashMatched: true }, + canConfirmExactMinute: false, + canAcceptRange: false, + gateReasons: ["insufficient_scoreable_events"], + createdAt: now, +}; +const diagnostics: DiagnosticsSummary = diagnosticsSummarySchema.parse({ + id: "00000000-0000-4000-8000-000000000904", + caseId, + snapshotId, + primaryClusterRetentionRate: 1, + leaveOneEventOutRetentionRate: .8, + leaveOneDomainOutRetentionRate: .7, + dateSensitivityRetentionRate: .9, + neighborSupportMinutes: 3, + primarySecondaryMarginPercent: 12, + clusterMassRatio: .8, + unstableEventIds: [], + mostDiscriminatingLayers: ["D9"], + eventDateSensitivity: [], + candidateSplits: [], + calculationHash: "d".repeat(64), + createdAt: now, +}); +const opportunity: QuestionOpportunity = { + opportunityId, + kind: "ask_new_event", + domain: "career", + targetEventId: null, + prompt: "请补充一次职业变化。", + reason: "领域覆盖不足。", + expectedInformationGain: .8, + dateSensitivity: .5, + candidateSplitRelevance: .5, + domainCoverageGain: 1, + recallEase: .8, + novelty: 1, + repetitionPenalty: 0, + privacyCost: 0, + utility: .85, + active: true, +}; +const caseValue: RectificationV4Case = { + id: caseId, + userId: "00000000-0000-4000-8000-000000000905", + protocol: "rectification-evidence-v5", + version: 2, + status: "processing", + phase: "reasoning", + calculationSpec: spec, + calculationSpecHash: calculationSpecHash(spec), + evidenceSetHash: "e".repeat(64), + currentQuestion: null, + latestSnapshot: snapshot, + orchestrationModelId: null, + narrationModelId: null, + skillVersion: "birth-time-rectification-v5", + promptVersion: "rectification-agent-v5-1", + algorithmVersion: "rectification-v5-matrix-scoring-1", + deploymentMode: "v5_agent", + agentMode: "deterministic_fallback", + featureSnapshotId: null, + latestDiagnosticsId: diagnostics.id, + acceptedRange: null, + createdAt: now, + updatedAt: now, +}; + +function event(overrides: Partial = {}): LifeEventRevision { + return { + id: randomUUID(), + eventId: randomUUID(), + revision: 1, + domain: "education", + eventKind: "education_milestone", + subject: "self", + relatedPerson: null, + summary: "2016年大学入学", + rawText: "2016年9月大学入学", + dateRange: { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "2016年9月" }, + scoreability: "scoreable", + supersedesRevisionId: null, + createdAt: now, + ...overrides, + }; +} + +test("SHA-256 canary assignment is stable and deployment modes are explicit", () => { + assert.equal(rectificationCanaryBucket("user-a"), 98.66510317660868); + assert.equal(selectRectificationDeploymentMode("user-a", { RECTIFICATION_AGENT_V5_ENABLED: "0" }), "v4_legacy"); + assert.equal(selectRectificationDeploymentMode("user-a", { + RECTIFICATION_AGENT_V5_ENABLED: "1", RECTIFICATION_AGENT_V5_CANARY_PERCENT: "100", RECTIFICATION_AGENT_V5_SHADOW: "1", + }), "v5_shadow"); + assert.equal(selectRectificationDeploymentMode("user-a", { + RECTIFICATION_AGENT_V5_ENABLED: "1", RECTIFICATION_AGENT_V5_CANARY_PERCENT: "100", RECTIFICATION_AGENT_V5_SHADOW: "0", + }), "v5_agent"); + assert.equal(selectRectificationDeploymentMode("user-a", { + RECTIFICATION_AGENT_V5_ENABLED: "1", RECTIFICATION_AGENT_V5_CANARY_PERCENT: "10", + }), "v4_legacy"); +}); + +test("opportunities are ordered only by their published utility", () => { + const target = event(); + const values = buildQuestionOpportunities({ + caseId, + events: [target], + turns: [], + snapshot: null, + diagnostics: null, + }); + assert.ok(values.length >= 2); + assert.deepEqual(values.map((value) => value.utility), [...values].map((value) => value.utility).sort((a, b) => b - a)); +}); + +test("an unresolved current target exclusively owns the next-question route", () => { + const target = event(); + const values = buildQuestionOpportunities({ + caseId, + events: [target, event({ eventId: randomUUID(), domain: "relocation", eventKind: "relocation", summary: "搬家到北京" })], + turns: [], + snapshot: null, + diagnostics: null, + retryTargetEventIds: [target.eventId], + }); + assert.deepEqual(values.map((value) => [value.kind, value.targetEventId]), [["resolve_event_conflict", target.eventId]]); +}); + +test("reasoner falls back when unavailable", async () => { + const result = await runBoundedReasoner({ caseValue, snapshot, diagnostics, opportunities: [opportunity] }); + assert.equal(result.mode, "deterministic_fallback"); + assert.equal(result.fallbackReason, "reasoner_model_unavailable"); + assert.equal(result.decision.action, "ask_question"); +}); + +test("reasoner permits one diagnostic, then requires a final action and accumulates usage", async () => { + const phases: string[] = []; + const result = await runBoundedReasoner({ + caseValue, + snapshot, + diagnostics, + opportunities: [opportunity], + generateDecision: async (_prompt, phase) => { + phases.push(phase); + return phase === "initial" + ? { object: { action: "run_diagnostic", diagnostic: "neighbor_stability" }, totalUsage: { inputTokens: 11, outputTokens: 3 } } + : { object: { action: "ask_question", opportunityId, narrativeFocus: ["candidate_change"] }, totalUsage: Promise.resolve({ inputTokens: 7, outputTokens: 2 }) }; + }, + }); + assert.deepEqual(phases, ["initial", "after_diagnostic"]); + assert.equal(result.mode, "agent"); + assert.equal(result.decision.action, "ask_question"); + assert.equal(result.toolCalls.length, 1); + assert.equal(result.toolCalls[0]?.outcome, "succeeded"); + assert.equal(result.inputTokenCount, 18); + assert.equal(result.outputTokenCount, 5); +}); + +test("reasoner rejects a second diagnostic and enforces the tool budget", async () => { + const nonfinal = await runBoundedReasoner({ + caseValue, + snapshot, + diagnostics, + opportunities: [opportunity], + generateDecision: async () => ({ object: { action: "run_diagnostic", diagnostic: "neighbor_stability" } }), + }); + assert.equal(nonfinal.mode, "deterministic_fallback"); + assert.equal(nonfinal.fallbackReason, "reasoner_returned_nonfinal_diagnostic"); + assert.equal(nonfinal.toolCalls.length, 1); + + const exhausted = await runBoundedReasoner({ + caseValue, + snapshot, + diagnostics, + opportunities: [opportunity], + maxToolCalls: 0, + generateDecision: async () => ({ object: { action: "run_diagnostic", diagnostic: "neighbor_stability" } }), + }); + assert.equal(exhausted.fallbackReason, "diagnostic_budget_exhausted"); + assert.equal(exhausted.toolCalls[0]?.outcome, "rejected"); +}); + +test("renderer cannot replace the server-owned question", () => { + assert.deepEqual(enforceServerQuestion({ + acknowledgement: "已记录。", + candidateUpdate: null, + limitation: null, + question: "模型注入的问题", + }, "服务器选定的问题"), { + acknowledgement: "已记录。", + candidateUpdate: null, + limitation: null, + question: "服务器选定的问题", + }); +}); + +test("agent-run persistence contract carries deployment, tool, token, and latency facts", () => { + const parsed = agentRunSchema.parse({ + id: randomUUID(), caseId, jobId: randomUUID(), caseVersion: 2, modelId: "test-model", + skillVersion: "birth-time-rectification-v5", promptVersion: "rectification-agent-v5-1", + deploymentMode: "v5_agent", deploymentSha: "abc123", + decision: { action: "ask_question", opportunityId, narrativeFocus: [] }, + validatedDecision: { + decision: { action: "ask_question", opportunityId, narrativeFocus: [] }, mode: "agent", validationIssues: [], selectedOpportunity: opportunity, + }, + toolCalls: [{ tool: "run_rectification_diagnostics", diagnostic: "neighbor_stability", outcome: "succeeded", durationMs: 4, errorCode: null }], + fallbackReason: null, inputTokenCount: 18, outputTokenCount: 5, latencyMs: 20, createdAt: now, + }); + assert.equal(parsed.toolCalls.length, 1); + assert.equal(parsed.inputTokenCount, 18); + assert.equal(parsed.latencyMs, 20); +}); + +test("an answer about another event never overwrites the current target and creates a conflict opportunity", () => { + const target = event(); + const reconciled = reconcileV4Evidence({ + caseId, + answer: "2018年8月搬家到北京", + sourceTurnId: randomUUID(), + asOfDate: "2026-07-28", + existing: [target], + targetEventId: target.eventId, + now: new Date(now), + }); + assert.equal(reconciled.unansweredTargetEventId, target.eventId); + assert.equal(reconciled.revisions.some((value) => value.eventId === target.eventId), false); + assert.equal(reconciled.revisions[0]?.eventKind, "relocation"); + const opportunities = buildQuestionOpportunities({ + caseId, + events: [target, ...reconciled.revisions], + turns: [], + snapshot: null, + diagnostics: null, + retryTargetEventIds: [target.eventId], + }); + assert.equal(opportunities[0]?.kind, "resolve_event_conflict"); + assert.equal(opportunities[0]?.targetEventId, target.eventId); +}); + +test("unparsed answers are retained as pending evidence", () => { + const target = event(); + const turnId = randomUUID(); + const reconciled = reconcileV4Evidence({ + caseId, + answer: "我记不清了,可能是那几年之间", + sourceTurnId: turnId, + asOfDate: "2026-07-28", + existing: [target], + targetEventId: target.eventId, + now: new Date(now), + }); + assert.equal(reconciled.revisions.length, 0); + assert.equal(reconciled.pending.length, 1); + assert.equal(reconciled.pending[0]?.turnId, turnId); + assert.equal(reconciled.pending[0]?.targetEventId, target.eventId); +}); + +test("shadow mode persists V5 artifacts while preserving the legacy visible reply", async () => { + async function run(mode: "v4_legacy" | "v5_shadow") { + return withV5Mode(mode, async () => { + const store = createRectificationV4MemoryStore(); + const service = createRectificationV4CaseService(store, { now: () => new Date(now) }); + const worker = createRectificationV4Worker({ + store, + now: () => new Date(now), + engine: { score: async ({ calculationSpec, events }) => v5EngineResult(calculationSpec, events) }, + }); + const userId = randomUUID(); + const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + const queued = await service.answer({ + userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: created.case.version, + answer: "2016年9月大学入学", + }); + assert.ok(queued?.job); + await worker.runOnce(); + return { + message: [...store.publicMessages.values()][0], + question: (await service.loadCase(userId, created.case.id))?.case.currentQuestion, + agentRuns: store.agentRuns.size, + }; + }); + } + const legacy = await run("v4_legacy"); + const shadow = await run("v5_shadow"); + assert.deepEqual(shadow.message, legacy.message); + assert.equal(shadow.question?.prompt, legacy.question?.prompt); + assert.equal(shadow.agentRuns, 1); +}); diff --git a/frontend/tests/rectification-v4-domain.test.ts b/frontend/tests/rectification-v4-domain.test.ts index 85f98ad7..ee33c936 100644 --- a/frontend/tests/rectification-v4-domain.test.ts +++ b/frontend/tests/rectification-v4-domain.test.ts @@ -1,16 +1,18 @@ import assert from "node:assert/strict"; -import test from "node:test"; import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts"; import { buildCandidateClusters } from "../src/lib/rectification-v4/candidate-clusters.ts"; import { dateRangeFromDeclared, sampledDates } from "../src/lib/rectification-v4/date-range.ts"; import { evaluateDecisionGate } from "../src/lib/rectification-v4/decision-gate.ts"; import { appendEventRevision, latestEventRevisions } from "../src/lib/rectification-v4/evidence-ledger.ts"; import { extractV4EventRevisions } from "../src/lib/rectification-v4/extraction.ts"; -import { openingQuestion, planNextQuestion } from "../src/lib/rectification-v4/question-planner.ts"; +import { openingQuestion } from "../src/lib/rectification-v4/opening-question.ts"; -const now = new Date("2026-07-26T00:00:00.000Z"); +const now = new Date("2026-07-28T00:00:00.000Z"); +const revision = (input: Parameters[1]) => appendEventRevision([], input, { id: randomUUID(), now }); -test("declared month, quarter and year retain real boundaries instead of invented midpoints", () => { +test("declared month, quarter and year retain boundaries instead of invented midpoints", () => { assert.deepEqual(dateRangeFromDeclared("2024-02", "month"), { start: "2024-02-01", end: "2024-02-29", precision: "month", label: "2024-02", }); @@ -23,31 +25,39 @@ test("declared month, quarter and year retain real boundaries instead of invente assert.equal(sampledDates(dateRangeFromDeclared("2024-02", "month")).includes("2024-02-15"), false); }); -test("relationship start and end remain separate immutable events", () => { - const startId = randomUUID(); - const endId = randomUUID(); - const start = appendEventRevision([], { - eventId: startId, domain: "relationship", eventKind: "relationship_start", summary: "关系开始", - rawText: "2024年5月开始", dateRange: dateRangeFromDeclared("2024-05", "month"), - }, { id: randomUUID(), now }); - const end = appendEventRevision([start], { - eventId: endId, domain: "relationship", eventKind: "relationship_end", summary: "关系结束", - rawText: "2024年8月结束", dateRange: dateRangeFromDeclared("2024-08", "month"), - }, { id: randomUUID(), now }); +test("relationship start and end remain separate self/partner events", () => { + const start = revision({ + eventId: randomUUID(), domain: "relationship", eventKind: "relationship_start", subject: "self", relatedPerson: "partner", + summary: "关系开始", rawText: "2024年5月开始", dateRange: dateRangeFromDeclared("2024-05", "month"), scoreability: "scoreable", + }); + const end = revision({ + eventId: randomUUID(), domain: "relationship", eventKind: "relationship_end", subject: "self", relatedPerson: "partner", + summary: "关系结束", rawText: "2024年8月结束", dateRange: dateRangeFromDeclared("2024-08", "month"), scoreability: "scoreable", + }); assert.notEqual(start.eventId, end.eventId); - assert.equal(start.eventKind, "relationship_start"); - assert.equal(end.eventKind, "relationship_end"); + assert.deepEqual([start.eventKind, end.eventKind], ["relationship_start", "relationship_end"]); }); -test("family evidence is retained explicitly as context only", () => { - const revision = appendEventRevision([], { - eventId: randomUUID(), domain: "family", eventKind: "family_event", summary: "家庭变化", - rawText: "家庭发生变化", dateRange: dateRangeFromDeclared("2020", "year"), - }, { id: randomUUID(), now }); - assert.equal(revision.scoreability, "context_only"); +test("family health and bereavement stay context-only while self health is scoreable", () => { + for (const eventKind of ["family_health_event", "family_bereavement"] as const) { + const event = revision({ + eventId: randomUUID(), domain: "family", eventKind, subject: "family", relatedPerson: "mother", + summary: "家人健康事件", rawText: "2020年家人住院", dateRange: dateRangeFromDeclared("2020", "year"), scoreability: "context_only", + }); + assert.equal(event.scoreability, "context_only"); + } + const selfHealth = revision({ + eventId: randomUUID(), domain: "health_pressure", eventKind: "self_health_event", subject: "self", relatedPerson: null, + summary: "本人手术", rawText: "2021年3月手术", dateRange: dateRangeFromDeclared("2021-03", "month"), scoreability: "scoreable", + }); + assert.equal(selfHealth.scoreability, "scoreable"); + assert.throws(() => revision({ + eventId: randomUUID(), domain: "health_pressure", eventKind: "self_health_event", subject: "family", relatedPerson: "mother", + summary: "母亲手术", rawText: "2021年3月母亲手术", dateRange: dateRangeFromDeclared("2021-03", "month"), scoreability: "scoreable", + }), /non_self_event_not_scoreable/); }); -test("candidate minutes merge into ranked contiguous clusters", () => { +test("candidate minutes merge into ranked contiguous clusters and never confirm one minute", () => { const id = randomUUID(); const clusters = buildCandidateClusters([ { time: "05:13", score: 100, supportingEventIds: [id], conflictingEventIds: [] }, @@ -57,25 +67,19 @@ test("candidate minutes merge into ranked contiguous clusters", () => { { time: "05:17", score: 97, supportingEventIds: [id], conflictingEventIds: [] }, { time: "05:18", score: 97, supportingEventIds: [id], conflictingEventIds: [] }, ]); - assert.deepEqual(clusters.map((cluster) => [cluster.rank, cluster.startTime, cluster.endTime]), [ - [1, "05:13", "05:15"], [2, "05:17", "05:18"], - ]); -}); - -test("decision gate can accept a stable range but never an exact minute", () => { - const result = evaluateDecisionGate({ - clusters: [{ rank: 1, startTime: "05:13", endTime: "05:15", representativeTime: "05:13", widthMinutes: 3, peakScore: 10, scoreMass: 29 }], + assert.deepEqual(clusters.map((cluster) => [cluster.rank, cluster.startTime, cluster.endTime]), [[1, "05:13", "05:15"], [2, "05:17", "05:18"]]); + const gate = evaluateDecisionGate({ + clusters: [clusters[0]!], robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, dateSensitivityRetentionRate: 0.9, calculationSpecHashMatched: true }, scoreableEventCount: 10, scoreableDomainCount: 5, }); - assert.equal(result.canAcceptRange, true); - assert.equal(result.canConfirmExactMinute, false); + assert.equal(gate.canAcceptRange, true); + assert.equal(gate.canConfirmExactMinute, false); }); -test("opening question invites free narration without a fixed domain", () => { +test("opening question is open narration, not a fixed-domain questionnaire", () => { const question = openingQuestion({ start: "04:50", end: "05:10" }, randomUUID()); - assert.equal(question.domain, "other"); assert.equal(question.targetEventId, null); assert.match(question.prompt, /04:50–05:10/); @@ -84,70 +88,33 @@ test("opening question invites free narration without a fixed domain", () => { assert.doesNotMatch(question.prompt, /毕业|搬家|恋爱|工作|财务|健康/); }); -test("fallback planner refines an imprecise event, then returns to open narration", () => { - const eventId = randomUUID(); - const event = appendEventRevision([], { - eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业", - rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"), - }, { id: randomUUID(), now }); - const question = planNextQuestion({ - events: [event], - attemptedRefinementEventIds: [], - latestAnswer: "2016年高中毕业", - id: randomUUID(), +test("Opportunity Builder prioritizes event-local date refinement and never asks family as self health", () => { + const event = revision({ + eventId: randomUUID(), domain: "education", eventKind: "education_milestone", subject: "self", relatedPerson: null, + summary: "离家去外地上大学", rawText: "2016年离家去外地上大学", dateRange: dateRangeFromDeclared("2016", "year"), scoreability: "scoreable", }); - assert.equal(question.targetEventId, eventId); - assert.equal(question.domain, "education"); - assert.match(question.prompt, /高中毕业/); - assert.match(question.prompt, /月份或日期/); - - const fallback = planNextQuestion({ - events: [event], - attemptedRefinementEventIds: [eventId], - latestAnswer: "2016年高中毕业", - id: randomUUID(), - }); - assert.equal(fallback.targetEventId, null); - assert.equal(fallback.domain, "other"); - assert.match(fallback.prompt, /继续讲另一件/); - assert.doesNotMatch(fallback.prompt, /搬家|恋爱|事业|财务|健康/); + const opportunities = buildQuestionOpportunities({ caseId: randomUUID(), events: [event], turns: [], snapshot: null, diagnostics: null }); + const local = opportunities.find((item) => item.kind === "refine_event_date"); + assert.equal(local?.targetEventId, event.eventId); + assert.equal(local?.domain, "education"); + assert.match(local?.prompt ?? "", /离家去外地上大学/); + assert.match(local?.prompt ?? "", /月份或日期/); + assert.ok(opportunities.every((item, index) => index === 0 || opportunities[index - 1]!.utility >= item.utility)); + assert.ok(opportunities.every((item) => item.domain !== "family")); }); -test("month-precise evidence is sufficient for the model to choose the next topic", () => { - const event = appendEventRevision([], { - eventId: randomUUID(), domain: "education", eventKind: "education_milestone", summary: "去外地上大学", - rawText: "2016年9月去外地上大学", dateRange: dateRangeFromDeclared("2016-09", "month"), - }, { id: randomUUID(), now }); - - const question = planNextQuestion({ - events: [event], - attemptedRefinementEventIds: [], - latestAnswer: "2016年9月去外地上大学", - id: randomUUID(), - }); - - assert.equal(question.targetEventId, null); - assert.equal(question.domain, "other"); -}); - -test("targeted date answer appends a revision without duplicating the scoreable event", () => { +test("targeted date answer appends a revision without duplicating the event", () => { const eventId = randomUUID(); - const original = appendEventRevision([], { - eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业", - rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"), - }, { id: randomUUID(), now }); + const original = revision({ + eventId, domain: "education", eventKind: "education_milestone", subject: "self", relatedPerson: null, + summary: "高中毕业", rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"), scoreability: "scoreable", + }); const revisions = extractV4EventRevisions({ - answer: "2016年6月8日", - sourceTurnId: randomUUID(), - asOfDate: "2026-07-26", - existing: [original], - targetEventId: eventId, - now, + answer: "2016年6月8日", sourceTurnId: randomUUID(), asOfDate: "2026-07-28", existing: [original], targetEventId: eventId, now, }); assert.equal(revisions.length, 1); assert.equal(revisions[0]?.eventId, eventId); assert.equal(revisions[0]?.revision, 2); - assert.equal(revisions[0]?.dateRange.precision, "day"); assert.equal(revisions[0]?.dateRange.start, "2016-06-08"); assert.equal(latestEventRevisions([original, ...revisions]).length, 1); }); diff --git a/frontend/tests/rectification-v4-replay.test.ts b/frontend/tests/rectification-v4-replay.test.ts index 96239dcc..5081ffbb 100644 --- a/frontend/tests/rectification-v4-replay.test.ts +++ b/frontend/tests/rectification-v4-replay.test.ts @@ -3,10 +3,10 @@ import { randomUUID } from "node:crypto"; import test from "node:test"; import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts"; -import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts"; -import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts"; +import type { CalculationSpec, CandidateMinute } from "../src/lib/rectification-v4/contracts.ts"; import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; +import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; const now = () => new Date("2026-07-26T08:00:00.000Z"); const spec: CalculationSpec = { @@ -29,13 +29,7 @@ async function answerAndRun( version: number, answer: string, ) { - const queued = await service.answer({ - userId, - caseId, - actionId: randomUUID(), - expectedCaseVersion: version, - answer, - }); + const queued = await service.answer({ userId, caseId, actionId: randomUUID(), expectedCaseVersion: version, answer }); assert.ok(queued?.job); assert.equal(await worker.runOnce(), true); const loaded = await service.loadCase(userId, caseId); @@ -43,79 +37,95 @@ async function answerAndRun( return loaded; } -test("fixture replay returns ranges only and never mutates the profile birth minute", async () => { +test("V5 golden replay persists the full artifact chain, returns ranges only, and never mutates the profile minute", async () => withV5Mode("v5_agent", async () => { const profile = { active_birth_time: "05:00:00" }; const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now }); + const candidates: readonly CandidateMinute[] = [ + { time: "05:13", score: 100, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:14", score: 99, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:15", score: 98, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:16", score: 60, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:17", score: 97.8, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:18", score: 97.7, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:19", score: 97.6, supportingEventIds: [], conflictingEventIds: [] }, + ]; const worker = createRectificationV4Worker({ store, now, - questionAuthor: async () => ({ - id: randomUUID(), - domain: "other", - targetEventId: null, - prompt: "请继续讲另一件时间比较清楚的人生变化。", - recallCost: "low", - reason: "Replay keeps narration open instead of depending on a fixed domain order.", - }), engine: { async score({ calculationSpec, events }) { const ids = events.map((event) => event.eventId); - return { - resultId: randomUUID(), - calculationSpecHash: calculationSpecHash(calculationSpec), - candidates: [ - { time: "05:13", score: 100, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:14", score: 99, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:15", score: 98, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:16", score: 60, supportingEventIds: [], conflictingEventIds: ids }, - { time: "05:17", score: 97.8, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:18", score: 97.7, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:19", score: 97.6, supportingEventIds: ids, conflictingEventIds: [] }, - ], - robustness: { - neighborSupportMinutes: 3, - leaveOneOutRetentionRate: 1, - dateSensitivityRetentionRate: 0.9, - }, - missingLayers: [], - }; + return v5EngineResult(calculationSpec, events, candidates.map((candidate) => ({ + ...candidate, + supportingEventIds: candidate.score >= 97 ? ids : [], + conflictingEventIds: candidate.score < 97 ? ids : [], + }))); }, }, }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - let loaded = await answerAndRun( - service, - worker, - userId, - created.case.id, - created.case.version, - "2015年7月高中毕业后复读一年,2016年6月再次高中毕业", - ); + assert.equal(created.case.deploymentMode, "v5_agent"); + + let loaded = await answerAndRun(service, worker, userId, created.case.id, created.case.version, "2015年7月高中毕业后复读一年,2016年6月再次高中毕业"); assert.deepEqual(loaded.events.map((event) => [event.dateRange.start, event.dateRange.end]), [ ["2015-07-01", "2015-07-31"], ["2016-06-01", "2016-06-30"], ]); - loaded = await answerAndRun(service, worker, userId, created.case.id, loaded.case.version, "2018年8月搬家到北京"); + const firstTarget = loaded.case.currentQuestion?.targetEventId; + assert.ok(firstTarget); + const firstTargetEvent = loaded.events.find((event) => event.eventId === firstTarget); + assert.ok(firstTargetEvent); loaded = await answerAndRun( service, worker, userId, created.case.id, loaded.case.version, - "2020年5月开始恋爱,2022年3月分手", + firstTargetEvent.dateRange.start.startsWith("2015-") ? "2015年7月18日" : "2016年6月22日", ); + const secondTarget = loaded.case.currentQuestion?.targetEventId; + assert.ok(secondTarget); + assert.notEqual(secondTarget, firstTarget); + const secondTargetEvent = loaded.events.find((event) => event.eventId === secondTarget); + assert.ok(secondTargetEvent); + loaded = await answerAndRun( + service, + worker, + userId, + created.case.id, + loaded.case.version, + secondTargetEvent.dateRange.start.startsWith("2015-") ? "2015年7月18日" : "2016年6月22日", + ); + assert.equal(loaded.case.currentQuestion?.domain, "relocation"); + loaded = await answerAndRun( + service, + worker, + userId, + created.case.id, + loaded.case.version, + "2018年8月搬家到北京;2019年3月入职新公司;2020年5月开始一段恋爱关系", + ); + const snapshot = loaded.case.latestSnapshot; assert.ok(snapshot); assert.equal(snapshot.canConfirmExactMinute, false); assert.equal(snapshot.canAcceptRange, true); - assert.deepEqual(snapshot.clusters.map((cluster) => [cluster.startTime, cluster.endTime]), [ - ["05:13", "05:15"], - ["05:17", "05:19"], - ]); + assert.deepEqual(snapshot.clusters.map((cluster) => [cluster.startTime, cluster.endTime]), [["05:13", "05:15"], ["05:17", "05:19"]]); assert.equal(snapshot.clusters[0]?.representativeTime, "05:13"); assert.equal(loaded.case.acceptedRange, null); + assert.ok(loaded.case.featureSnapshotId); + assert.ok(loaded.case.latestDiagnosticsId); + assert.equal(store.featureSnapshots.size, 1); + assert.equal(store.diagnostics.size, 1); + assert.equal(store.agentRuns.size, 4); + assert.equal(store.publicMessages.size, 4); + assert.equal(store.validatedDecisions.size, 4); + const finalRun = [...store.agentRuns.values()].at(-1); + assert.equal(finalRun?.validatedDecision.decision.action, "offer_candidate_range"); + assert.equal(finalRun?.inputTokenCount, null); + assert.equal(finalRun?.outputTokenCount, null); const accepted = await service.acceptRange({ userId, @@ -128,4 +138,4 @@ test("fixture replay returns ranges only and never mutates the profile birth min assert.deepEqual(accepted?.case.acceptedRange, { start: "05:13", end: "05:15" }); assert.equal(accepted?.case.latestSnapshot?.canConfirmExactMinute, false); assert.equal(profile.active_birth_time, "05:00:00"); -}); +})); diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index a736eb28..4d0c202b 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; -import test from "node:test"; import { randomUUID } from "node:crypto"; +import test from "node:test"; import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts"; import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts"; import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; -const fixedNow = () => new Date("2026-07-26T12:00:00.000Z"); +const fixedNow = () => new Date("2026-07-28T12:00:00.000Z"); const spec: CalculationSpec = { version: "rectification-calculation-spec-v4", birthDate: "1997-08-08", @@ -19,224 +19,129 @@ const spec: CalculationSpec = { minuteStep: 1, }; +async function withMode(mode: "v4_legacy" | "v5_shadow" | "v5_agent", run: () => Promise): Promise { + const keys = ["RECTIFICATION_AGENT_V5_ENABLED", "RECTIFICATION_AGENT_V5_SHADOW", "RECTIFICATION_AGENT_V5_CANARY_PERCENT"] as const; + const before = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + process.env.RECTIFICATION_AGENT_V5_ENABLED = mode === "v4_legacy" ? "0" : "1"; + process.env.RECTIFICATION_AGENT_V5_SHADOW = mode === "v5_shadow" ? "1" : "0"; + process.env.RECTIFICATION_AGENT_V5_CANARY_PERCENT = "100"; + try { return await run(); } finally { + for (const key of keys) { + if (before[key] === undefined) delete process.env[key]; + else process.env[key] = before[key]; + } + } +} -test("same calculation spec resumes the unfinished case", async () => { +test("same calculation spec resumes while a changed spec abandons the old case and stales its job", async () => withMode("v4_legacy", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow }); const userId = randomUUID(); const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const resumed = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: { ...spec } }); - assert.equal(resumed.case.id, first.case.id); - assert.equal(store.cases.size, 1); -}); -test("changed calculation spec atomically abandons the old case and stales its job", async () => { - const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); - const userId = randomUUID(); - const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const queued = await service.answer({ userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学", }); assert.ok(queued?.job); - const replacement = await service.createCase({ - userId, - actionId: randomUUID(), - calculationSpec: { ...spec, candidateRange: { start: "04:45", end: "05:30" } }, + userId, actionId: randomUUID(), calculationSpec: { ...spec, candidateRange: { start: "04:45", end: "05:30" } }, }); - assert.notEqual(replacement.case.id, first.case.id); assert.equal(store.cases.get(first.case.id)?.status, "abandoned"); - assert.equal(store.cases.get(first.case.id)?.currentQuestion, null); assert.equal(store.jobs.get(queued.job.id)?.status, "stale"); - assert.equal((await service.loadActive(userId))?.case.id, replacement.case.id); -}); +})); -test("an accepted range closes the active lifecycle and allows a new case", async () => { - const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); - const userId = randomUUID(); - const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - await store.transitionCase({ - userId, - caseId: first.case.id, - actionId: randomUUID(), - expectedCaseVersion: first.case.version, - status: "range_ready", - phase: "complete", - acceptedRange: { start: "05:13", end: "05:15" }, - now: fixedNow().toISOString(), - }); - - assert.equal(await service.loadActive(userId), null); - const next = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - assert.notEqual(next.case.id, first.case.id); - assert.equal(store.cases.size, 2); -}); - -test("answer is durably queued and poll remains read only", async () => { +test("answer is durably queued and polling does not mutate the job", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - assert.equal(created.case.status, "awaiting_answer"); - assert.equal(created.case.currentQuestion?.domain, "other"); - assert.match(created.case.currentQuestion?.prompt ?? "", /不需要按固定领域回答/); + assert.equal(created.case.protocol, "rectification-evidence-v5"); + assert.equal(created.case.deploymentMode, "v5_agent"); const queued = await service.answer({ userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, - answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业", - modelId: "gpt-5.5", + answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业", modelId: "gpt-5.5", }); assert.equal(queued?.case.status, "processing"); - assert.equal(queued?.job?.status, "pending"); assert.equal(queued?.turns.at(-1)?.modelId, "gpt-5.5"); const before = JSON.stringify([...store.jobs.values()]); - const polled = await service.loadCase(userId, created.case.id); - assert.equal(polled?.job, null); + assert.equal((await service.loadCase(userId, created.case.id))?.job, null); assert.equal(JSON.stringify([...store.jobs.values()]), before); -}); +})); -test("worker extracts dated events, keeps one question and never confirms an exact minute", async () => { +test("V5 agent fallback persists Agent Run, Public Message and a server-owned opportunity", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const queued = await service.answer({ userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, - answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业", - }); - const worker = createRectificationV4Worker({ - store, - now: fixedNow, - engine: { async score() { throw new Error("engine must not run before enough events"); } }, - }); - assert.equal(await worker.runOnce(), true); - const done = await service.loadCase(userId, created.case.id); - assert.equal(done?.case.status, "awaiting_answer"); - assert.equal(done?.events.length, 2); - assert.equal(done?.case.currentQuestion?.domain, "other"); - assert.equal(done?.case.currentQuestion?.targetEventId, null); - assert.match(done?.case.currentQuestion?.prompt ?? "", /继续讲另一件/); - assert.equal(done?.case.latestSnapshot, null); - assert.equal(queued?.job?.status, "pending"); -}); - -test("worker rejects a model-authored domain jump while the latest event still needs a month", async () => { - const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); - const userId = randomUUID(); - const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - await service.answer({ - userId, - caseId: created.case.id, - actionId: randomUUID(), - expectedCaseVersion: created.case.version, answer: "2016年离家去外地上大学", - modelId: "gpt-5.5", }); + assert.ok(queued?.job); const worker = createRectificationV4Worker({ - store, - now: fixedNow, + store, now: fixedNow, engine: { async score() { throw new Error("engine must not run before enough events"); } }, - questionAuthor: async () => ({ - id: randomUUID(), - domain: "relocation", - targetEventId: null, - prompt: "请说一次影响较大的搬家或长期迁居,并给出尽可能准确的年月。", - recallCost: "low", - reason: "模型错误地跳到了另一个领域。", - }), }); - assert.equal(await worker.runOnce(), true); const done = await service.loadCase(userId, created.case.id); const event = done?.events.find((item) => item.summary === "离家去外地上大学"); - - assert.ok(event); + const run = [...store.agentRuns.values()][0]; + const message = store.publicMessages.get(queued.job.id); + assert.ok(event && run && message); + assert.equal(run.deploymentMode, "v5_agent"); + assert.equal(run.validatedDecision.mode, "deterministic_fallback"); + assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, event.eventId); assert.equal(done?.case.currentQuestion?.targetEventId, event.eventId); - assert.equal(done?.case.currentQuestion?.domain, "education"); assert.match(done?.case.currentQuestion?.prompt ?? "", /离家去外地上大学/); - assert.match(done?.case.currentQuestion?.prompt ?? "", /月份或日期/); - assert.doesNotMatch(done?.case.currentQuestion?.prompt ?? "", /搬家或长期迁居/); -}); + assert.equal(message.question, done?.case.currentQuestion?.prompt); + assert.equal(done?.case.latestSnapshot, null); +})); -test("completed job rejects stale case or calculation hashes", async () => { +test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible projection", async () => withMode("v5_shadow", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - await service.answer({ userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学" }); - const claimed = await store.claimNextJob("worker", fixedNow().toISOString()); - assert.ok(claimed); - await assert.rejects(() => store.completeJob({ - workerId: "worker", jobId: claimed.job.id, expectedCaseVersion: claimed.case.version, - inputEvidenceSetHash: "0".repeat(64), outputEvidenceSetHash: claimed.case.evidenceSetHash, - calculationSpecHash: claimed.case.calculationSpecHash, newEventRevisions: [], snapshot: null, - nextQuestion: claimed.case.currentQuestion, status: "awaiting_answer", phase: "collecting_evidence", - }, fixedNow().toISOString()), /stale_job/); -}); - -test("worker gives the selected model full conversation context for the next question", async () => { - const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); - const userId = randomUUID(); - const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - const contexts: Array<{ - modelId: string | null; - turnAnswers: string[]; - eventSummaries: string[]; - }> = []; - const worker = createRectificationV4Worker({ - store, - now: fixedNow, - engine: { async score() { throw new Error("engine must not run before enough events"); } }, - questionAuthor: async (context) => { - contexts.push({ - modelId: context.modelId, - turnAnswers: context.turns.map((turn) => turn.answer), - eventSummaries: context.events.map((event) => event.summary), - }); - return { - id: randomUUID(), - domain: "other", - targetEventId: null, - prompt: context.turns.length === 1 - ? "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?" - : "你提到毕业和搬家是连续发生的。那次搬家前后,生活节奏还有什么明显变化?", - recallCost: "low", - reason: "根据完整对话选择下一条高信息量追问。", - }; - }, + const queued = await service.answer({ + userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, + answer: "2016年离家去外地上大学", }); + const worker = createRectificationV4Worker({ + store, now: fixedNow, + engine: { async score() { throw new Error("engine must not run before enough events"); } }, + }); + assert.equal(await worker.runOnce(), true); + const done = await service.loadCase(userId, created.case.id); + const event = done?.events[0]; + const run = [...store.agentRuns.values()][0]; + assert.ok(queued?.job && event && run); + assert.equal(run.deploymentMode, "v5_shadow"); + assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, event.eventId); + assert.equal(done.case.currentQuestion?.targetEventId, event.eventId); + assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/); + assert.match(store.publicMessages.get(queued.job.id)?.acknowledgement ?? "", /我记下了/); +})); - let current = created; - for (const [answer, modelId] of [ - ["2015年7月高中毕业后复读一年,2016年6月再次毕业", "gpt-5.5"], - ["2018年8月搬到北京,之后开始独立生活", "deepseek-chat"], - ] as const) { +test("legacy cases are not hard-switched to V5 even when flags change later", async () => { + const store = createRectificationV4MemoryStore(); + const service = createRectificationV4CaseService(store, { now: fixedNow }); + const userId = randomUUID(); + const created = await withMode("v4_legacy", () => service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec })); + await withMode("v5_agent", async () => { const queued = await service.answer({ - userId, - caseId: created.case.id, - actionId: randomUUID(), - expectedCaseVersion: current.case.version, - answer, - modelId, + userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学", + }); + const worker = createRectificationV4Worker({ + store, now: fixedNow, + engine: { async score() { throw new Error("engine must not run before enough events"); } }, }); - assert.ok(queued?.job); assert.equal(await worker.runOnce(), true); - current = (await service.loadCase(userId, created.case.id))!; - } - - assert.equal(contexts.length, 2); - assert.equal(contexts[0]?.modelId, "gpt-5.5"); - assert.deepEqual(contexts[1]?.turnAnswers, [ - "2015年7月高中毕业后复读一年,2016年6月再次毕业", - "2018年8月搬到北京,之后开始独立生活", - ]); - assert.equal(contexts[1]?.modelId, "deepseek-chat"); - assert.equal(contexts[1]?.eventSummaries.length, 3); - assert.match(current.case.currentQuestion?.prompt ?? "", /毕业和搬家/); - assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false); + const run = [...store.agentRuns.values()][0]; + assert.ok(queued?.job && run); + assert.equal(run.deploymentMode, "v4_legacy"); + assert.equal(run.fallbackReason, "deployment_mode_legacy"); + }); }); diff --git a/frontend/tests/rectification-v5-migration-contract.test.ts b/frontend/tests/rectification-v5-migration-contract.test.ts new file mode 100644 index 00000000..0e6c7dd9 --- /dev/null +++ b/frontend/tests/rectification-v5-migration-contract.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const migration = readFileSync(new URL( + "../supabase/migrations/20260728020000_rectification_agent_v5.sql", + import.meta.url, +), "utf8"); + +test("V5 migration freezes protocol and deployment mode per case", () => { + assert.match(migration, /protocol in \('rectification-evidence-v4', 'rectification-evidence-v5'\)/); + assert.match(migration, /p_deployment_mode text/); + assert.match(migration, /deployment_mode in \('v4_legacy', 'v5_shadow', 'v5_agent'\)/); + assert.match(migration, /An in-flight Case keeps the deployment mode and protocol it was created with/); + assert.match(migration, /v_protocol := case when p_deployment_mode = 'v4_legacy'/); + assert.match(migration, /if found and v_case\.calculation_spec_hash = p_calculation_spec_hash/); +}); + +test("V5 migration owns all durable artifacts and indexes", () => { + for (const table of [ + "birth_time_rectification_candidate_feature_snapshots", + "birth_time_rectification_diagnostics", + "birth_time_rectification_agent_runs", + "birth_time_rectification_public_messages", + "birth_time_rectification_pending_evidence", + ]) assert.match(migration, new RegExp(`create table if not exists public\\.${table}`)); + for (const indexFragment of [ + "feature_snapshots_case_created_idx", "feature_snapshots_user_created_idx", + "diagnostics_case_created_idx", "diagnostics_user_created_idx", "diagnostics_snapshot_idx", + "agent_runs_case_created_idx", "agent_runs_user_created_idx", "public_messages_case_created_idx", + ]) assert.match(migration, new RegExp(indexFragment)); + assert.match(migration, /birth_time_rectification_v5_feature_snapshot_fk[\s\S]*candidate_feature_snapshots/); + assert.match(migration, /birth_time_rectification_v5_latest_diagnostics_fk[\s\S]*birth_time_rectification_diagnostics/); + assert.match(migration, /alter table public\.birth_time_rectification_agent_runs[\s\S]*add column if not exists deployment_mode/); + assert.match(migration, /alter column deployment_mode set not null/); + assert.match(migration, /birth_time_rectification_v5_agent_runs_tool_count_check/); + assert.match(migration, /birth_time_rectification_v5_agent_runs_token_count_check/); + assert.match(migration, /birth_time_rectification_pending_evidence_target_event_idx/); + assert.match(migration, /birth_time_rectification_v5_pending_resolution_check/); +}); + +test("V5 migration replaces every V4-only worker and evidence constraint", () => { + assert.match(migration, /birth_time_rectification_v5_jobs_phase_check[\s\S]*'reasoning', 'rendering'/); + assert.match(migration, /birth_time_rectification_v5_candidate_snapshots_algorithm_check[\s\S]*rectification-v4-range-scoring-1[\s\S]*rectification-v5-matrix-scoring-1/); + assert.match(migration, /birth_time_rectification_v5_event_revisions_scoreability_check[\s\S]*'pending_review', 'unsupported'/); + assert.match(migration, /birth_time_rect_v5_event_revision_domain_score_check[\s\S]*scoreability <> 'scoreable'/); + assert.match(migration, /birth_time_rect_v5_event_revision_relationship_kind_check[\s\S]*'relationship_change'/); +}); + +test("V5 completion is lease-bound, hash-bound, ownership-bound and replay-safe", () => { + for (const fragment of [ + "rectification_v4_job_lease_lost", + "stale_rectification_v4_job", + "rectification_v5_snapshot_mismatch", + "rectification_v5_feature_snapshot_mismatch", + "rectification_v5_diagnostics_mismatch", + "invalid_rectification_v5_agent_run", + "rectification_v5_replay_payload_mismatch", + "rectification_v5_pending_target_event_mismatch", + "rectification_v5_pending_resolved_event_mismatch", + "rectification_v5_artifact_set_incomplete", + "exact_minute_confirmation_forbidden", + ]) assert.match(migration, new RegExp(fragment)); + assert.match(migration, /if v_job\.status = 'completed'[\s\S]*return v_case\.id/); + assert.match(migration, /jsonb_array_length\(p_agent_run->'toolCalls'\) > 8/); + assert.match(migration, /p_agent_run->>'deploymentMode' is distinct from v_case\.deployment_mode/); + assert.match(migration, /p_completion_payload_hash text/); + assert.match(migration, /v_job\.completion_payload_hash is distinct from p_completion_payload_hash/); + assert.match(migration, /completion_payload_hash = p_completion_payload_hash/); +}); + +test("V5 inserts use explicit columns and never mutate the profile birth minute", () => { + for (const table of [ + "birth_time_rectification_v4_events", + "birth_time_rectification_v4_event_revisions", + "birth_time_rectification_v4_candidate_snapshots", + "birth_time_rectification_candidate_feature_snapshots", + "birth_time_rectification_diagnostics", + "birth_time_rectification_agent_runs", + "birth_time_rectification_public_messages", + ]) assert.match(migration, new RegExp(`insert into public\\.${table}\\s*\\(`)); + assert.doesNotMatch(migration, /profiles\.active_birth_time|update\s+public\.profiles/i); +}); diff --git a/frontend/tests/rectification-v5-test-support.ts b/frontend/tests/rectification-v5-test-support.ts new file mode 100644 index 00000000..841548a3 --- /dev/null +++ b/frontend/tests/rectification-v5-test-support.ts @@ -0,0 +1,87 @@ +import { randomUUID } from "node:crypto"; +import type { CandidateEngineResult } from "../src/lib/rectification-v4/candidate-engine.ts"; +import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "../src/lib/rectification-v4/contracts.ts"; +import { rectificationV4AlgorithmVersion } from "../src/lib/rectification-v4/contracts.ts"; +import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts"; + +export function v5EngineResult( + calculationSpec: CalculationSpec, + events: readonly LifeEventRevision[], + candidates: readonly CandidateMinute[] = [ + { time: "05:13", score: 100, supportingEventIds: events.map((event) => event.eventId), conflictingEventIds: [] }, + { time: "05:14", score: 99, supportingEventIds: events.map((event) => event.eventId), conflictingEventIds: [] }, + { time: "05:15", score: 98, supportingEventIds: events.map((event) => event.eventId), conflictingEventIds: [] }, + ], +): CandidateEngineResult { + const specHash = calculationSpecHash(calculationSpec); + return { + resultId: randomUUID(), + calculationSpecHash: specHash, + candidates, + robustness: { + neighborSupportMinutes: 3, + leaveOneOutRetentionRate: 1, + leaveOneDomainOutRetentionRate: 1, + dateSensitivityRetentionRate: .9, + }, + diagnostics: { + primary_cluster_retention_rate: 1, + leave_one_event_out_retention_rate: 1, + leave_one_domain_out_retention_rate: 1, + date_sensitivity_retention_rate: .9, + neighbor_support_minutes: 3, + primary_secondary_margin_percent: 20, + cluster_mass_ratio: .9, + unstable_event_ids: [], + most_discriminating_layers: ["D9", "D10"], + event_date_sensitivity: events.map((event) => ({ + event_id: event.eventId, + declared_date_range: { start: event.dateRange.start, end: event.dateRange.end, precision: event.dateRange.precision }, + sample_dates: [event.dateRange.start, event.dateRange.end].filter((value, index, values) => values.indexOf(value) === index), + winner_retention_rate: 1, + score_variance: 0, + candidate_cluster_retention_rate: 1, + })), + candidate_splits: [], + }, + featureSnapshot: { + calculation_spec_hash: specHash, + algorithm_version: rectificationV4AlgorithmVersion, + candidate_count: candidates.length, + feature_hash: "f".repeat(64), + features: candidates.map((candidate, index) => ({ + time: candidate.time, + ascendant_degree: index, + ascendant_sign_index: 0, + varga_ascendants: { D1: 0, D9: index % 12 }, + arudha_signs: { A7: 1, A10: 2, UL: 3 }, + available_layers: ["D1", "D9", "D10"], + blocked_layers: ["KP_cusps"], + fingerprints: { static: `${candidate.time}:${index}` }, + })), + }, + contributionMatrix: Object.fromEntries(events.map((event) => [ + event.eventId, + Object.fromEntries(candidates.map((candidate) => [candidate.time, { + points: candidate.supportingEventIds.includes(event.eventId) ? 1 : candidate.conflictingEventIds.includes(event.eventId) ? -1 : 0, + rule_ids: ["fixture:rule"], + technique_layers: ["D9"], + }])), + ])), + missingLayers: ["KP_cusps"], + }; +} + +export async function withV5Mode(mode: "v4_legacy" | "v5_shadow" | "v5_agent", run: () => Promise): Promise { + const keys = ["RECTIFICATION_AGENT_V5_ENABLED", "RECTIFICATION_AGENT_V5_SHADOW", "RECTIFICATION_AGENT_V5_CANARY_PERCENT"] as const; + const before = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + process.env.RECTIFICATION_AGENT_V5_ENABLED = mode === "v4_legacy" ? "0" : "1"; + process.env.RECTIFICATION_AGENT_V5_SHADOW = mode === "v5_shadow" ? "1" : "0"; + process.env.RECTIFICATION_AGENT_V5_CANARY_PERCENT = "100"; + try { return await run(); } finally { + for (const key of keys) { + if (before[key] === undefined) delete process.env[key]; + else process.env[key] = before[key]; + } + } +} diff --git a/references/real_case_calibration/conversational_rectification_development_v1.json b/references/real_case_calibration/conversational_rectification_development_v1.json index ce30dea7..4039e35e 100644 --- a/references/real_case_calibration/conversational_rectification_development_v1.json +++ b/references/real_case_calibration/conversational_rectification_development_v1.json @@ -83,8 +83,8 @@ { "event_id": "curie_widowed_1906", "user_utterance": "1906年4月19日我的丈夫因交通事故去世。", - "expected_extraction": {"date_value": "1906-04-19", "date_precision": "day", "domain": "health_pressure", "scoreable": true}, - "expected_route_scoreable": true + "expected_extraction": {"date_value": "1906-04-19", "date_precision": "day", "domain": "family", "scoreable": false}, + "expected_route_scoreable": false } ] } diff --git a/scripts/active_rectification_event_engine.py b/scripts/active_rectification_event_engine.py index a0e386c0..b3d27432 100644 --- a/scripts/active_rectification_event_engine.py +++ b/scripts/active_rectification_event_engine.py @@ -14,7 +14,7 @@ import sys from datetime import date, datetime, time, timedelta from pathlib import Path from collections.abc import Sequence -from typing import Final, assert_never +from typing import Any, Final, assert_never from scripts.active_rectification_events import ( CandidateEvidence, @@ -316,10 +316,22 @@ def _shadbala_verified_components_auxiliary(natal_chart: dict, birth_hour: float return [], 0.0 -def _candidate_row( +def _feature_hash(value: Any) -> str: + normalized = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def _arudha_sign(arudha_padas: dict, key: str) -> int | None: + value = arudha_padas.get(key) or {} + sign_index = value.get("sign_idx") + return int(sign_index) if isinstance(sign_index, int) and 0 <= sign_index <= 11 else None + + +def build_candidate_static_context( request: RectificationEventRequest, candidate_at: datetime, -) -> CandidateScoreRow: +) -> dict[str, Any]: + """Compute every candidate-minute natal layer once for scoring and diagnostics.""" chart = domain_calculation_service.compute_chart({ "year": candidate_at.year, "month": candidate_at.month, @@ -340,16 +352,102 @@ def _candidate_row( ascendant_longitude = float(chart["ascendant"]["lon"]) ascendant_index = int(ascendant_longitude // 30) arudha = jaimini.calc_arudha_padas(ascendant_index, planet_longitudes) - arudha_padas = { - **(arudha.get("padas") or {}), - "UL": arudha.get("upapada") or {}, - } + arudha_padas = {**(arudha.get("padas") or {}), "UL": arudha.get("upapada") or {}} charts = varga.calc_all_vargas( planet_longitudes, ascendant_longitude, divisions=[2, 4, 9, 10, 24, 30], ) d11_chart = _d11_chart(planet_longitudes, ascendant_longitude) + varga_charts = { + prefix: d11_chart if prefix == "D11" else _varga_chart(charts, prefix) + for prefix in ("D2", "D4", "D9", "D10", "D11", "D24", "D30") + } + available_layers = ["D1"] + blocked_layers = ["KP_cusps"] + varga_ascendants: dict[str, int] = {} + for prefix, value in varga_charts.items(): + ascendant = (value or {}).get("Ascendant") or {} + sign_index = ascendant.get("sign_idx") + if isinstance(sign_index, int) and 0 <= sign_index <= 11: + varga_ascendants[prefix] = sign_index + available_layers.append(prefix) + else: + blocked_layers.append(prefix) + + arudha_signs = {key: _arudha_sign(arudha_padas, key) for key in ("A7", "A10", "UL")} + for key, sign_index in arudha_signs.items(): + (available_layers if sign_index is not None else blocked_layers).append(key) + + ashtakavarga_result = None + try: + ashtakavarga_result = ashtakavarga.calc_ashtakavarga(chart.get("planets", {}), ascendant_index) + available_layers.append("Ashtakavarga") + except (KeyError, TypeError, ValueError): + blocked_layers.append("Ashtakavarga") + + shadbala_result = None + try: + shadbala_result = shadbala.calc_shadbala( + chart.get("planets", {}), + str(chart["ascendant"].get("sign")), + candidate_at.hour + candidate_at.minute / 60, + planet_longitudes["Sun"], + planet_longitudes["Moon"], + birth_minute=float(candidate_at.minute), + ) + available_layers.append("Shadbala") + except (KeyError, TypeError, ValueError): + blocked_layers.append("Shadbala") + + feature_payload = { + "time": candidate_at.strftime("%H:%M"), + "ascendant_degree": ascendant_longitude, + "ascendant_sign_index": ascendant_index, + "varga_ascendants": varga_ascendants, + "arudha_signs": arudha_signs, + "available_layers": sorted(set(available_layers)), + "blocked_layers": sorted(set(blocked_layers)), + "fingerprints": { + "natal": str(chart.get("result_hash") or _feature_hash({"ascendant": chart.get("ascendant"), "planets": chart.get("planets")})), + "vargas": _feature_hash(varga_ascendants), + "arudha": _feature_hash(arudha_signs), + "ashtakavarga": _feature_hash(ashtakavarga_result) if ashtakavarga_result is not None else "blocked", + "shadbala": _feature_hash(shadbala_result) if shadbala_result is not None else "blocked", + }, + } + feature_payload["fingerprints"]["static"] = _feature_hash(feature_payload) + return { + "candidate_at": candidate_at, + "chart": chart, + "planet_longitudes": planet_longitudes, + "ascendant_longitude": ascendant_longitude, + "ascendant_index": ascendant_index, + "arudha_padas": arudha_padas, + "varga_charts": varga_charts, + "feature": feature_payload, + } + + +def compute_candidate_static_contexts( + request: RectificationEventRequest, + *, + candidates: Sequence[datetime] | None = None, +) -> list[dict[str, Any]]: + candidate_datetimes = list(candidates) if candidates is not None else _candidate_datetimes(request) + return [build_candidate_static_context(request, candidate) for candidate in candidate_datetimes] + + +def _candidate_row( + request: RectificationEventRequest, + context: dict[str, Any], +) -> CandidateScoreRow: + candidate_at = context["candidate_at"] + chart = context["chart"] + planet_longitudes = context["planet_longitudes"] + ascendant_index = context["ascendant_index"] + arudha_padas = context["arudha_padas"] + varga_charts = context["varga_charts"] moon_longitude = planet_longitudes["Moon"] evidence: list[CandidateEvidence] = [] missing_layers: list[str] = [] @@ -357,7 +455,7 @@ def _candidate_row( for event in request["events"]: event_at = _event_datetime(event) prefixes, _ = DOMAIN_CONFIG[event["domain"]] - domain_vargas = [d11_chart if prefix == "D11" else _varga_chart(charts, prefix) for prefix in prefixes] + domain_vargas = [varga_charts[prefix] for prefix in prefixes] if any(chart is None for chart in domain_vargas): missing_layers.extend(prefixes) continue @@ -407,7 +505,7 @@ def _candidate_row( "time": candidate_at.strftime("%H:%M"), "score": round(sum(item["points"] for item in evidence), 4), "evidence": evidence, - "missing_layers": sorted(set(missing_layers)), + "missing_layers": sorted(set(missing_layers + context["feature"]["blocked_layers"])), } @@ -501,7 +599,8 @@ def compute_event_candidate_rows( request: RectificationEventRequest, *, candidates: Sequence[datetime] | None = None, + static_contexts: Sequence[dict[str, Any]] | None = None, ) -> list[CandidateScoreRow]: - """Return every computed minute row without performing release adjudication.""" - candidate_datetimes = list(candidates) if candidates is not None else _candidate_datetimes(request) - return [_candidate_row(request, candidate) for candidate in candidate_datetimes] + """Return every computed minute row while reusing one static chart scan per candidate.""" + contexts = list(static_contexts) if static_contexts is not None else compute_candidate_static_contexts(request, candidates=candidates) + return [_candidate_row(request, context) for context in contexts] diff --git a/scripts/active_rectification_events_v4.py b/scripts/active_rectification_events_v4.py index 4c120ba8..507170cf 100644 --- a/scripts/active_rectification_events_v4.py +++ b/scripts/active_rectification_events_v4.py @@ -2,219 +2,16 @@ # requires-python = ">=3.11" # dependencies = [] # /// -"""Range-preserving event scoring for the asynchronous rectification V4 worker.""" - +"""Compatibility entrypoint backed by the formal V5 score service.""" from __future__ import annotations -import hashlib -import json -from collections.abc import Sequence -from typing import Any, Final, Literal, NotRequired, TypedDict -from uuid import NAMESPACE_URL, uuid5 +from typing import Any -from scripts.active_rectification_event_engine import compute_event_candidate_rows -from scripts.active_rectification_events import CandidateEvidence, CandidateScoreRow - -ALGORITHM_VERSION: Final = "rectification-v4-range-scoring-1" -INPUT_CONTRACT_VERSION: Final = "rectification-calculation-spec-v4" - -EventDomain = Literal["education", "relocation", "relationship", "career", "finance", "health_pressure"] -EventPrecision = Literal["day", "month", "quarter", "year", "range"] +from scripts.rectification.api_service import score_candidates -def _json_compatible_numbers(value: Any) -> Any: - if isinstance(value, float) and value.is_integer(): - return int(value) - if isinstance(value, dict): - return {key: _json_compatible_numbers(item) for key, item in value.items()} - if isinstance(value, list): - return [_json_compatible_numbers(item) for item in value] - return value - - -class RangeLifeEvent(TypedDict): - id: str - domain: EventDomain - event_kind: str - date_start: str - date_end: str - precision: EventPrecision - summary: NotRequired[str] - - -class RangeRectificationRequest(TypedDict): - birth_date: str - start_time: str - end_time: str - lat: float - lon: float - tz: float - events: list[RangeLifeEvent] - - -def _legacy_request(request: RangeRectificationRequest, boundary: Literal["start", "end"]) -> dict[str, Any]: - return { - "birth_date": request["birth_date"], - "start_time": request["start_time"], - "end_time": request["end_time"], - "lat": request["lat"], - "lon": request["lon"], - "tz": request["tz"], - "events": [{ - "id": event["id"], - "domain": event["domain"], - "date": event[f"date_{boundary}"], - "precision": "day", - "summary": event.get("summary", ""), - } for event in request["events"]], - } - - -def _evidence_by_event(row: CandidateScoreRow) -> dict[str, CandidateEvidence]: - return {item["event_id"]: item for item in row["evidence"]} - - -def _average_rows( - lower_rows: Sequence[CandidateScoreRow], - upper_rows: Sequence[CandidateScoreRow], -) -> list[CandidateScoreRow]: - if [row["time"] for row in lower_rows] != [row["time"] for row in upper_rows]: - raise ValueError("candidate_grid_mismatch") - averaged: list[CandidateScoreRow] = [] - for lower, upper in zip(lower_rows, upper_rows, strict=True): - lower_events = _evidence_by_event(lower) - upper_events = _evidence_by_event(upper) - evidence: list[CandidateEvidence] = [] - for event_id in sorted(set(lower_events) | set(upper_events)): - lower_item = lower_events.get(event_id) - upper_item = upper_events.get(event_id) - source = lower_item or upper_item - if source is None: - continue - lower_points = lower_item["points"] if lower_item else 0.0 - upper_points = upper_item["points"] if upper_item else 0.0 - evidence.append({ - "event_id": event_id, - "domain": source["domain"], - "candidate_time": lower["time"], - "rule_ids": sorted(set( - (lower_item or {}).get("rule_ids", []) - + (upper_item or {}).get("rule_ids", []) - + ["date_range_boundaries_averaged"] - )), - "points": round((lower_points + upper_points) / 2, 4), - }) - averaged.append({ - "time": lower["time"], - "score": round(sum(item["points"] for item in evidence), 4), - "evidence": evidence, - "missing_layers": sorted(set(lower["missing_layers"] + upper["missing_layers"])), - }) - return averaged - - -def _minute_value(value: str) -> int: - hour, minute = value.split(":", maxsplit=1) - return int(hour) * 60 + int(minute) - - -def _next_minute(previous: str, current: str) -> bool: - return (_minute_value(current) - _minute_value(previous)) % 1_440 == 1 - - -def _primary_cluster(rows: Sequence[CandidateScoreRow], relative_floor: float = 0.97) -> list[str]: - if not rows: - return [] - peak = max(row["score"] for row in rows) - floor = peak * relative_floor if peak >= 0 else peak / relative_floor - viable = sorted((row for row in rows if row["score"] >= floor), key=lambda row: _minute_value(row["time"])) - clusters: list[list[CandidateScoreRow]] = [] - for row in viable: - if clusters and _next_minute(clusters[-1][-1]["time"], row["time"]): - clusters[-1].append(row) - else: - clusters.append([row]) - if not clusters: - return [] - clusters.sort(key=lambda group: (-max(row["score"] for row in group), -sum(max(row["score"], 0) for row in group))) - return [row["time"] for row in clusters[0]] - - -def _top_time(rows: Sequence[CandidateScoreRow]) -> str | None: - if not rows: - return None - top = max(row["score"] for row in rows) - return next(row["time"] for row in rows if row["score"] == top) - - -def _leave_one_out(rows: Sequence[CandidateScoreRow], event_ids: Sequence[str], primary: set[str]) -> dict[str, Any]: - runs = [] - retained = 0 - for event_id in event_ids: - rescored = [] - for row in rows: - removed = sum(item["points"] for item in row["evidence"] if item["event_id"] == event_id) - rescored.append({**row, "score": round(row["score"] - removed, 4)}) - winner = _top_time(rescored) - stable = winner in primary - retained += int(stable) - runs.append({"removed_event_id": event_id, "winner": winner, "primary_cluster_retained": stable}) - return { - "retention_rate": retained / len(event_ids) if event_ids else 0.0, - "runs": runs, - } - - -def score_life_events_v4(request: RangeRectificationRequest) -> dict[str, Any]: - lower_rows = compute_event_candidate_rows(_legacy_request(request, "start")) - upper_rows = compute_event_candidate_rows(_legacy_request(request, "end")) - rows = _average_rows(lower_rows, upper_rows) - primary = _primary_cluster(rows) - primary_set = set(primary) - lower_winner = _top_time(lower_rows) - upper_winner = _top_time(upper_rows) - date_retention = sum(winner in primary_set for winner in (lower_winner, upper_winner)) / 2 - loo = _leave_one_out(rows, [event["id"] for event in request["events"]], primary_set) - normalized = json.dumps(request, ensure_ascii=True, sort_keys=True, separators=(",", ":")) - fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest() - spec = { - "version": INPUT_CONTRACT_VERSION, - "birthDate": request["birth_date"], - "candidateRange": {"start": request["start_time"], "end": request["end_time"]}, - "latitude": request["lat"], - "longitude": request["lon"], - "timezoneOffsetHours": request["tz"], - "ayanamsa": "lahiri", - "nodeMode": "mean", - "minuteStep": 1, - } - spec_hash = hashlib.sha256(json.dumps( - _json_compatible_numbers(spec), sort_keys=True, separators=(",", ":") - ).encode("utf-8")).hexdigest() - missing_layers = sorted({layer for row in rows for layer in row["missing_layers"]}) - candidates = [{ - "time": row["time"], - "score": row["score"], - "supporting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] > 0], - "conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0], - } for row in rows] - return { - "result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")), - "algorithm_version": ALGORITHM_VERSION, - "calculation_spec": spec, - "calculation_spec_hash": spec_hash, - "candidate_scores": candidates, - "primary_cluster_times": primary, - "robustness": { - "neighbor_support_minutes": len(primary), - "leave_one_out_retention_rate": loo["retention_rate"], - "date_sensitivity_retention_rate": date_retention, - "date_boundary_winners": {"start": lower_winner, "end": upper_winner}, - "leave_one_out": loo, - }, - "missing_layers": missing_layers, - "can_confirm_exact_minute": False, - } +def score_life_events_v4(request: dict[str, Any]) -> dict[str, Any]: + return score_candidates(request) if __name__ == "__main__": diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 1502baf3..68d1c2d7 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -1630,6 +1630,9 @@ API_COMMAND_MAP = { 'active-rectification-score': '/api/active_rectification_score', 'active-rectification-events': '/api/active_rectification_events', 'active-rectification-events-v4': '/api/active_rectification_events_v4', + 'rectification-v5-candidate-features': '/api/rectification/v5/candidate-features', + 'rectification-v5-score': '/api/rectification/v5/score', + 'rectification-v5-diagnostics': '/api/rectification/v5/diagnostics', 'case-validation': '/api/case_validation', 'divisional-yoga': '/api/divisional_yoga', 'deep-varga-avastha': '/api/deep_varga_avastha', @@ -1665,6 +1668,9 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = { '/api/active_rectification_score', '/api/active_rectification_events', '/api/active_rectification_events_v4', + '/api/rectification/v5/candidate-features', + '/api/rectification/v5/score', + '/api/rectification/v5/diagnostics', '/api/relationship', '/api/remedies', '/api/sade_sati', @@ -2114,6 +2120,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif path == '/api/active_rectification_events_v4': result = self._compute_active_rectification_events_v4(body) self._json(result) + elif path == '/api/rectification/v5/candidate-features': + self._json(self._compute_rectification_v5_candidate_features(body)) + elif path == '/api/rectification/v5/score': + self._json(self._compute_rectification_v5_score(body)) + elif path == '/api/rectification/v5/diagnostics': + self._json(self._compute_rectification_v5_diagnostics(body)) elif path == '/api/dynamic_rectification_opportunities': result = self._compute_dynamic_rectification_opportunities(body) self._json(result) @@ -7527,100 +7539,45 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'tz': self._get_float(body, 'tz', 0, -14, 14), } - def _compute_active_rectification_events_v4(self, body): - if not isinstance(body, dict): - raise BadRequest('request body must be an object') - - def required_text(name, pattern=None): - value = body.get(name) - if not isinstance(value, str) or not value.strip(): - raise BadRequest(f'{name} must be a string') - value = value.strip() - if pattern and not re.fullmatch(pattern, value): - raise BadRequest(f'{name} has invalid format') - return value - - birth_date = required_text('birth_date', r'\d{4}-\d{2}-\d{2}') - start_time = required_text('start_time', r'(?:[01]\d|2[0-3]):[0-5]\d') - end_time = required_text('end_time', r'(?:[01]\d|2[0-3]):[0-5]\d') + def _rectification_v5_request(self, body): + from scripts.rectification.contracts import normalize_rectification_request try: - birth_day = datetime.strptime(birth_date, '%Y-%m-%d').date() + return normalize_rectification_request(body) except ValueError as exc: - raise BadRequest('birth_date must be a valid calendar date') from exc - if start_time > end_time: - raise BadRequest('start_time must not exceed end_time') + raise BadRequest(str(exc)) from exc - def bounded_number(name, minimum, maximum): - value = body.get(name) - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): - raise BadRequest(f'{name} must be a finite number') - value = float(value) - if not minimum <= value <= maximum: - raise BadRequest(f'{name} must be between {minimum} and {maximum}') - return value - - events = body.get('events') - if not isinstance(events, list) or not 1 <= len(events) <= 100: - raise BadRequest('events must contain between 1 and 100 items') - allowed_kinds = { - 'education': {'education_milestone'}, - 'relocation': {'relocation'}, - 'relationship': {'relationship_start', 'relationship_end'}, - 'career': {'career_change'}, - 'finance': {'finance_change'}, - 'health_pressure': {'health_event'}, + def _compute_rectification_v5_candidate_features(self, body): + from scripts.rectification.api_service import candidate_features + return { + 'success': True, + 'endpoint': 'rectification_v5_candidate_features', + **candidate_features(self._rectification_v5_request(body)), + } + + def _compute_rectification_v5_score(self, body): + from scripts.rectification.api_service import score_candidates + return { + 'success': True, + 'endpoint': 'rectification_v5_score', + **score_candidates(self._rectification_v5_request(body)), + } + + def _compute_rectification_v5_diagnostics(self, body): + from scripts.rectification.api_service import diagnostics + return { + 'success': True, + 'endpoint': 'rectification_v5_diagnostics', + **diagnostics(self._rectification_v5_request(body)), + } + + def _compute_active_rectification_events_v4(self, body): + """Compatibility projection; validation and calculations are owned by V5 services.""" + from scripts.rectification.api_service import score_candidates + return { + 'success': True, + 'endpoint': 'active_rectification_events_v4', + **score_candidates(self._rectification_v5_request(body)), } - allowed_precision = {'day', 'month', 'quarter', 'year', 'range'} - cleaned_events = [] - today = datetime.now().date() - for index, event in enumerate(events): - if not isinstance(event, dict): - raise BadRequest(f'events[{index}] must be an object') - try: - event_id = str(uuid.UUID(str(event.get('id') or ''))) - except (ValueError, AttributeError) as exc: - raise BadRequest(f'events[{index}].id must be a UUID') from exc - domain = event.get('domain') - event_kind = event.get('event_kind') - precision = event.get('precision') - if domain not in allowed_kinds: - raise BadRequest(f'events[{index}].domain is not scoreable') - if event_kind not in allowed_kinds[domain]: - raise BadRequest(f'events[{index}].event_kind does not match domain') - if precision not in allowed_precision: - raise BadRequest(f'events[{index}].precision is invalid') - try: - start_day = datetime.strptime(str(event.get('date_start') or ''), '%Y-%m-%d').date() - end_day = datetime.strptime(str(event.get('date_end') or ''), '%Y-%m-%d').date() - except ValueError as exc: - raise BadRequest(f'events[{index}] dates must be valid YYYY-MM-DD values') from exc - if start_day > end_day: - raise BadRequest(f'events[{index}].date_start must not exceed date_end') - if start_day < birth_day or end_day > today: - raise BadRequest(f'events[{index}] dates must be between birth_date and today') - summary = event.get('summary', '') - if not isinstance(summary, str) or len(summary) > 1000: - raise BadRequest(f'events[{index}].summary must be a string up to 1000 characters') - cleaned_events.append({ - 'id': event_id, - 'domain': domain, - 'event_kind': event_kind, - 'date_start': start_day.isoformat(), - 'date_end': end_day.isoformat(), - 'precision': precision, - 'summary': summary.strip(), - }) - module = _load_local_module('active_rectification_events_v4') - result = module.score_life_events_v4({ - 'birth_date': birth_date, - 'start_time': start_time, - 'end_time': end_time, - 'lat': bounded_number('lat', -90, 90), - 'lon': bounded_number('lon', -180, 180), - 'tz': bounded_number('tz', -14, 14), - 'events': cleaned_events, - }) - return {'success': True, 'endpoint': 'active_rectification_events_v4', **result} def _compute_dynamic_rectification_opportunities(self, body): self._require_dynamic_rectification_token() @@ -8485,6 +8442,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/active_rectification_score': self._compute_active_rectification_score, '/api/active_rectification_events': self._compute_active_rectification_events, '/api/active_rectification_events_v4': self._compute_active_rectification_events_v4, + '/api/rectification/v5/candidate-features': self._compute_rectification_v5_candidate_features, + '/api/rectification/v5/score': self._compute_rectification_v5_score, + '/api/rectification/v5/diagnostics': self._compute_rectification_v5_diagnostics, '/api/relationship': self._compute_relationship, '/api/remedies': self._compute_remedies, '/api/sade_sati': self._compute_sade_sati, @@ -8611,6 +8571,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/rectification_gate': 'Evaluate birth-time precision gate', '/api/active_rectification_events': 'Score dated life events against actual birth-time candidates', '/api/active_rectification_events_v4': 'Score immutable dated event ranges for asynchronous V4 rectification', + '/api/rectification/v5/candidate-features': 'Scan immutable candidate static features once per calculation specification', + '/api/rectification/v5/score': 'Build the V5 event-by-candidate contribution matrix and score candidate ranges', + '/api/rectification/v5/diagnostics': 'Run V5 stability diagnostics over the server-owned contribution matrix', '/api/relationship': 'Compute relationship and spouse-status evidence', '/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha', '/api/sade_sati': 'Compute Sade Sati status and phase', @@ -8673,6 +8636,21 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/pancha_mahapurusha': {'planets': SAMPLE_PLANETS, 'sun_degree': SAMPLE_PLANETS['Sun']['lon']}, '/api/prashna': {'planets': SAMPLE_PLANETS, 'question': 'general'}, '/api/rectification_gate': {**base, 'declared_accuracy': 'minute', 'time_source': 'family_clear'}, + '/api/rectification/v5/candidate-features': { + 'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03', + 'lat': 36.419, 'lon': 114.213, 'tz': 8, + 'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}], + }, + '/api/rectification/v5/score': { + 'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03', + 'lat': 36.419, 'lon': 114.213, 'tz': 8, + 'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}], + }, + '/api/rectification/v5/diagnostics': { + 'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03', + 'lat': 36.419, 'lon': 114.213, 'tz': 8, + 'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}], + }, '/api/relationship': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries', 'dasha_info': {'maha_dasha': 'Venus', 'antar_dasha': 'Jupiter'}}, '/api/remedies': {'shadbala': {'Sun': {'rupas': 4.1}, 'Moon': {'rupas': 3.8}}, 'doshas': ['manglik'], 'dasha_lord': 'Venus'}, '/api/sade_sati': {'moon_degree': SAMPLE_PLANETS['Moon']['lon'], 'asc_degree': SAMPLE_ASCENDANT['lon'], 'saturn_degree': SAMPLE_PLANETS['Saturn']['lon']}, diff --git a/scripts/rectification/__init__.py b/scripts/rectification/__init__.py new file mode 100644 index 00000000..8a372530 --- /dev/null +++ b/scripts/rectification/__init__.py @@ -0,0 +1 @@ +"""Single source of truth for V5 birth-time rectification scoring and diagnostics.""" diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py new file mode 100644 index 00000000..f3e26664 --- /dev/null +++ b/scripts/rectification/api_service.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any +from uuid import NAMESPACE_URL, uuid5 + +from scripts.rectification.candidate_feature_service import build_candidate_feature_snapshot +from scripts.rectification.contracts import RectificationRequest +from scripts.rectification.diagnostics_service import run_diagnostics +from scripts.rectification.scoring_service import ( + ALGORITHM_VERSION, + build_event_contribution_matrix, + calculation_spec, + score_from_matrix, + sha256, +) + + +def candidate_features(request: RectificationRequest) -> dict[str, Any]: + spec = calculation_spec(request) + spec_hash = sha256(spec) + return { + "algorithm_version": ALGORITHM_VERSION, + "calculation_spec": spec, + "calculation_spec_hash": spec_hash, + "candidate_feature_snapshot": build_candidate_feature_snapshot(request, spec_hash), + "can_confirm_exact_minute": False, + } + + +def score_candidates(request: RectificationRequest) -> dict[str, Any]: + built = build_event_contribution_matrix(request) + rows = score_from_matrix(request, built) + spec = calculation_spec(request) + spec_hash = sha256(spec) + diagnostics = run_diagnostics(request, rows, built) + fingerprint = sha256(request) + return { + "result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")), + "algorithm_version": ALGORITHM_VERSION, + "calculation_spec": spec, + "calculation_spec_hash": spec_hash, + "candidate_scores": [{ + "time": row["time"], + "score": row["score"], + "supporting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] > 0], + "conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0], + } for row in rows], + "event_contribution_matrix": built["matrix"], + "candidate_feature_snapshot": build_candidate_feature_snapshot(request, spec_hash, built.get("static_contexts")), + "diagnostics": diagnostics, + "robustness": { + "neighbor_support_minutes": diagnostics["neighbor_support_minutes"], + "leave_one_out_retention_rate": diagnostics["leave_one_event_out_retention_rate"], + "leave_one_domain_out_retention_rate": diagnostics["leave_one_domain_out_retention_rate"], + "date_sensitivity_retention_rate": diagnostics["date_sensitivity_retention_rate"], + }, + "missing_layers": built["missing_layers"], + "can_confirm_exact_minute": False, + } + + +def diagnostics(request: RectificationRequest) -> dict[str, Any]: + scored = score_candidates(request) + return { + "result_id": scored["result_id"], + "algorithm_version": scored["algorithm_version"], + "calculation_spec_hash": scored["calculation_spec_hash"], + "diagnostics": scored["diagnostics"], + "missing_layers": scored["missing_layers"], + "can_confirm_exact_minute": False, + } diff --git a/scripts/rectification/candidate_feature_service.py b/scripts/rectification/candidate_feature_service.py new file mode 100644 index 00000000..d7400100 --- /dev/null +++ b/scripts/rectification/candidate_feature_service.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any, Sequence + +from scripts.active_rectification_event_engine import compute_candidate_static_contexts +from scripts.rectification.contracts import RectificationRequest +from scripts.rectification.scoring_service import ALGORITHM_VERSION, sha256 + + +def build_candidate_feature_snapshot( + request: RectificationRequest, + calculation_spec_hash: str, + static_contexts: Sequence[dict[str, Any]] | None = None, +) -> dict[str, Any]: + contexts = list(static_contexts) if static_contexts is not None else compute_candidate_static_contexts(request) + features = [context["feature"] for context in contexts] + return { + "calculation_spec_hash": calculation_spec_hash, + "algorithm_version": ALGORITHM_VERSION, + "candidate_count": len(features), + "feature_hash": sha256(features), + "features": features, + } diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py new file mode 100644 index 00000000..a2dd90ce --- /dev/null +++ b/scripts/rectification/contracts.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import math +import re +import uuid +from datetime import date +from typing import Any, Literal, NotRequired, TypedDict, cast + +DatePrecision = Literal["day", "month", "quarter", "year", "range"] + +SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = { + "education": frozenset({"education_milestone"}), + "relocation": frozenset({"relocation"}), + "relationship": frozenset({"relationship_start", "relationship_end", "relationship_change"}), + "career": frozenset({"career_change"}), + "finance": frozenset({"finance_change"}), + "health_pressure": frozenset({"self_health_event"}), +} +DATE_PRECISIONS = frozenset({"day", "month", "quarter", "year", "range"}) +_REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"}) +_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) +_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z") + + +class LifeEvent(TypedDict): + id: str + domain: str + event_kind: str + date_start: str + date_end: str + precision: DatePrecision + summary: NotRequired[str] + + +class RectificationRequest(TypedDict): + birth_date: str + start_time: str + end_time: str + lat: float + lon: float + tz: float + events: list[LifeEvent] + + +JsonObject = dict[str, Any] + + +def _bounded_number(body: dict[str, Any], name: str, minimum: float, maximum: float) -> float: + value = body.get(name) + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): + raise ValueError(f"{name} must be a finite number") + result = float(value) + if not minimum <= result <= maximum: + raise ValueError(f"{name} must be between {minimum:g} and {maximum:g}") + return result + + +def _calendar_date(value: Any, label: str) -> date: + if not isinstance(value, str): + raise ValueError(f"{label} must be a valid YYYY-MM-DD value") + try: + return date.fromisoformat(value) + except ValueError as exc: + raise ValueError(f"{label} must be a valid YYYY-MM-DD value") from exc + + +def normalize_rectification_request(body: Any, *, today: date | None = None) -> RectificationRequest: + if not isinstance(body, dict): + raise ValueError("request body must be an object") + unsupported = sorted(set(body) - _REQUEST_FIELDS) + if unsupported: + raise ValueError(f"unsupported rectification field: {unsupported[0]}") + + birth_day = _calendar_date(body.get("birth_date"), "birth_date") + start_time, end_time = body.get("start_time"), body.get("end_time") + if not isinstance(start_time, str) or not _CLOCK.fullmatch(start_time): + raise ValueError("start_time must be HH:MM") + if not isinstance(end_time, str) or not _CLOCK.fullmatch(end_time): + raise ValueError("end_time must be HH:MM") + if start_time > end_time: + raise ValueError("start_time must not exceed end_time") + + events = body.get("events") + if not isinstance(events, list) or not 1 <= len(events) <= 100: + raise ValueError("events must contain between 1 and 100 items") + upper_date = today or date.today() + cleaned_events: list[LifeEvent] = [] + for index, raw_event in enumerate(events): + if not isinstance(raw_event, dict): + raise ValueError(f"events[{index}] must be an object") + unsupported_event_fields = sorted(set(raw_event) - _EVENT_FIELDS) + if unsupported_event_fields: + raise ValueError(f"events[{index}] contains unsupported field: {unsupported_event_fields[0]}") + try: + event_id = str(uuid.UUID(str(raw_event.get("id") or ""))) + except (ValueError, AttributeError) as exc: + raise ValueError(f"events[{index}].id must be a UUID") from exc + domain, event_kind = raw_event.get("domain"), raw_event.get("event_kind") + if domain not in SCOREABLE_EVENT_KINDS: + raise ValueError(f"events[{index}].domain is not scoreable") + if event_kind not in SCOREABLE_EVENT_KINDS[cast(str, domain)]: + raise ValueError(f"events[{index}].event_kind does not match domain") + precision = raw_event.get("precision") + if precision not in DATE_PRECISIONS: + raise ValueError(f"events[{index}].precision is invalid") + start_day = _calendar_date(raw_event.get("date_start"), f"events[{index}].date_start") + end_day = _calendar_date(raw_event.get("date_end"), f"events[{index}].date_end") + if start_day > end_day: + raise ValueError(f"events[{index}].date_start must not exceed date_end") + if start_day < birth_day or end_day > upper_date: + raise ValueError(f"events[{index}] dates must be between birth_date and today") + summary = raw_event.get("summary", "") + if not isinstance(summary, str) or len(summary) > 1_000: + raise ValueError(f"events[{index}].summary must be a string up to 1000 characters") + cleaned_events.append({ + "id": event_id, + "domain": cast(str, domain), + "event_kind": cast(str, event_kind), + "date_start": start_day.isoformat(), + "date_end": end_day.isoformat(), + "precision": cast(DatePrecision, precision), + "summary": summary.strip(), + }) + + return { + "birth_date": birth_day.isoformat(), + "start_time": start_time, + "end_time": end_time, + "lat": _bounded_number(body, "lat", -90, 90), + "lon": _bounded_number(body, "lon", -180, 180), + "tz": _bounded_number(body, "tz", -14, 14), + "events": cleaned_events, + } diff --git a/scripts/rectification/diagnostics_service.py b/scripts/rectification/diagnostics_service.py new file mode 100644 index 00000000..f80aed88 --- /dev/null +++ b/scripts/rectification/diagnostics_service.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections import defaultdict +from statistics import variance +from typing import Any, Sequence + +from scripts.active_rectification_events import CandidateScoreRow +from scripts.rectification.contracts import RectificationRequest + + +def _winner(rows: Sequence[CandidateScoreRow]) -> str | None: + return max(rows, key=lambda row: row["score"])["time"] if rows else None + + +def _primary_cluster(rows: Sequence[CandidateScoreRow], relative_floor: float = .97) -> list[str]: + if not rows: + return [] + peak = max(row["score"] for row in rows) + floor = peak * relative_floor if peak >= 0 else peak / relative_floor + selected = [row["time"] for row in rows if row["score"] >= floor] + if not selected: + return [] + groups: list[list[str]] = [] + for current in selected: + minute = lambda value: int(value[:2]) * 60 + int(value[3:]) + if groups and minute(current) - minute(groups[-1][-1]) == 1: + groups[-1].append(current) + else: + groups.append([current]) + return max(groups, key=lambda group: (max(next(row["score"] for row in rows if row["time"] == time) for time in group), len(group))) + + +def _subtract(rows: Sequence[CandidateScoreRow], removed_ids: set[str]) -> list[CandidateScoreRow]: + return [{**row, "score": round(row["score"] - sum(item["points"] for item in row["evidence"] if item["event_id"] in removed_ids), 4)} for row in rows] + + +def run_diagnostics(request: RectificationRequest, rows: list[CandidateScoreRow], built: dict[str, Any]) -> dict[str, Any]: + primary = set(_primary_cluster(rows)) + event_runs = [] + domain_runs = [] + event_domain = {event["id"]: event["domain"] for event in request["events"]} + for event in request["events"]: + winner = _winner(_subtract(rows, {event["id"]})) + event_runs.append({"removed_event_id": event["id"], "winner": winner, "retained": winner in primary}) + by_domain: dict[str, set[str]] = defaultdict(set) + for event_id, domain in event_domain.items(): + by_domain[domain].add(event_id) + for domain, event_ids in by_domain.items(): + winner = _winner(_subtract(rows, event_ids)) + domain_runs.append({"removed_domain": domain, "winner": winner, "retained": winner in primary}) + top = sorted(rows, key=lambda row: row["score"], reverse=True) + top_score = top[0]["score"] if top else 0 + secondary = next((row for row in top if row["time"] not in primary), None) + margin = 0 if not secondary else max(0, (top_score - secondary["score"]) / max(abs(top_score), 1e-9) * 100) + positive_total = sum(max(row["score"], 0) for row in rows) + primary_mass = sum(max(row["score"], 0) for row in rows if row["time"] in primary) + date_items = [] + for item in built["date_sensitivity"]: + date_items.append({ + **{key: value for key, value in item.items() if key != "sample_winners"}, + "candidate_cluster_retention_rate": sum(winner in primary for winner in item["sample_winners"]) / len(item["sample_winners"]), + }) + layers: dict[str, float] = defaultdict(float) + for event_id, candidates in built["matrix"].items(): + for contribution in candidates.values(): + for layer in contribution["technique_layers"]: + layers[layer] += abs(contribution["points"]) + clusters = [_primary_cluster(rows)] + candidate_splits = [] + if secondary and clusters[0]: + candidate_splits.append({ + "left_cluster": {"start": clusters[0][0], "end": clusters[0][-1]}, + "right_cluster": {"start": secondary["time"], "end": secondary["time"]}, + "technique_layers": [name for name, _ in sorted(layers.items(), key=lambda item: item[1], reverse=True)[:8]], + "event_ids": [item["event_id"] for item in secondary["evidence"] if item["points"] != 0], + }) + return { + "primary_cluster_retention_rate": 1.0 if primary else 0.0, + "leave_one_event_out_retention_rate": sum(item["retained"] for item in event_runs) / len(event_runs) if event_runs else 0.0, + "leave_one_domain_out_retention_rate": sum(item["retained"] for item in domain_runs) / len(domain_runs) if domain_runs else 0.0, + "date_sensitivity_retention_rate": sum(item["candidate_cluster_retention_rate"] for item in date_items) / len(date_items) if date_items else 0.0, + "neighbor_support_minutes": len(primary), + "primary_secondary_margin_percent": round(min(margin, 100), 4), + "cluster_mass_ratio": primary_mass / positive_total if positive_total else 0.0, + "unstable_event_ids": [item["removed_event_id"] for item in event_runs if not item["retained"]], + "most_discriminating_layers": [name for name, _ in sorted(layers.items(), key=lambda item: item[1], reverse=True)[:12]], + "event_date_sensitivity": date_items, + "candidate_splits": candidate_splits, + "leave_one_event_out": event_runs, + "leave_one_domain_out": domain_runs, + } diff --git a/scripts/rectification/scoring_service.py b/scripts/rectification/scoring_service.py new file mode 100644 index 00000000..6f119573 --- /dev/null +++ b/scripts/rectification/scoring_service.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import hashlib +import json +from collections import defaultdict +from datetime import date, timedelta +from functools import lru_cache +from typing import Any, Callable, Sequence + +from scripts.active_rectification_event_engine import compute_candidate_static_contexts, compute_event_candidate_rows +from scripts.active_rectification_events import CandidateScoreRow +from scripts.rectification.contracts import LifeEvent, RectificationRequest + +ALGORITHM_VERSION = "rectification-v5-matrix-scoring-1" +INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4" + + +def _parse(value: str) -> date: + return date.fromisoformat(value) + + +def _iso(value: date) -> str: + return value.isoformat() + + +def _month_end(value: date) -> date: + next_month = value.replace(day=28) + timedelta(days=4) + return next_month - timedelta(days=next_month.day) + + +def _even_dates(start: date, end: date, count: int) -> list[date]: + if count <= 1 or start == end: + return [start] + span = (end - start).days + return sorted({start + timedelta(days=round(span * index / (count - 1))) for index in range(count)}) + + +def sample_event_dates(event: LifeEvent) -> list[str]: + start, end = _parse(event["date_start"]), _parse(event["date_end"]) + precision = event["precision"] + if start > end: + raise ValueError("invalid_event_date_range") + if precision == "day" or start == end: + return [_iso(start)] + if precision == "month": + middle = start.replace(day=min(15, _month_end(start).day)) + return sorted({_iso(start), _iso(middle), _iso(end)}) + if precision == "quarter": + values: list[date] = [] + cursor = start.replace(day=15) + while cursor <= end and len(values) < 3: + values.append(cursor) + cursor = (cursor.replace(day=28) + timedelta(days=4)).replace(day=15) + return [_iso(item) for item in values] or [_iso(start)] + if precision == "year": + return [_iso(start.replace(month=month, day=15)) for month in range(1, 13)] + return [_iso(item) for item in _even_dates(start, end, 12)] + + +def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_date: str) -> dict[str, Any]: + return { + "birth_date": request["birth_date"], + "start_time": request["start_time"], + "end_time": request["end_time"], + "lat": request["lat"], + "lon": request["lon"], + "tz": request["tz"], + "events": [{ + "id": event["id"], "domain": event["domain"], "date": sampled_date, + "precision": "day", "summary": event.get("summary", ""), + }], + } + + +def _canonical(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + +@lru_cache(maxsize=4096) +def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]: + return tuple(compute_event_candidate_rows(json.loads(serialized))) + + +def build_event_contribution_matrix( + request: RectificationRequest, + row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None, +) -> dict[str, Any]: + static_contexts = None if row_provider is not None else compute_candidate_static_contexts(request) + provider = row_provider or (lambda value: compute_event_candidate_rows(value, static_contexts=static_contexts)) + matrix: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) + missing_layers: set[str] = set() + date_sensitivity: list[dict[str, Any]] = [] + candidate_grid: list[str] | None = None + for event in request["events"]: + samples = sample_event_dates(event) + sample_rows = [list(provider(_legacy_request(request, event, sampled))) for sampled in samples] + grids = [[row["time"] for row in rows] for rows in sample_rows] + if any(grid != grids[0] for grid in grids[1:]) or (candidate_grid is not None and grids[0] != candidate_grid): + raise ValueError("candidate_grid_mismatch") + candidate_grid = grids[0] + winners = [] + for rows in sample_rows: + winners.append(max(rows, key=lambda row: row["score"])["time"]) + missing_layers.update(layer for row in rows for layer in row["missing_layers"]) + for index, candidate_time in enumerate(candidate_grid): + evidences = [rows[index]["evidence"][0] for rows in sample_rows] + points = [float(item["points"]) for item in evidences] + matrix[event["id"]][candidate_time] = { + "points": round(sum(points) / len(points), 4), + "rule_ids": sorted({rule for item in evidences for rule in item["rule_ids"]}), + "technique_layers": sorted({rule.split(":", 1)[0] for item in evidences for rule in item["rule_ids"]}), + } + winner = max(set(winners), key=winners.count) + mean = sum(matrix[event["id"]][time]["points"] for time in candidate_grid) / len(candidate_grid) + variance = sum((matrix[event["id"]][time]["points"] - mean) ** 2 for time in candidate_grid) / len(candidate_grid) + date_sensitivity.append({ + "event_id": event["id"], + "declared_date_range": {"start": event["date_start"], "end": event["date_end"], "precision": event["precision"]}, + "sample_dates": samples, + "winner_retention_rate": winners.count(winner) / len(winners), + "score_variance": round(variance, 6), + "sample_winners": winners, + }) + return { + "candidate_times": candidate_grid or [], + "matrix": dict(matrix), + "date_sensitivity": date_sensitivity, + "missing_layers": sorted(missing_layers), + "static_contexts": static_contexts, + } + + +def score_from_matrix(request: RectificationRequest, built: dict[str, Any]) -> list[CandidateScoreRow]: + rows: list[CandidateScoreRow] = [] + for candidate_time in built["candidate_times"]: + evidence = [] + for event in request["events"]: + contribution = built["matrix"][event["id"]][candidate_time] + evidence.append({ + "event_id": event["id"], "domain": event["domain"], "candidate_time": candidate_time, + "rule_ids": contribution["rule_ids"], "points": contribution["points"], + }) + rows.append({ + "time": candidate_time, + "score": round(sum(item["points"] for item in evidence), 4), + "evidence": evidence, + "missing_layers": built["missing_layers"], + }) + return rows + + +def calculation_spec(request: RectificationRequest) -> dict[str, Any]: + return { + "version": INPUT_CONTRACT_VERSION, + "birthDate": request["birth_date"], + "candidateRange": {"start": request["start_time"], "end": request["end_time"]}, + "latitude": request["lat"], "longitude": request["lon"], "timezoneOffsetHours": request["tz"], + "ayanamsa": "lahiri", "nodeMode": "mean", "minuteStep": 1, + } + + +def sha256(value: Any) -> str: + return hashlib.sha256(_canonical(value).encode()).hexdigest() diff --git a/skills/birth-time-rectification/SKILL.md b/skills/birth-time-rectification/SKILL.md new file mode 100644 index 00000000..68b08205 --- /dev/null +++ b/skills/birth-time-rectification/SKILL.md @@ -0,0 +1,36 @@ +--- +name: birth-time-rectification +description: Evidence-led birth-time rectification for the Web agent. Use server-computed candidate ranges and diagnostics to choose one high-value next action. Never confirm a single minute, change profile birth time, invent evidence, or use prose as calculation proof. +--- + +# Birth-time rectification + +This is a constrained evidence workflow, not a generic astrology reading. + +Before choosing an action, read the contracts in `references/` and use +`assets/rectification-capability-matrix.json` only as a capability boundary. + +## Hard boundaries + +- The server owns candidate scanning, scores, diagnostics, event IDs, and policy gates. +- The agent may select one server-provided opportunity or request one server-provided diagnostic. +- Never invent candidate times, scores, event IDs, dates, techniques, or tool inputs. +- Never confirm a single minute or write `profiles.active_birth_time`. +- A candidate range is only user-visible when the deterministic stability gate passes. +- Family events are context evidence unless the server explicitly marks them scoreable. + +## Turn strategy + +1. Acknowledge the concrete experience the user just supplied. +2. Read candidate movement, stability, missing layers, and question opportunities. +3. Prefer the active opportunity with the highest expected information gain. +4. Ask one natural question only. +5. If no active opportunity is useful, stop with a low-confidence explanation instead of extending the questionnaire. + +## Layer priority + +Use the server's available layers only. Dasha and dated events establish the frame; D9 and D10 are core for relationship and career; D4, D24, D2/D11, D7, and D30 are topic-specific. D60 is reference-only and must never drive a conclusion. + +## Public language + +Explain whether the latest evidence moved or supported the current candidate range. Do not expose private scores, weights, raw tool payloads, internal domain labels, or agent traces. diff --git a/skills/birth-time-rectification/assets/rectification-capability-matrix.json b/skills/birth-time-rectification/assets/rectification-capability-matrix.json new file mode 100644 index 00000000..77d7b521 --- /dev/null +++ b/skills/birth-time-rectification/assets/rectification-capability-matrix.json @@ -0,0 +1,11 @@ +{ + "education": { "primary": ["D24", "Dasha"], "scoreableByDefault": true }, + "relocation": { "primary": ["D4", "Dasha"], "scoreableByDefault": true }, + "relationship": { "primary": ["D9", "UL", "A7", "Dasha"], "scoreableByDefault": true }, + "career": { "primary": ["D10", "A10", "Dasha"], "scoreableByDefault": true }, + "finance": { "primary": ["D2", "D11", "Dasha"], "scoreableByDefault": true }, + "family": { "primary": ["D12"], "scoreableByDefault": false }, + "children": { "primary": ["D7"], "scoreableByDefault": false }, + "health_pressure": { "primary": ["D30"], "scoreableByDefault": true }, + "D60": { "primary": ["D60"], "scoreableByDefault": false } +} diff --git a/skills/birth-time-rectification/references/event-schema.md b/skills/birth-time-rectification/references/event-schema.md new file mode 100644 index 00000000..bdb6038a --- /dev/null +++ b/skills/birth-time-rectification/references/event-schema.md @@ -0,0 +1,3 @@ +# Event schema + +Keep event subject, related person, event kind, date precision, extraction status, correction lineage, and scoreability. A family bereavement is a family context event, not the user's health event. diff --git a/skills/birth-time-rectification/references/failure-policy.md b/skills/birth-time-rectification/references/failure-policy.md new file mode 100644 index 00000000..e14b46af --- /dev/null +++ b/skills/birth-time-rectification/references/failure-policy.md @@ -0,0 +1,3 @@ +# Failure policy + +On invalid model output, unavailable tools, or a failed policy gate, use the deterministic fallback and record the failure. Do not fabricate a next question or candidate result. diff --git a/skills/birth-time-rectification/references/output-contract.md b/skills/birth-time-rectification/references/output-contract.md new file mode 100644 index 00000000..95e57758 --- /dev/null +++ b/skills/birth-time-rectification/references/output-contract.md @@ -0,0 +1,3 @@ +# Output contract + +Public output contains an acknowledgement, a concise calculation update grounded in the packet, and at most one question. It never contains a single-minute conclusion or private scores. diff --git a/skills/birth-time-rectification/references/product-contract.md b/skills/birth-time-rectification/references/product-contract.md new file mode 100644 index 00000000..32041ad4 --- /dev/null +++ b/skills/birth-time-rectification/references/product-contract.md @@ -0,0 +1,3 @@ +# Product contract + +The product returns a candidate range, not a verified birth minute. Existing profile birth time remains unchanged until the user explicitly saves an allowed candidate range through the product flow. diff --git a/skills/birth-time-rectification/references/question-policy.md b/skills/birth-time-rectification/references/question-policy.md new file mode 100644 index 00000000..fa92f57a --- /dev/null +++ b/skills/birth-time-rectification/references/question-policy.md @@ -0,0 +1,3 @@ +# Question policy + +Choose one active server opportunity. Prefer date sensitivity, candidate-split relevance, and new domain coverage over recency or fixed domain order. Do not repeat a resolved follow-up. diff --git a/skills/birth-time-rectification/references/technique-policy.md b/skills/birth-time-rectification/references/technique-policy.md new file mode 100644 index 00000000..08d891f1 --- /dev/null +++ b/skills/birth-time-rectification/references/technique-policy.md @@ -0,0 +1,3 @@ +# Technique policy + +Only server-reported available layers may be described as used. Missing, blocked, reference-only, and research-only layers are not evidence of a result. diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py new file mode 100644 index 00000000..76bd1224 --- /dev/null +++ b/tests/test_rectification_v5_services.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import unittest +from datetime import date +from unittest.mock import patch + +from scripts.rectification.api_service import diagnostics, score_candidates +from scripts.rectification.contracts import normalize_rectification_request +from scripts.rectification.scoring_service import build_event_contribution_matrix, sample_event_dates, score_from_matrix +from scripts.jyotish_api_server import ( + API_COMMAND_MAP, + TECHNIQUE_EXAMPLE_ENDPOINTS, + BadRequest, + JyotishAPIHandler, +) + +EVENT_ID = "00000000-0000-4000-8000-000000000001" + + +def request(*, precision: str = "month", event_kind: str = "education_milestone", domain: str = "education"): + return { + "birth_date": "1997-08-08", + "start_time": "05:13", + "end_time": "05:15", + "lat": 36.419, + "lon": 114.213, + "tz": 8, + "events": [{ + "id": EVENT_ID, + "domain": domain, + "event_kind": event_kind, + "date_start": "2016-09-01", + "date_end": "2016-09-30", + "precision": precision, + "summary": "大学入学", + }], + } + + +class RectificationV5ServicesTest(unittest.TestCase): + def test_shared_validator_rejects_family_and_non_self_health_scoring(self): + with self.assertRaisesRegex(ValueError, "domain is not scoreable"): + normalize_rectification_request(request(domain="family", event_kind="family_bereavement"), today=date(2026, 7, 28)) + with self.assertRaisesRegex(ValueError, "event_kind does not match domain"): + normalize_rectification_request(request(domain="health_pressure", event_kind="family_health_event"), today=date(2026, 7, 28)) + normalized = normalize_rectification_request(request(domain="health_pressure", event_kind="self_health_event"), today=date(2026, 7, 28)) + self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event") + + def test_date_sampling_preserves_declared_range_and_uses_bounded_samples(self): + base = request()["events"][0] + self.assertEqual(sample_event_dates({**base, "precision": "month"}), ["2016-09-01", "2016-09-15", "2016-09-30"]) + year = {**base, "precision": "year", "date_start": "2016-01-01", "date_end": "2016-12-31"} + self.assertEqual(len(sample_event_dates(year)), 12) + ranged = {**base, "precision": "range", "date_start": "2015-01-01", "date_end": "2016-12-31"} + self.assertLessEqual(len(sample_event_dates(ranged)), 12) + + def test_contribution_matrix_and_leave_out_diagnostics_use_matrix_math(self): + normalized = normalize_rectification_request(request(), today=date(2026, 7, 28)) + + def rows(value): + sampled = value["events"][0]["date"] + shift = {"2016-09-01": 0, "2016-09-15": 1, "2016-09-30": 2}[sampled] + return [{ + "time": candidate, + "score": points + shift, + "evidence": [{ + "event_id": EVENT_ID, + "domain": "education", + "candidate_time": candidate, + "rule_ids": ["D24:test"], + "points": points + shift, + }], + "missing_layers": ["KP_cusps"], + } for candidate, points in [("05:13", 9), ("05:14", 10), ("05:15", 8)]] + + built = build_event_contribution_matrix(normalized, row_provider=rows) + scored = score_from_matrix(normalized, built) + self.assertEqual(built["matrix"][EVENT_ID]["05:14"]["points"], 11) + self.assertEqual(scored[1]["score"], 11) + self.assertEqual(built["missing_layers"], ["KP_cusps"]) + + def test_formal_score_and_diagnostics_endpoints_share_the_service_bundle(self): + normalized = normalize_rectification_request(request(), today=date(2026, 7, 28)) + built = { + "candidate_times": ["05:13", "05:14"], + "matrix": {EVENT_ID: { + "05:13": {"points": 10, "rule_ids": ["D24:a"], "technique_layers": ["D24"]}, + "05:14": {"points": 8, "rule_ids": ["D24:b"], "technique_layers": ["D24"]}, + }}, + "date_sensitivity": [{ + "event_id": EVENT_ID, + "declared_date_range": {"start": "2016-09-01", "end": "2016-09-30", "precision": "month"}, + "sample_dates": ["2016-09-01", "2016-09-15", "2016-09-30"], + "winner_retention_rate": 1, + "score_variance": 1, + "sample_winners": ["05:13", "05:13", "05:13"], + }], + "missing_layers": ["KP_cusps"], + "static_contexts": [{"feature": {"time": "05:13"}}, {"feature": {"time": "05:14"}}], + } + feature = { + "calculation_spec_hash": "0" * 64, + "algorithm_version": "rectification-v5-matrix-scoring-1", + "candidate_count": 2, + "feature_hash": "1" * 64, + "features": [{"time": "05:13"}, {"time": "05:14"}], + } + with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch( + "scripts.rectification.api_service.build_candidate_feature_snapshot", return_value=feature + ): + scored = score_candidates(normalized) + diagnostic_result = diagnostics(normalized) + self.assertFalse(scored["can_confirm_exact_minute"]) + self.assertIn("event_contribution_matrix", scored) + self.assertEqual(diagnostic_result["diagnostics"]["leave_one_event_out_retention_rate"], 1) + self.assertFalse(diagnostic_result["can_confirm_exact_minute"]) + + def test_http_registry_exposes_all_v5_endpoints(self): + expected = { + "rectification-v5-candidate-features": "/api/rectification/v5/candidate-features", + "rectification-v5-score": "/api/rectification/v5/score", + "rectification-v5-diagnostics": "/api/rectification/v5/diagnostics", + } + for command, endpoint in expected.items(): + self.assertEqual(API_COMMAND_MAP[command], endpoint) + self.assertIn(endpoint, TECHNIQUE_EXAMPLE_ENDPOINTS) + + def test_http_handler_enforces_subject_and_event_kind_boundaries(self): + handler = object.__new__(JyotishAPIHandler) + with self.assertRaisesRegex(BadRequest, "domain is not scoreable"): + handler._rectification_v5_request(request(domain="family", event_kind="family_bereavement")) + with self.assertRaisesRegex(BadRequest, "event_kind does not match domain"): + handler._rectification_v5_request(request(domain="health_pressure", event_kind="family_health_event")) + normalized = handler._rectification_v5_request( + request(domain="health_pressure", event_kind="self_health_event") + ) + self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event") + + def test_v4_compatibility_and_v5_score_handlers_share_the_v5_service(self): + handler = object.__new__(JyotishAPIHandler) + result = {"result_id": "00000000-0000-4000-8000-000000000099", "can_confirm_exact_minute": False} + with patch("scripts.rectification.api_service.score_candidates", return_value=result) as scorer: + v5 = handler._compute_rectification_v5_score(request()) + v4 = handler._compute_active_rectification_events_v4(request()) + self.assertEqual(scorer.call_count, 2) + self.assertEqual(v5["endpoint"], "rectification_v5_score") + self.assertEqual(v4["endpoint"], "active_rectification_events_v4") + self.assertEqual(v5["result_id"], v4["result_id"]) + self.assertFalse(v5["can_confirm_exact_minute"]) + self.assertFalse(v4["can_confirm_exact_minute"]) + + +if __name__ == "__main__": + unittest.main()