diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6401bd99..104899ea 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1786,3 +1786,19 @@ - 防复发:历史 migration 文件不得从受审仓库删除;若必须兼容已遗失记录,只能用代码审查过的静态 filename + checksum,并保留 fail-closed 测试。 - 相关记录:BUG-099 - 修复版本:staging migration integrity compatibility + +## BUG-101 | 正常访谈被 Opportunity 模板与 Renderer 回退主导,事件种类在 Python bridge 丢失 + +- 状态:resolved +- 首次发现:2026-07-30 +- 最近更新:2026-07-30 +- 影响面:V5 Agent 生时纠正常访谈、事件修订暂存、公开问题生成、Python 候选评分语义 +- 用户现象:模型虽然参与推理,但下一步焦点、问题类别和公开回复仍主要由服务器硬编码的 Builder、Opportunity 分类与正则 Renderer 决定;同一轮难以自然识别多件事件,关系开始、结束或变化进入 Python 评分后又退化为通用 relationship 领域。 +- 触发条件:正常 `v5_agent` 路径依次调用 `buildQuestionOpportunities()`、`runBoundedReasoner()` 与 `renderPublicTurn()`;事件通过 TypeScript/Python bridge 时只传 `domain`,没有保留 canonical `event_kind`。 +- 根因:Agent 只在服务器预先枚举的机会中选择,无法基于完整 Case Dossier 自主理解当前访谈焦点并生成下一句;公开回复失败时继续由领域正则模板接管。与此同时 Python legacy request 把领域值当作事件种类,抹平关系事件的 start/end/change 语义。 +- 修复:新增 Director 两阶段合同:服务器提供完整 Case Dossier,Director 可在一轮提出多个 create/revise evidence proposal;服务器验证原文、日期、目标和 opaque ID 后生成 revisions 并重算评分/诊断,Director 再自主选择焦点并直接生成自然问题与公开回复。服务器继续控制 status、phase、snapshot/range gate、内部 ID、精确分钟与单问题安全边界;输出失败只允许同一 Director 做一次安全 repair,再进入通用 fallback。`v5_shadow` 保留 legacy 可见投影与确定性证据行为,只持久化 Director artifacts。Python bridge 与评分 trace 继续传递 canonical `event_kind`,并为 relationship start/end/change 保留可验证的最小区分。 +- 验证:TypeScript 相关套件 114/114 通过,其中 Director 专项 7/7;TypeScript `tsc --noEmit` 与修改文件 ESLint 通过;Python `tests/test_active_rectification_events.py` 14/14 通过。 +- 安全边界:模型不得公开内部 ID、分数、贡献矩阵、工具信息或精确分钟;proposal 必须引用最新回答中的原文与日期文本,所有持久化 revision、候选重算、门禁判断和原子提交仍由服务器拥有。 +- 防复发:正常 Agent 路径不得重新依赖 Opportunity 枚举或领域正则决定访谈内容;Director 合同测试必须覆盖多事件提议、修订目标验证、拒绝/不知道后的换焦点、range gate、内部信息泄露与一次 repair;Python 测试必须断言 `event_kind` 从输入穿透到规则 trace。 +- 相关记录:BUG-095、BUG-097、BUG-099 +- 修复版本:staging diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts index 569a390c..be955e23 100644 --- a/frontend/src/lib/rectification-agent/contracts.ts +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -1,12 +1,12 @@ import { z } from "zod"; -import { clockTimeSchema, evidenceDomainSchema, rectificationAnalysisTraceSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts"; +import { clockTimeSchema, eventKindSchema, eventSubjectSchema, evidenceDomainSchema, relatedPersonSchema, rectificationAnalysisTraceSchema, 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 CURRENT_RECTIFICATION_SKILL_VERSION = "birth-time-rectification-v6" as const; -export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-agent-v6-1" as const; +export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-director-v1" as const; export const rectificationDiagnosticSchema = z.enum([ "leave_one_event_out", @@ -17,12 +17,144 @@ export const rectificationDiagnosticSchema = z.enum([ ]); export type RectificationDiagnostic = z.infer; -export const rectificationDecisionSchema = z.discriminatedUnion("action", [ +export const targetDispositionSchema = z.enum([ + "resolved", + "unknown", + "declined", + "direction_change", + "answered_other_event", + "unresolved", + "not_applicable", +]); + +export const evidenceProposalSchema = z.object({ + operation: z.enum(["create", "revise", "ignore"]), + targetEventId: uuid.nullable(), + sourceSpan: nonblank(4_000), + dateText: nonblank(80).nullable(), + proposedSummary: nonblank(1_000), + proposedDomain: evidenceDomainSchema, + proposedEventKind: eventKindSchema, + proposedSubject: eventSubjectSchema, + proposedRelatedPerson: relatedPersonSchema.nullable(), + confidence: z.enum(["high", "medium", "low"]), +}).strict(); +export type EvidenceProposal = z.infer; + +export const rectificationFocusSchema = z.object({ + mode: z.enum([ + "clarify_existing_event", + "collect_independent_event", + "pair_related_event", + "resolve_conflict", + "distinguish_candidate_clusters", + ]), + targetEventId: uuid.nullable(), + domain: evidenceDomainSchema.nullable(), + requestedFacts: z.array(z.enum([ + "year", + "month", + "day_or_period", + "subject", + "event_type", + "event_stage", + "independent_event", + "paired_event", + ])).max(3), + rationaleCodes: z.array(nonblank(80)).max(8), +}).strict(); +export type RectificationFocus = z.infer; + +const directorActionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("ask_question"), + focus: rectificationFocusSchema, + question: nonblank(240), + optionalQuickReplies: z.array(z.object({ label: nonblank(40), value: nonblank(120) }).strict()).max(4), + }).strict(), + z.object({ type: z.literal("request_diagnostic"), diagnostic: rectificationDiagnosticSchema }).strict(), + z.object({ type: z.literal("offer_candidate_range"), snapshotId: uuid }).strict(), + z.object({ type: z.literal("stop_low_confidence"), reasonCodes: z.array(nonblank(80)).min(1).max(8) }).strict(), +]); + +export const rectificationTurnPlanSchema = z.object({ + contractVersion: z.literal("rectification-turn-plan-v1"), + targetDisposition: targetDispositionSchema, + evidenceProposals: z.array(evidenceProposalSchema).max(8), + action: directorActionSchema, + publicReply: z.object({ + acknowledgement: nonblank(1_000), + candidateCommentary: nonblank(1_000).nullable(), + limitation: nonblank(1_000).nullable(), + }).strict(), +}).strict(); +export type RectificationTurnPlan = z.infer; + +export const rectificationCaseDossierSchema = z.object({ + case: z.object({ + candidateWindow: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(), + birthDate: nonblank(10), + location: z.object({ + latitude: z.number().finite(), + longitude: z.number().finite(), + timezoneId: z.string().nullable(), + timezoneOffsetHours: z.number().finite(), + }).strict(), + birthTimeSource: z.string().nullable(), + algorithmVersion: nonblank(120), + }).strict(), + conversation: z.object({ + recentRawTurns: z.array(z.object({ question: z.string(), answer: z.string() }).strict()).max(12), + earlierConversationSummary: z.string().nullable(), + }).strict(), + eventLedger: z.array(z.object({ + eventId: uuid, + revision: z.number().int().positive(), + summary: nonblank(1_000), + rawText: nonblank(4_000), + domain: evidenceDomainSchema, + eventKind: eventKindSchema, + subject: eventSubjectSchema, + relatedPerson: relatedPersonSchema.nullable(), + dateRange: z.object({ start: nonblank(10), end: nonblank(10), precision: nonblank(20), label: nonblank(80) }).strict(), + scoreability: nonblank(40), + status: z.enum(["active", "superseded", "pending"]), + }).strict()), + interviewState: z.object({ + currentTargetEventId: uuid.nullable(), + declinedDomains: z.array(evidenceDomainSchema), + unresolvedTargets: z.array(uuid), + askedTopics: z.array(z.string()).max(50), + turnCount: z.number().int().nonnegative(), + targetDisposition: targetDispositionSchema, + }).strict(), + candidateState: z.object({ + hasSnapshot: z.boolean(), + publicRangeAllowed: z.boolean(), + rangeChanged: z.boolean(), + topClusters: z.array(z.object({ rank: z.number().int(), widthMinutes: z.number().int(), stability: z.enum(["stable", "unstable"]) }).strict()).max(4), + contrasts: z.array(z.object({ techniqueLayers: z.array(z.string()), relevantEventIds: z.array(uuid) }).strict()).max(8), + eventDiagnostics: z.array(z.object({ eventId: uuid, winnerRetentionRate: z.number(), scoreVariance: z.number() }).strict()).max(100), + gateReasons: z.array(z.string()).max(20), + currentSnapshotId: uuid.nullable(), + }).strict(), + capabilities: z.object({ + supportedDomains: z.array(evidenceDomainSchema), + supportedEventKinds: z.array(eventKindSchema), + maxQuestionsPerTurn: z.literal(1), + maxDiagnosticsPerRun: z.number().int().min(0).max(2), + forbiddenPublicClaims: z.array(z.string()), + }).strict(), +}).strict(); +export type RectificationCaseDossier = z.infer; + +export const rectificationDecisionSchema = z.union([ 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("ask_question"), focus: rectificationFocusSchema, question: nonblank(240) }).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(), @@ -336,7 +468,7 @@ export function validateRectificationDecision(input: Readonly<{ 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") { + if (decision.action === "ask_question" && "opportunityId" in decision) { 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"); diff --git a/frontend/src/lib/rectification-agent/director-agent.ts b/frontend/src/lib/rectification-agent/director-agent.ts new file mode 100644 index 00000000..b172cda3 --- /dev/null +++ b/frontend/src/lib/rectification-agent/director-agent.ts @@ -0,0 +1,166 @@ +import path from "node:path"; +import { Agent } from "@mastra/core/agent"; +import { z } from "zod"; +import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; +import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts"; +import type { TargetDisposition } from "../rectification-v4/extraction.ts"; +import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace } from "./contracts.ts"; + +const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification"); +const domains: EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family", "other"]; +const kinds: EventKind[] = ["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 privatePattern = /(?:[0-9a-f]{8}-[0-9a-f-]{27,}|opportunity(?:id)?|snapshot(?:id)?|event(?:id)?|targetEventId|score|评分|得分|权重|rule[_ -]?id|贡献矩阵|tool[_ -]?call|cluster[_ -]?id)/iu; +const exactMinutePattern = /(?:\b(?:[01]?\d|2[0-3]):[0-5]\d\b|(?:凌晨|清晨|上午|中午|下午|傍晚|晚上)?\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*[点时]\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*分)/u; +type Generated = Readonly<{ object: unknown; totalUsage?: { inputTokens?: number; outputTokens?: number } | Promise<{ inputTokens?: number; outputTokens?: number }> }>; +const regeneratedQuestionSchema = z.object({ question: z.string().trim().min(8).max(500) }).strict(); +export type RectificationDirectorGenerator = (prompt: string, phase: "evidence" | "final" | "after_diagnostic" | "repair") => Promise; + +function diagnosticResult(kind: RectificationDiagnostic, value: DiagnosticsSummary) { + switch (kind) { + case "leave_one_event_out": return { retentionRate: value.leaveOneEventOutRetentionRate, unstableEventIds: value.unstableEventIds }; + case "leave_one_domain_out": return { retentionRate: value.leaveOneDomainOutRetentionRate }; + case "date_sensitivity": return { retentionRate: value.dateSensitivityRetentionRate, events: value.eventDateSensitivity }; + case "neighbor_stability": return { supportMinutes: value.neighborSupportMinutes, clusterMassRatio: value.clusterMassRatio }; + case "candidate_split": return { marginPercent: value.primarySecondaryMarginPercent, splits: value.candidateSplits }; + } +} + +export function buildRectificationCaseDossier(input: Readonly<{ caseValue: RectificationV4Case; turns: readonly RectificationV4Turn[]; events: readonly LifeEventRevision[]; pendingEvidence?: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; previousSnapshot?: CandidateSnapshot | null; diagnostics: DiagnosticsSummary | null; targetDisposition: TargetDisposition; currentTargetEventId: string | null }>): RectificationCaseDossier { + const latest = new Map(); + input.events.forEach((event) => latest.set(event.eventId, Math.max(latest.get(event.eventId) ?? 0, event.revision))); + const recent = input.turns.slice(-12); + return rectificationCaseDossierSchema.parse({ + case: { candidateWindow: input.caseValue.calculationSpec.candidateRange, birthDate: input.caseValue.calculationSpec.birthDate, location: { latitude: input.caseValue.calculationSpec.latitude, longitude: input.caseValue.calculationSpec.longitude, timezoneId: input.caseValue.calculationSpec.timezoneId ?? null, timezoneOffsetHours: input.caseValue.calculationSpec.timezoneOffsetHours }, birthTimeSource: input.caseValue.calculationSpec.birthTimeSource ?? null, algorithmVersion: input.caseValue.algorithmVersion }, + conversation: { recentRawTurns: recent.map(({ question, answer }) => ({ question, answer })), earlierConversationSummary: input.turns.length > 12 ? `更早还有 ${input.turns.length - 12} 轮;完整事实以事件账本为准。` : null }, + eventLedger: input.events.map((event) => ({ eventId: event.eventId, revision: event.revision, summary: event.summary, rawText: event.rawText, domain: event.domain, eventKind: event.eventKind, subject: event.subject, relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, status: latest.get(event.eventId) === event.revision ? "active" : "superseded" })), + interviewState: { currentTargetEventId: input.currentTargetEventId, declinedDomains: [], unresolvedTargets: [...new Set([...(input.currentTargetEventId && ["unresolved", "answered_other_event"].includes(input.targetDisposition) ? [input.currentTargetEventId] : []), ...(input.pendingEvidence ?? []).flatMap((item) => item.targetEventId ? [item.targetEventId] : [])])], askedTopics: input.turns.slice(-50).map((turn) => turn.question), turnCount: input.turns.length, targetDisposition: input.targetDisposition }, + candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed: input.snapshot?.canAcceptRange ?? false, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: input.snapshot?.canAcceptRange ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null }, + capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxDiagnosticsPerRun: 1, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] }, + }); +} + +function fallback(dossier: RectificationCaseDossier, latestAnswer: string): RectificationTurnPlan { + if (dossier.candidateState.publicRangeAllowed && dossier.candidateState.currentSnapshotId) return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "offer_candidate_range", snapshotId: dossier.candidateState.currentSnapshotId }, publicReply: { acknowledgement: "现有事件已经完成本轮复核。", candidateCommentary: "候选范围已通过当前稳定性门槛,可以作为工作范围查看。", limitation: "这仍不是对某个精确出生分钟的确认。" } }; + const keepTarget = Boolean(dossier.interviewState.currentTargetEventId && ["unresolved", "answered_other_event"].includes(dossier.interviewState.targetDisposition)); + const latestGroundedEvent = [...dossier.eventLedger].reverse().find((event) => event.status === "active" && (event.rawText === latestAnswer || latestAnswer.includes(event.summary))); + const safeSummary = latestGroundedEvent && !privatePattern.test(latestGroundedEvent.summary) && !exactMinutePattern.test(latestGroundedEvent.summary) + ? latestGroundedEvent.summary.slice(0, 120) + : null; + return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "ask_question", focus: { mode: keepTarget ? "clarify_existing_event" : "collect_independent_event", targetEventId: keepTarget ? dossier.interviewState.currentTargetEventId : null, domain: null, requestedFacts: keepTarget ? ["day_or_period"] : ["independent_event", "year"], rationaleCodes: [keepTarget ? "unresolved_current_event" : "need_independent_dated_event"] }, question: keepTarget ? "关于刚才那件事,你还记得它大约发生在哪一年或哪个阶段吗?" : "你还能想到一件发生在你本人身上、时间大致确定的重要经历吗?", optionalQuickReplies: [] }, publicReply: { acknowledgement: safeSummary ? `你提到的“${safeSummary}”已经纳入本轮事件线索。` : latestAnswer.trim() ? "我已按你刚才的描述继续整理事件线索。" : "我们先从真实经历建立事件线索。", candidateCommentary: null, limitation: "在证据通过稳定性门槛前,我不会把某个具体分钟当成确定出生时间。" } }; +} + +export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final" }>): Readonly<{ plan: RectificationTurnPlan | null; issues: readonly string[] }> { + const parsed = rectificationTurnPlanSchema.safeParse(input.plan); + if (!parsed.success) return { plan: null, issues: ["turn_plan_schema_invalid"] }; + const plan = parsed.data; + const issues: string[] = []; + const known = new Set(input.dossier.eventLedger.map((event) => event.eventId)); + plan.evidenceProposals.forEach((proposal) => { + if (!input.latestAnswer.includes(proposal.sourceSpan)) issues.push("evidence_source_not_in_latest_answer"); + if (proposal.dateText && !input.latestAnswer.includes(proposal.dateText)) issues.push("evidence_date_not_in_latest_answer"); + if (proposal.operation === "create" && proposal.targetEventId) issues.push("create_must_not_target_event"); + if (proposal.operation === "revise" && (!proposal.targetEventId || !known.has(proposal.targetEventId))) issues.push("revision_target_invalid"); + }); + const currentTarget = input.dossier.interviewState.currentTargetEventId; + if (input.phase === "final") { + if (plan.evidenceProposals.length) issues.push("final_plan_contains_evidence"); + if (plan.targetDisposition !== input.dossier.interviewState.targetDisposition) issues.push("final_target_disposition_changed"); + } else if (!currentTarget && plan.targetDisposition !== "not_applicable") { + issues.push("target_disposition_requires_target"); + } else if (currentTarget) { + const revisedCurrentTarget = plan.evidenceProposals.some((proposal) => proposal.operation === "revise" && proposal.targetEventId === currentTarget); + const createdOtherEvent = plan.evidenceProposals.some((proposal) => proposal.operation === "create"); + if (plan.targetDisposition === "not_applicable") issues.push("target_disposition_missing"); + if (plan.targetDisposition === "resolved" && !revisedCurrentTarget) issues.push("resolved_target_not_revised"); + if (plan.targetDisposition === "answered_other_event" && !createdOtherEvent) issues.push("other_event_not_proposed"); + } + const publicText = [plan.publicReply.acknowledgement, plan.publicReply.candidateCommentary, plan.publicReply.limitation, plan.action.type === "ask_question" ? plan.action.question : null].filter(Boolean).join(" "); + if (privatePattern.test(publicText)) issues.push("private_detail_exposed"); + if (exactMinutePattern.test(publicText)) issues.push("exact_minute_claimed"); + if (plan.action.type === "ask_question") { + if ((plan.action.question.match(/[??]/g) ?? []).length > 1) issues.push("multiple_questions"); + if (plan.action.focus.targetEventId && !known.has(plan.action.focus.targetEventId)) issues.push("focus_target_invalid"); + if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition) && plan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId) issues.push("declined_target_reopened"); + } + if (plan.action.type === "offer_candidate_range" && (!input.dossier.candidateState.publicRangeAllowed || plan.action.snapshotId !== input.dossier.candidateState.currentSnapshotId)) issues.push("candidate_range_gate_failed"); + return { plan: issues.length ? null : plan, issues }; +} + +export async function regenerateDirectorQuestion(input: Readonly<{ + caseValue: RectificationV4Case; + currentQuestion: string; + latestAnswer: string; + acceptedEvents: readonly LifeEventRevision[]; + focus: Extract["focus"]; + generateQuestion?: (prompt: string, phase: "regenerate" | "repair") => Promise; +}>): Promise { + const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel(); + const agent = model ? new Agent({ id: `rectification-director-regenerate-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Rewrite one natural interview question while preserving the supplied structured focus. Do not expose internal ids, scores, tools, or a birth minute. Return only structured output." }) : null; + const generate = input.generateQuestion ?? (async (prompt: string) => { + if (!agent) throw new Error("director_model_unavailable"); + return agent.generate(prompt, { structuredOutput: { schema: regeneratedQuestionSchema, jsonPromptInjection: "inline" } }); + }); + const validate = (value: unknown) => { + const parsed = regeneratedQuestionSchema.safeParse(value); + if (!parsed.success) return { question: null, issues: ["question_schema_invalid"] }; + const issues: string[] = []; + if ((parsed.data.question.match(/[??]/g) ?? []).length > 1) issues.push("multiple_questions"); + if (privatePattern.test(parsed.data.question)) issues.push("private_detail_exposed"); + if (exactMinutePattern.test(parsed.data.question)) issues.push("exact_minute_claimed"); + return { question: issues.length ? null : parsed.data.question, issues }; + }; + try { + const context = { task: "Rewrite the current question without changing its structured focus.", currentQuestion: input.currentQuestion, latestAnswer: input.latestAnswer, focus: input.focus, acceptedEvents: input.acceptedEvents.map(({ summary, domain, eventKind, dateRange }) => ({ summary, domain, eventKind, dateRange })) }; + let result = validate((await generate(JSON.stringify(context), "regenerate")).object); + if (!result.question) result = validate((await generate(JSON.stringify({ ...context, task: "Repair the rejected rewrite once.", validationIssues: result.issues }), "repair")).object); + return result.question ?? input.currentQuestion; + } catch { + return input.currentQuestion; + } +} + +export async function runRectificationDirector(input: Readonly<{ caseValue: RectificationV4Case; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final"; diagnostics: DiagnosticsSummary; timeoutMs?: number; generatePlan?: RectificationDirectorGenerator }>) { + const started = Date.now(); + const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel(); + const agent = model ? new Agent({ id: `rectification-director-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Direct the interview from the complete dossier. Propose every explicit event in the latest answer, choose the current focus, and write the public reply plus at most one natural question. Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output." }) : null; + const generate = input.generatePlan ?? (async (prompt: string) => { + if (!agent) throw new Error("director_model_unavailable"); + return agent.generate(prompt, { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 25_000), structuredOutput: { schema: rectificationTurnPlanSchema, jsonPromptInjection: "inline" } }); + }); + let inputTokens = 0, outputTokens = 0; + let usageObserved = false; + const toolCalls: ToolCallTrace[] = []; + const addUsage = async (generated: Generated) => { + if (!generated.totalUsage) return; + const usage = await generated.totalUsage; + inputTokens += Math.max(0, Math.trunc(usage.inputTokens ?? 0)); + outputTokens += Math.max(0, Math.trunc(usage.outputTokens ?? 0)); + usageObserved = true; + }; + try { + const first = await generate(JSON.stringify({ task: input.phase === "evidence" ? "Interpret the latest answer and propose every explicit event. The action is provisional." : "Choose the final action and public response. evidenceProposals must be empty because staging is complete.", latestAnswer: input.latestAnswer, dossier: input.dossier }), input.phase); + await addUsage(first); + let candidate = rectificationTurnPlanSchema.parse(first.object); + if (input.phase === "final" && candidate.action.type === "request_diagnostic") { + const toolStarted = Date.now(); + const result = diagnosticResult(candidate.action.diagnostic, input.diagnostics); + toolCalls.push({ tool: "run_rectification_diagnostics", diagnostic: candidate.action.diagnostic, outcome: "succeeded", durationMs: Date.now() - toolStarted, errorCode: null }); + const second = await generate(JSON.stringify({ task: "Use the diagnostic result and return a final non-diagnostic action with no evidence proposals.", latestAnswer: input.latestAnswer, dossier: input.dossier, diagnosticResult: result }), "after_diagnostic"); + await addUsage(second); + candidate = rectificationTurnPlanSchema.parse(second.object); + } + if (input.phase === "final" && candidate.action.type === "request_diagnostic") throw new Error("director_final_plan_not_final"); + let validated = validateRectificationTurnPlan({ plan: candidate, dossier: input.dossier, latestAnswer: input.latestAnswer, phase: input.phase }); + if (!validated.plan) { + const repaired = await generate(JSON.stringify({ task: "Repair the rejected plan once. Preserve grounded facts, return one safe final plan, and address every validation issue.", latestAnswer: input.latestAnswer, dossier: input.dossier, rejectedPlan: candidate, validationIssues: validated.issues }), "repair"); + await addUsage(repaired); + candidate = rectificationTurnPlanSchema.parse(repaired.object); + if (candidate.action.type === "request_diagnostic") throw new Error("director_repair_requested_diagnostic"); + validated = validateRectificationTurnPlan({ plan: candidate, dossier: input.dossier, latestAnswer: input.latestAnswer, phase: input.phase }); + } + if (!validated.plan) throw new Error(`director_plan_rejected:${validated.issues.join(",")}`); + return { plan: validated.plan, mode: "agent" as const, fallbackReason: null, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started }; + } catch (error) { + return { plan: fallback(input.dossier, input.latestAnswer), mode: "deterministic_fallback" as const, fallbackReason: error instanceof Error ? error.message.slice(0, 120) : "director_failed", toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started }; + } +} diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index f5976541..b01d89c0 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -3,7 +3,8 @@ import type { CandidateEngineResult, RectificationV4CandidateEngine } from "../r import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.ts"; import type { CandidateSnapshot, RectificationAnalysisTrace, RectificationV4Question } from "../rectification-v4/contracts.ts"; import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts"; -import { reconcileV4Evidence } from "../rectification-v4/extraction.ts"; +import { reconcileV4Evidence, stageAgentEvidenceProposals } from "../rectification-v4/extraction.ts"; +import { buildRectificationCaseDossier, runRectificationDirector } from "./director-agent.ts"; import { extractEventWithModel } from "./event-extractor-agent.ts"; import { evidenceSetHash } from "../rectification-v4/fingerprints.ts"; import { latestEventRevisions, scoreableEvents } from "../rectification-v4/evidence-ledger.ts"; @@ -11,7 +12,6 @@ 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 { @@ -144,36 +144,51 @@ export async function processRectificationAgentTurn(input: Readonly<{ }; await enterPhase("extracting_evidence"); const asOfDate = now.toISOString().slice(0, 10); - let reconciliation = claimed.turn.answer ? reconcileV4Evidence({ - caseId: claimed.case.id, - answer: claimed.turn.answer, - sourceTurnId: claimed.turn.id, - asOfDate, - existing: claimed.events, - targetEventId: claimed.turn.questionTargetEventId, - now, - }) : { revisions: [], pending: [], unansweredTargetEventId: null, targetDisposition: "not_applicable" as const }; - const needsAssistance = claimed.case.deploymentMode !== "v4_legacy" && ( + const provisionalDisposition = claimed.turn.questionTargetEventId ? "unresolved" as const : "not_applicable" as const; + const provisionalDiagnostics = diagnosticsSummarySchema.parse({ + id: randomUUID(), caseId: claimed.case.id, snapshotId: claimed.case.latestSnapshot?.id ?? randomUUID(), + primaryClusterRetentionRate: 0, leaveOneEventOutRetentionRate: 0, leaveOneDomainOutRetentionRate: 0, + dateSensitivityRetentionRate: 0, neighborSupportMinutes: 0, primarySecondaryMarginPercent: 0, + clusterMassRatio: 0, unstableEventIds: [], mostDiscriminatingLayers: [], eventDateSensitivity: [], + candidateSplits: [], calculationHash: hash(claimed.events), createdAt: now.toISOString(), + }); + let evidenceDirector: Awaited> | null = null; + let reconciliation; + if (claimed.case.deploymentMode !== "v4_legacy" && claimed.turn.answer) { + const dossier = buildRectificationCaseDossier({ + caseValue: claimed.case, turns: claimed.turns, events: claimed.events, snapshot: claimed.case.latestSnapshot, + previousSnapshot: claimed.case.latestSnapshot, diagnostics: null, targetDisposition: provisionalDisposition, + currentTargetEventId: claimed.turn.questionTargetEventId, + }); + evidenceDirector = await runRectificationDirector({ + caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "evidence", diagnostics: provisionalDiagnostics, + }); + reconciliation = claimed.case.deploymentMode === "v5_agent" && evidenceDirector.mode === "agent" && evidenceDirector.plan.evidenceProposals.length + ? stageAgentEvidenceProposals({ caseId: claimed.case.id, rawText: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, proposals: evidenceDirector.plan.evidenceProposals, now }) + : reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now }); + if (claimed.case.deploymentMode === "v5_agent" && evidenceDirector.mode === "agent") { + const proposedDisposition = evidenceDirector.plan.targetDisposition; + const currentTarget = claimed.turn.questionTargetEventId; + const revisedCurrentTarget = Boolean(currentTarget && reconciliation.revisions.some((event) => event.eventId === currentTarget)); + const addedOtherEvent = reconciliation.revisions.some((event) => event.eventId !== currentTarget); + const stagedDispositionIsValid = proposedDisposition !== "resolved" && proposedDisposition !== "answered_other_event" + || proposedDisposition === "resolved" && revisedCurrentTarget + || proposedDisposition === "answered_other_event" && addedOtherEvent; + if (stagedDispositionIsValid) reconciliation = { ...reconciliation, targetDisposition: proposedDisposition }; + } + } else { + reconciliation = claimed.turn.answer ? reconcileV4Evidence({ + caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, + existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now, + }) : { revisions: [], pending: [], unansweredTargetEventId: null, targetDisposition: "not_applicable" as const }; + } + const needsAssistance = claimed.case.deploymentMode === "v4_legacy" && ( reconciliation.pending.some((event) => event.reasonCode === "event_unparsed") || reconciliation.revisions.some((event) => event.scoreability === "pending_review" || event.scoreability === "unsupported") ); if (needsAssistance) { - const assisted = await extractEventWithModel({ - rawText: claimed.turn.answer, - sourceTurnId: claimed.turn.id, - asOfDate, - modelId: claimed.case.orchestrationModelId, - }); - if (assisted) reconciliation = reconcileV4Evidence({ - caseId: claimed.case.id, - answer: claimed.turn.answer, - sourceTurnId: claimed.turn.id, - asOfDate, - existing: claimed.events, - targetEventId: claimed.turn.questionTargetEventId, - assistedEvidence: [assisted], - now, - }); + const assisted = await extractEventWithModel({ rawText: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, modelId: claimed.case.orchestrationModelId }); + if (assisted) reconciliation = reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, assistedEvidence: [assisted], now }); } const extracted = reconciliation.revisions; const events = latestEventRevisions([...claimed.events, ...extracted]); @@ -336,6 +351,95 @@ export async function processRectificationAgentTurn(input: Readonly<{ createdAt: now.toISOString(), }); + if (claimed.case.deploymentMode !== "v4_legacy") { + await enterPhase("planning_question"); + const dossier = buildRectificationCaseDossier({ + caseValue: claimed.case, turns: claimed.turns, events, pendingEvidence: reconciliation.pending, + snapshot, previousSnapshot: claimed.case.latestSnapshot, diagnostics, + targetDisposition: reconciliation.targetDisposition, currentTargetEventId: claimed.turn.questionTargetEventId, + }); + await enterPhase("reasoning"); + const directed = await runRectificationDirector({ + caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "final", diagnostics: safeDiagnostics, + }); + const plan = directed.plan; + const action = plan.action; + if (action.type === "request_diagnostic") { + throw new Error("rectification_director_diagnostic_loop_incomplete"); + } + const decision = action.type === "ask_question" + ? { action: "ask_question" as const, focus: action.focus, question: action.question } + : action.type === "offer_candidate_range" + ? { action: "offer_candidate_range" as const, snapshotId: action.snapshotId } + : { action: "stop_low_confidence" as const, reasonCodes: action.reasonCodes }; + const validatedDecision: ValidatedDecision = { + decision, + mode: directed.mode, + validationIssues: directed.fallbackReason ? [directed.fallbackReason] : [], + selectedOpportunity: null, + }; + await enterPhase("rendering"); + finishPhase(); + for (const call of directed.toolCalls) analysisToolCalls.push({ + category: "agent_diagnostic", label: call.diagnostic ? diagnosticLabels[call.diagnostic] : "只读诊断", + outcome: call.outcome, durationMs: call.durationMs, + }); + const publicMessage: StoredPublicMessage = { + acknowledgement: plan.publicReply.acknowledgement, + candidateUpdate: plan.publicReply.candidateCommentary, + limitation: plan.publicReply.limitation, + question: action.type === "ask_question" ? action.question : null, + analysisTrace: { + status: "completed", stages, toolCalls: analysisToolCalls, techniques: publicRectificationTechniques(engineResult), + reasoningSummary: null, reasoningSource: "none", + }, + }; + const targetEvent = action.type === "ask_question" && action.focus.targetEventId + ? events.find((event) => event.eventId === action.focus.targetEventId) ?? null + : null; + const nextQuestion: RectificationV4Question | null = action.type === "ask_question" ? { + id: randomUUID(), + domain: action.focus.domain ?? targetEvent?.domain ?? "other", + targetEventId: action.focus.targetEventId, + prompt: action.question, + recallCost: "medium", + reason: action.focus.rationaleCodes.join(",").slice(0, 240) || "agent_directed_focus", + } : null; + const status = action.type === "offer_candidate_range" ? "range_ready" as const + : action.type === "stop_low_confidence" ? "paused" as const + : "awaiting_answer" as const; + const totalInput = [evidenceDirector?.inputTokenCount, directed.inputTokenCount].filter((value): value is number => value !== null && value !== undefined).reduce((sum, value) => sum + value, 0); + const totalOutput = [evidenceDirector?.outputTokenCount, directed.outputTokenCount].filter((value): value is number => value !== null && value !== undefined).reduce((sum, value) => sum + value, 0); + const fallbackReason = [evidenceDirector?.fallbackReason, directed.fallbackReason].filter(Boolean).join(";").slice(0, 120) || null; + 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, validatedDecision, toolCalls: [...directed.toolCalls], fallbackReason, + inputTokenCount: totalInput || null, outputTokenCount: totalOutput || null, + latencyMs: Math.min(300_000, (evidenceDirector?.latencyMs ?? 0) + directed.latencyMs), createdAt: now.toISOString(), + }; + if (claimed.case.deploymentMode === "v5_shadow") { + const legacy = projectLegacyV4Turn({ + events, + newEvents: extracted, + attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, + latestAnswer: claimed.turn.answer, + snapshot, + }); + return { + newEventRevisions: extracted, pendingEvidence: [...reconciliation.pending], snapshot, diagnostics, featureSnapshot, + validatedDecision, publicMessage: { ...legacy.publicMessage, analysisTrace: publicMessage.analysisTrace }, + nextQuestion: legacy.nextQuestion, agentRun, status: legacy.status, phase: legacy.phase, + }; + } + return { + newEventRevisions: extracted, pendingEvidence: [...reconciliation.pending], snapshot, diagnostics, featureSnapshot, + validatedDecision, publicMessage, nextQuestion, agentRun, status, + phase: status === "awaiting_answer" ? "collecting_evidence" as const : "complete" as const, + }; + } + await enterPhase("planning_question"); const opportunities = buildQuestionOpportunities({ caseId: claimed.case.id, @@ -394,7 +498,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ } const finalDecision = validation.decision; if (!finalDecision) throw new Error("rectification_v5_fallback_validation_failed"); - const selectedOpportunity = finalDecision.action === "ask_question" + const selectedOpportunity = finalDecision.action === "ask_question" && "opportunityId" in finalDecision ? opportunities.find((item) => item.opportunityId === finalDecision.opportunityId) ?? null : null; const validatedDecision: ValidatedDecision = { @@ -412,37 +516,8 @@ export async function processRectificationAgentTurn(input: Readonly<{ latestAnswer: claimed.turn.answer, snapshot, }); - const agentVisible = claimed.case.deploymentMode === "v5_agent"; - let rendererRealization: "model_validated" | "server_fallback" | null = null; - let rendererFallbackReason: "model_unavailable" | "question_rejected" | "model_failed" | null = null; - const renderedMessage = agentVisible - ? await renderPublicTurn({ - caseValue: claimed.case, - latestAnswer: claimed.turn.answer, - acceptedEvents: extracted, - pendingEvidence: reconciliation.pending, - snapshot, - previousSnapshot: claimed.case.latestSnapshot, - validated: validatedDecision, - onRealization: (outcome) => { - rendererRealization = outcome.mode; - rendererFallbackReason = outcome.reason; - }, - }) - : legacyProjection.publicMessage; + const renderedMessage = legacyProjection.publicMessage; finishPhase(); - if (agentVisible && rendererRealization) { - stages.push({ - phase: "rendering", - label: rendererRealization === "model_validated" - ? "自然语言问题已通过安全校验" - : rendererFallbackReason === "question_rejected" - ? "模型问题未通过安全校验,已使用服务器安全问题" - : "模型回复不可用,已使用服务器安全问题", - status: "completed", - durationMs: null, - }); - } for (const call of reasoned.toolCalls) { analysisToolCalls.push({ category: "agent_diagnostic", @@ -453,7 +528,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ } const reasoningSummary = reasoned.mode === "agent" && !fallbackReason ? reasoned.reasoningSummary : null; const analysisTrace: RectificationAnalysisTrace = { - status: claimed.case.deploymentMode === "v4_legacy" ? "legacy" : "completed", + status: "legacy", stages, toolCalls: analysisToolCalls, techniques: publicRectificationTechniques(engineResult), @@ -461,28 +536,9 @@ export async function processRectificationAgentTurn(input: Readonly<{ reasoningSource: reasoningSummary ? "provider_summary" : "none", }; const publicMessage: StoredPublicMessage = { ...renderedMessage, analysisTrace }; - const nextQuestion = agentVisible && selectedOpportunity ? { - id: randomUUID(), - domain: selectedOpportunity.domain, - targetEventId: selectedOpportunity.targetEventId, - prompt: publicMessage.question ?? selectedOpportunity.fallbackPrompt, - 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 nextQuestion = legacyProjection.nextQuestion; + const status = legacyProjection.status; + const phase = legacyProjection.phase; const agentRun: AgentRun = { id: randomUUID(), diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index b187c891..732b27b0 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -8,6 +8,7 @@ import type { import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts"; import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts"; import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts"; +import { regenerateDirectorQuestion } from "../rectification-agent/director-agent.ts"; import { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts"; import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; import { openingQuestion } from "./opening-question.ts"; @@ -20,10 +21,12 @@ export function createRectificationV4CaseService( options: { readonly now?: () => Date; readonly regenerateQuestion?: typeof regenerateQuestionRealization; + readonly regenerateDirectorQuestion?: typeof regenerateDirectorQuestion; } = {}, ) { const now = options.now ?? (() => new Date()); const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization; + const redirectQuestion = options.regenerateDirectorQuestion ?? regenerateDirectorQuestion; async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise { const [events, turns, analysis, job] = await Promise.all([ @@ -123,19 +126,30 @@ export function createRectificationV4CaseService( const current = await store.loadCase(input.userId, input.caseId); if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null; const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId); - const opportunity = validated?.selectedOpportunity; - if (!opportunity) return null; + if (!validated || validated.decision.action !== "ask_question") return null; const [events, turns] = await Promise.all([ store.loadEvents(input.userId, input.caseId), store.loadTurns(input.userId, input.caseId), ]); - const prompt = await realizeQuestion({ - caseValue: current, - currentPrompt: current.currentQuestion.prompt, - latestAnswer: turns.at(-1)?.answer ?? "", - acceptedEvents: events, - opportunity, - }); + let prompt: string; + if (validated.selectedOpportunity) { + prompt = await realizeQuestion({ + caseValue: current, + currentPrompt: current.currentQuestion.prompt, + latestAnswer: turns.at(-1)?.answer ?? "", + acceptedEvents: events, + opportunity: validated.selectedOpportunity, + }); + } else { + if (!("focus" in validated.decision)) return null; + prompt = await redirectQuestion({ + caseValue: current, + currentQuestion: current.currentQuestion.prompt, + latestAnswer: turns.at(-1)?.answer ?? "", + acceptedEvents: events, + focus: validated.decision.focus, + }); + } return store.replaceCurrentQuestion({ ...input, question: { ...current.currentQuestion, id: randomUUID(), prompt }, diff --git a/frontend/src/lib/rectification-v4/extraction.ts b/frontend/src/lib/rectification-v4/extraction.ts index 998df44d..0ce1e874 100644 --- a/frontend/src/lib/rectification-v4/extraction.ts +++ b/frontend/src/lib/rectification-v4/extraction.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { extractLifeEventEvidence, type ExtractedLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts"; +import { extractLifeEventEvidence, parseDeclaredDateText, validatedModelAssistedEvidence, type ExtractedLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts"; +import type { EvidenceProposal } from "../rectification-agent/contracts.ts"; import type { EventKind, EvidenceDomain, @@ -215,3 +216,72 @@ export function reconcileV4Evidence(input: { export function extractV4EventRevisions(input: Omit[0], "caseId"> & { readonly caseId?: string }): readonly LifeEventRevision[] { return reconcileV4Evidence({ ...input, caseId: input.caseId ?? "00000000-0000-4000-8000-000000000000" }).revisions; } + + +export function stageAgentEvidenceProposals(input: Readonly<{ + caseId: string; + rawText: string; + sourceTurnId: string; + asOfDate: string; + existing: readonly LifeEventRevision[]; + proposals: readonly EvidenceProposal[]; + now?: Date; +}>): ReconciledV4Evidence { + const revisions: LifeEventRevision[] = []; + const pending: PendingEvidence[] = []; + const active = latestEventRevisions(input.existing); + for (const proposal of input.proposals) { + if (proposal.operation === "ignore") continue; + if (!input.rawText.includes(proposal.sourceSpan) || !proposal.dateText || !input.rawText.includes(proposal.dateText)) { + pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: proposal.dateText ? "event_unparsed" : "date_unresolved", targetEventId: proposal.targetEventId, now: input.now })); + continue; + } + const extracted = validatedModelAssistedEvidence({ + rawText: input.rawText, + sourceTurnId: input.sourceTurnId, + asOfDate: input.asOfDate, + extraction: { + sourceSpan: proposal.sourceSpan, + summary: proposal.proposedSummary, + domain: proposal.proposedDomain, + eventKind: proposal.proposedEventKind, + subject: proposal.proposedSubject, + relatedPerson: proposal.proposedRelatedPerson, + dateText: proposal.dateText, + }, + }); + if (!extracted?.dateValue || extracted.datePrecision === "unknown") { + pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: "event_unparsed", targetEventId: proposal.targetEventId, now: input.now })); + continue; + } + if (proposal.operation === "create") { + const revision = newRevision(extracted, [...input.existing, ...revisions], input.now); + if (revision && !revisions.some((value) => value.eventId === revision.eventId)) revisions.push(revision); + continue; + } + const target = proposal.targetEventId ? active.find((event) => event.eventId === proposal.targetEventId) : null; + const parsedDate = parseDeclaredDateText(proposal.dateText.normalize("NFKC"), input.asOfDate); + if (!target || !parsedDate) { + pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: "event_unparsed", targetEventId: proposal.targetEventId, now: input.now })); + continue; + } + revisions.push(appendEventRevision([...input.existing, ...revisions], { + eventId: target.eventId, + domain: extracted.domain as EvidenceDomain, + eventKind: normalizeKind(extracted.domain as EvidenceDomain, extracted.eventKind, extracted.eventSummary), + subject: extracted.subject as EventSubject, + relatedPerson: extracted.relatedPerson as RelatedPerson | null, + summary: proposal.proposedSummary, + rawText: input.rawText, + dateRange: dateRangeFromDeclared(parsedDate.value, parsedDate.precision), + ...eventDateProvenance(target), + scoreability: extracted.scoreability as Scoreability, + }, { now: input.now })); + } + return { + revisions, + pending, + unansweredTargetEventId: null, + targetDisposition: revisions.length ? "resolved" : "unresolved", + }; +} diff --git a/frontend/tests/rectification-analysis-trace.test.ts b/frontend/tests/rectification-analysis-trace.test.ts index 38fd7f72..e8fde3fb 100644 --- a/frontend/tests/rectification-analysis-trace.test.ts +++ b/frontend/tests/rectification-analysis-trace.test.ts @@ -253,7 +253,10 @@ test("below the scoring gate does not claim candidate scanning, diagnostics, or assert.equal(result.snapshot, null); assert.equal(trace.stages.some((stage) => stage.phase === "scoring_candidates"), false); assert.equal(trace.stages.some((stage) => stage.phase === "checking_robustness"), false); - assert.equal(trace.stages.some((stage) => /安全校验|服务器安全问题/.test(stage.label)), true); + assert.equal( + trace.stages.some((stage) => stage.phase === "rendering" && stage.status === "completed"), + true, + ); assert.deepEqual(trace.toolCalls, []); assert.deepEqual(trace.techniques, []); }); diff --git a/frontend/tests/rectification-director.test.ts b/frontend/tests/rectification-director.test.ts new file mode 100644 index 00000000..91958bbc --- /dev/null +++ b/frontend/tests/rectification-director.test.ts @@ -0,0 +1,273 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { diagnosticsSummarySchema, type RectificationTurnPlan } from "../src/lib/rectification-agent/contracts.ts"; +import { buildRectificationCaseDossier, regenerateDirectorQuestion, runRectificationDirector, validateRectificationTurnPlan } from "../src/lib/rectification-agent/director-agent.ts"; +import type { CalculationSpec, LifeEventRevision, RectificationV4Case, RectificationV4Turn } from "../src/lib/rectification-v4/contracts.ts"; +import { stageAgentEvidenceProposals } from "../src/lib/rectification-v4/extraction.ts"; +import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts"; + +const now = "2026-07-30T00:00:00.000Z"; +const caseId = "00000000-0000-4000-8000-000000000701"; +const spec: CalculationSpec = { + version: "rectification-calculation-spec-v4", + birthDate: "1993-04-17", + candidateRange: { start: "05:00", end: "06:00" }, + latitude: 36.683333, + longitude: 114.35, + timezoneId: "Asia/Shanghai", + timezoneOffsetHours: 8, + birthTimeSource: "approximate", + ayanamsa: "lahiri", + nodeMode: "mean", + minuteStep: 1, +}; +const caseValue: RectificationV4Case = { + id: caseId, + userId: "00000000-0000-4000-8000-000000000702", + protocol: "rectification-evidence-v5", + version: 3, + status: "processing", + phase: "reasoning", + calculationSpec: spec, + calculationSpecHash: calculationSpecHash(spec), + evidenceSetHash: "e".repeat(64), + currentQuestion: null, + latestSnapshot: null, + orchestrationModelId: null, + narrationModelId: null, + skillVersion: "birth-time-rectification-v6", + promptVersion: "rectification-director-v1", + algorithmVersion: "rectification-v5-matrix-scoring-1", + deploymentMode: "v5_agent", + agentMode: "agent", + featureSnapshotId: null, + latestDiagnosticsId: null, + 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年9月大学入学", + rawText: "2016年9月大学入学", + dateRange: { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "2016年9月" }, + scoreability: "scoreable", + supersedesRevisionId: null, + createdAt: now, + ...overrides, + }; +} + +function turn(index: number): RectificationV4Turn { + return { + id: randomUUID(), + caseId, + caseVersion: index + 1, + questionId: null, + questionDomain: null, + questionTargetEventId: null, + question: `问题${index}`, + answer: `回答${index}`, + modelId: null, + actionId: randomUUID(), + createdAt: now, + }; +} + +function plan(overrides: Partial = {}): RectificationTurnPlan { + return { + contractVersion: "rectification-turn-plan-v1", + targetDisposition: "not_applicable", + evidenceProposals: [], + action: { + type: "ask_question", + focus: { + mode: "collect_independent_event", + targetEventId: null, + domain: null, + requestedFacts: ["independent_event"], + rationaleCodes: ["need_independent_event"], + }, + question: "接下来想从哪段变化继续聊?", + optionalQuickReplies: [], + }, + publicReply: { + acknowledgement: "我已按你的描述整理这轮线索。", + candidateCommentary: null, + limitation: "目前仍不足以确认具体出生分钟。", + }, + ...overrides, + }; +} + +function dossier(events: readonly LifeEventRevision[] = [], turns: readonly RectificationV4Turn[] = []) { + return buildRectificationCaseDossier({ + caseValue, + turns, + events, + snapshot: null, + diagnostics: null, + targetDisposition: "not_applicable", + currentTargetEventId: null, + }); +} + +const diagnostics = diagnosticsSummarySchema.parse({ + id: "00000000-0000-4000-8000-000000000703", + caseId, + snapshotId: "00000000-0000-4000-8000-000000000704", + primaryClusterRetentionRate: 0.8, + leaveOneEventOutRetentionRate: 0.8, + leaveOneDomainOutRetentionRate: 0.8, + dateSensitivityRetentionRate: 0.8, + neighborSupportMinutes: 3, + primarySecondaryMarginPercent: 8, + clusterMassRatio: 0.6, + unstableEventIds: [], + mostDiscriminatingLayers: ["D9"], + eventDateSensitivity: [], + candidateSplits: [], + calculationHash: "d".repeat(64), + createdAt: now, +}); + +test("dossier keeps twelve raw turns and the complete revision ledger", () => { + const sharedEventId = randomUUID(); + const events = Array.from({ length: 15 }, (_, index) => event({ + eventId: index < 2 ? sharedEventId : randomUUID(), + revision: index < 2 ? index + 1 : 1, + supersedesRevisionId: index === 1 ? randomUUID() : null, + summary: `事件${index}`, + rawText: `事件${index}`, + })); + const value = dossier(events, Array.from({ length: 14 }, (_, index) => turn(index))); + assert.equal(value.conversation.recentRawTurns.length, 12); + assert.equal(value.conversation.recentRawTurns[0]?.question, "问题2"); + assert.equal(value.eventLedger.length, 15); + assert.equal(value.eventLedger[0]?.status, "superseded"); + assert.equal(value.eventLedger[1]?.status, "active"); + assert.equal(value.case.location.timezoneId, "Asia/Shanghai"); +}); + +test("a natural question and multiple grounded event proposals pass without domain keywords or anchors", () => { + const latestAnswer = "2018年9月搬到北京,2020年4月开始第一份工作。"; + const value = plan({ + evidenceProposals: [ + { operation: "create", targetEventId: null, sourceSpan: "2018年9月搬到北京", dateText: "2018年9月", proposedSummary: "搬到北京", proposedDomain: "relocation", proposedEventKind: "relocation", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }, + { operation: "create", targetEventId: null, sourceSpan: "2020年4月开始第一份工作", dateText: "2020年4月", proposedSummary: "开始第一份工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }, + ], + }); + assert.deepEqual(validateRectificationTurnPlan({ plan: value, dossier: dossier(), latestAnswer, phase: "evidence" }).issues, []); + const staged = stageAgentEvidenceProposals({ caseId, rawText: latestAnswer, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [], proposals: value.evidenceProposals, now: new Date(now) }); + assert.equal(staged.revisions.length, 2); + assert.deepEqual(new Set(staged.revisions.map((item) => item.domain)), new Set(["relocation", "career"])); +}); + +test("server rejects invented sources, private details, exact minutes, and ungated ranges", () => { + const latestAnswer = "2018年9月搬到北京。"; + const invented = plan({ evidenceProposals: [{ operation: "create", targetEventId: null, sourceSpan: "2020年工作", dateText: "2020年", proposedSummary: "开始工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "low" }] }); + assert.ok(validateRectificationTurnPlan({ plan: invented, dossier: dossier(), latestAnswer, phase: "evidence" }).issues.includes("evidence_source_not_in_latest_answer")); + + const unsafe = plan({ publicReply: { acknowledgement: "内部 eventId 是 00000000-0000-4000-8000-000000000799。", candidateCommentary: "出生时间是05:13。", limitation: null } }); + const unsafeIssues = validateRectificationTurnPlan({ plan: unsafe, dossier: dossier(), latestAnswer, phase: "final" }).issues; + assert.ok(unsafeIssues.includes("private_detail_exposed")); + assert.ok(unsafeIssues.includes("exact_minute_claimed")); + + const range = plan({ action: { type: "offer_candidate_range", snapshotId: "00000000-0000-4000-8000-000000000704" } }); + assert.ok(validateRectificationTurnPlan({ plan: range, dossier: dossier(), latestAnswer, phase: "final" }).issues.includes("candidate_range_gate_failed")); +}); + +test("revisions keep the server-owned event id and append revision history", () => { + const target = event({ eventId: "00000000-0000-4000-8000-000000000705" }); + const rawText = "其实是2016年10月大学入学。"; + const staged = stageAgentEvidenceProposals({ + caseId, + rawText, + sourceTurnId: randomUUID(), + asOfDate: "2026-07-30", + existing: [target], + proposals: [{ operation: "revise", targetEventId: target.eventId, sourceSpan: "2016年10月大学入学", dateText: "2016年10月", proposedSummary: "2016年10月大学入学", proposedDomain: "education", proposedEventKind: "education_milestone", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }], + now: new Date(now), + }); + assert.equal(staged.revisions.length, 1); + assert.equal(staged.revisions[0]?.eventId, target.eventId); + assert.equal(staged.revisions[0]?.revision, 2); + assert.equal(staged.revisions[0]?.dateRange.start, "2016-10-01"); + assert.equal(staged.revisions[0]?.dateRange.end, "2016-10-31"); +}); + +test("declined targets cannot be reopened and a diagnostic is closed in one tool loop", async () => { + const target = event({ eventId: "00000000-0000-4000-8000-000000000706" }); + const targetDossier = buildRectificationCaseDossier({ caseValue, turns: [], events: [target], snapshot: null, diagnostics: null, targetDisposition: "declined", currentTargetEventId: target.eventId }); + const reopened = plan({ targetDisposition: "declined", action: { type: "ask_question", focus: { mode: "clarify_existing_event", targetEventId: target.eventId, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["retry"] }, question: "再说说那件事?", optionalQuickReplies: [] } }); + assert.ok(validateRectificationTurnPlan({ plan: reopened, dossier: targetDossier, latestAnswer: "不想说", phase: "final" }).issues.includes("declined_target_reopened")); + + const phases: string[] = []; + const result = await runRectificationDirector({ + caseValue, + dossier: dossier(), + latestAnswer: "", + phase: "final", + diagnostics, + generatePlan: async (_prompt, phase) => { + phases.push(phase); + return { object: phase === "final" ? plan({ action: { type: "request_diagnostic", diagnostic: "candidate_split" } }) : plan() }; + }, + }); + assert.equal(result.mode, "agent"); + assert.deepEqual(phases, ["final", "after_diagnostic"]); + assert.equal(result.toolCalls.length, 1); + assert.equal(result.plan.action.type, "ask_question"); +}); + +test("the same Director gets one repair attempt before deterministic fallback", async () => { + const phases: string[] = []; + const result = await runRectificationDirector({ + caseValue, + dossier: dossier(), + latestAnswer: "2018年9月搬到北京。", + phase: "evidence", + diagnostics, + generatePlan: async (_prompt, phase) => { + phases.push(phase); + return { object: phase === "repair" ? plan() : plan({ evidenceProposals: [{ operation: "create", targetEventId: null, sourceSpan: "不存在的内容", dateText: "2020年", proposedSummary: "开始工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "low" }] }) }; + }, + }); + assert.equal(result.mode, "agent"); + assert.deepEqual(phases, ["evidence", "repair"]); + assert.equal(result.fallbackReason, null); +}); + + +test("manual question regeneration preserves focus and repairs unsafe text once", async () => { + const phases: string[] = []; + const question = await regenerateDirectorQuestion({ + caseValue, + currentQuestion: "除了这段经历,你还想从哪件事继续?", + latestAnswer: "2016年9月大学入学", + acceptedEvents: [event()], + focus: { + mode: "collect_independent_event", + targetEventId: null, + domain: null, + requestedFacts: ["independent_event"], + rationaleCodes: ["need_independent_event"], + }, + generateQuestion: async (_prompt, phase) => { + phases.push(phase); + return { object: { question: phase === "regenerate" ? "请确认出生时间05:13?" : "除了这段经历,你还想从哪件事继续?" } }; + }, + }); + assert.equal(question, "除了这段经历,你还想从哪件事继续?"); + assert.deepEqual(phases, ["regenerate", "repair"]); +}); diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index b6314c3b..e98befb6 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -75,7 +75,7 @@ test("answer is durably queued and a processing case reload restores its active assert.equal(JSON.stringify([...store.jobs.values()]), before); })); -test("V5 agent fallback persists Agent Run, Public Message and a server-owned opportunity", async () => withMode("v5_agent", async () => { +test("V5 agent fallback persists the Director decision, Public Message and next question", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow }); const userId = randomUUID(); @@ -97,10 +97,10 @@ test("V5 agent fallback persists Agent Run, Public Message and a server-owned op assert.ok(event && run && message); assert.equal(run.deploymentMode, "v5_agent"); assert.equal(run.validatedDecision.mode, "deterministic_fallback"); - assert.equal(run.validatedDecision.selectedOpportunity?.kind, "ask_new_event"); - assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, null); + assert.equal(run.validatedDecision.decision.action, "ask_question"); + assert.equal(run.validatedDecision.selectedOpportunity, null); assert.equal(done?.case.currentQuestion?.targetEventId, null); - assert.match(done?.case.currentQuestion?.prompt ?? "", /离家去外地上大学/); + assert.ok((done?.case.currentQuestion?.prompt ?? "").length > 0); assert.doesNotMatch(done?.case.currentQuestion?.prompt ?? "", /具体哪一天|几号/); assert.equal(message.question, done?.case.currentQuestion?.prompt); assert.equal(done?.case.latestSnapshot, null); @@ -125,8 +125,8 @@ test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible proj const run = [...store.agentRuns.values()][0]; assert.ok(queued?.job && event && run); assert.equal(run.deploymentMode, "v5_shadow"); - assert.equal(run.validatedDecision.selectedOpportunity?.kind, "ask_new_event"); - assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, null); + assert.equal(run.validatedDecision.decision.action, "ask_question"); + assert.equal(run.validatedDecision.selectedOpportunity, null); assert.equal(done.case.currentQuestion?.targetEventId, null); assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/); assert.match(store.publicMessages.get(queued.job.id)?.acknowledgement ?? "", /我记下了/); @@ -181,10 +181,10 @@ test("V5 Agent regenerate rewrites only the current semantic question and replay let realizationCalls = 0; const service = createRectificationV4CaseService(store, { now: fixedNow, - regenerateQuestion: async ({ opportunity }) => { + regenerateDirectorQuestion: async ({ currentQuestion }) => { realizationCalls += 1; await new Promise((resolve) => setTimeout(resolve, 5)); - return opportunity.fallbackPrompt; + return `${currentQuestion}(换一种问法)`; }, }); const userId = randomUUID(); diff --git a/scripts/active_rectification_event_engine.py b/scripts/active_rectification_event_engine.py index b3d27432..6b168080 100644 --- a/scripts/active_rectification_event_engine.py +++ b/scripts/active_rectification_event_engine.py @@ -54,6 +54,12 @@ DOMAIN_CONFIG: Final[dict[EventDomain, DomainConfig]] = { "finance": (("D2", "D11"), (2, 11)), "health_pressure": (("D30",), (6, 8, 12)), } +# ponytail: non-semantic audit offsets; replace only when calibrated kind rules exist. +EVENT_KIND_MODIFIERS: Final[dict[str, float]] = { + "relationship_start": 0.001, + "relationship_end": 0.002, + "relationship_change": 0.003, +} class RectificationEventCalculationError(RuntimeError): @@ -236,12 +242,17 @@ def _score_event( rules.append(f"{label}_arudha_auxiliary") points += 0.35 + event_kind = event.get("event_kind", event["domain"]) + if not rules: + rules.append("no_domain_activation") + rules.append(f"event_kind:{event_kind}") + points += EVENT_KIND_MODIFIERS.get(event_kind, 0.0) weighted_points = round(points * precision_weight(event["precision"]), 4) return { "event_id": event["id"], "domain": event["domain"], "candidate_time": candidate_time, - "rule_ids": rules or ["no_domain_activation"], + "rule_ids": rules, "points": weighted_points, } @@ -527,6 +538,7 @@ def _canonical_input_contract(request: RectificationEventRequest) -> tuple[dict, "events": [{ "id": event["id"], "domain": event["domain"], + "event_kind": event.get("event_kind", event["domain"]), "date": event["date"], "precision": event["precision"], "summary": event.get("summary", ""), diff --git a/scripts/active_rectification_events.py b/scripts/active_rectification_events.py index dd09f046..8d86c426 100644 --- a/scripts/active_rectification_events.py +++ b/scripts/active_rectification_events.py @@ -57,6 +57,7 @@ class CandidateEvidence(TypedDict): class LifeEvent(TypedDict): id: str domain: EventDomain + event_kind: NotRequired[str] date: str precision: EventPrecision summary: NotRequired[str] diff --git a/scripts/rectification/scoring_service.py b/scripts/rectification/scoring_service.py index 578d0e15..aad6e8ea 100644 --- a/scripts/rectification/scoring_service.py +++ b/scripts/rectification/scoring_service.py @@ -66,8 +66,9 @@ def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_dat "lon": request["lon"], "tz": request["tz"], "events": [{ - "id": event["id"], "domain": event["domain"], "date": sampled_date, - "precision": "day", "summary": event.get("summary", ""), + "id": event["id"], "domain": event["domain"], + "event_kind": event.get("event_kind", event["domain"]), + "date": sampled_date, "precision": "day", "summary": event.get("summary", ""), }], } diff --git a/skills/birth-time-rectification/SKILL.md b/skills/birth-time-rectification/SKILL.md index cc04463d..fdbc4803 100644 --- a/skills/birth-time-rectification/SKILL.md +++ b/skills/birth-time-rectification/SKILL.md @@ -1,6 +1,6 @@ --- name: birth-time-rectification -description: Natural, evidence-led birth-time rectification for the Web agent. Select one server-owned semantic question opportunity at a time; never create candidate results, confirm a unique minute, change profile birth time, invent evidence, or expose private scoring and technique traces. +description: Natural, evidence-led birth-time rectification for the Web Director Agent. Read the complete Case Dossier, propose grounded event revisions, choose one interview focus, and write one natural question; never create candidate results, confirm a unique minute, change profile birth time, invent evidence, or expose private scoring and technique traces. --- # Birth-time rectification @@ -12,11 +12,11 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re ## Product boundary - Current skill version: `birth-time-rectification-v6`. -- Current prompt version: `rectification-agent-v6-1`. +- Current prompt version: `rectification-director-v1`. - The scoring algorithm remains `rectification-v5-matrix-scoring-1`; the V6 label describes the conversation contract, not a replacement scoring engine. - The server owns event reconciliation, the real Python scan of every minute in the candidate window, the event contribution matrix, Candidate Snapshots, LOEO/LODO, date sensitivity, neighbor stability, candidate split, jobs, replay, persistence, and final decision validation. -- The agent may select one active server opportunity, call at most one allowed read-only diagnostic, offer an already-gated candidate range, or stop for low confidence. -- The agent never creates events, dates, scores, candidate minutes, or profile updates. +- The Director reads the complete event ledger plus the latest 10–12 raw turns, may propose multiple grounded events or revisions, chooses one interview focus, writes the public reply and at most one natural question, and may call at most one allowed read-only diagnostic. +- Event proposals are not facts until the server validates their source span, declared date, target revision, subject, classification, and scoreability. The Director never creates scores, candidate minutes, Case state, database mutations, or profile updates. - VedAstro is a read-only post-validation gate for `v5_agent` only. It runs only after the local stability and range-eligibility gates pass, compares the server-provided primary and runner-up, and never replaces V5 local scoring or lets SearchEvents choose the final candidate. - Candidate windows are inclusive. When `start_time > end_time`, the Python scan continues across midnight into the next calendar day; equal endpoints mean one candidate minute, and a window may not exceed 1,440 minutes. - The persisted “分析过程” is a server-owned execution receipt, not hidden chain-of-thought. It may list only stages, tools, and techniques that actually ran, plus a provider-explicit reasoning summary after server-side safety filtering. @@ -43,7 +43,7 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re 8. Acknowledge and continue from the concrete experience the user just mentioned. 9. Do not use empty stock phrases such as “这个信息很有用” or repetitive “已记录” openings. 10. Do not assign life meaning to an ordinary experience or claim an unconfirmed turning point. -11. The agent selects a server-generated semantic opportunity; it does not create candidate results or an unrestricted question route. +11. The Director chooses a focus from the Case Dossier and writes the question directly; the server validates structure, target, provenance, privacy, range gates, and dangerous claims rather than requiring domain keywords or a prebuilt opportunity ID. 12. Show a candidate range only after the deterministic stability gate, including LODO and required-technique availability, passes. 13. Never confirm, imply, or display a unique or representative birth minute as the answer. 14. Stop with an honest low-confidence result when evidence is sparse, conflicting, or unstable; do not prolong the interview indefinitely. @@ -53,11 +53,11 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re ## Turn strategy -1. Reconcile the latest answer and preserve its original wording and stated date precision. -2. Respect the current target disposition before generating a follow-up. -3. Review up to five active semantic opportunities built from evidence coverage, candidate split, date sensitivity, recent topics, novelty, recall ease, repetition, and privacy cost. -4. Select one useful opportunity and realize one short, anchored question. If none is useful, stop at low confidence. -5. Mention a candidate range only when it is newly displayable or materially changed; never repeat an unchanged range. +1. Read the complete Case Dossier: recent raw turns, full revision ledger, current target disposition, pending evidence, candidate contrasts, event sensitivity, and range gate. +2. Propose every explicit event in the latest answer. Use exact source spans and declared date text; propose `revise` only with a server-issued event ID already present in the Dossier. +3. After the server stages valid revisions and recomputes diagnostics, choose the single most useful focus. Do not rotate through domains or ask for finer dates unless the diagnostics show value. +4. Write one short natural question and the public reply in the same TurnPlan. Do not expose internal IDs, scores, contribution details, tools, or candidate minutes. +5. If server validation rejects the TurnPlan, repair it once. If it still fails, accept the generic safety fallback. Offer a range only when the current server Snapshot allows it; otherwise stop honestly at low confidence when no useful question remains. A user who answers with a different complete event may have that event saved without overwriting the old target. The old target may receive at most one gentle clarification; repeated diversion closes it and moves the conversation on. diff --git a/tests/test_active_rectification_events.py b/tests/test_active_rectification_events.py index 70199002..80131bb5 100644 --- a/tests/test_active_rectification_events.py +++ b/tests/test_active_rectification_events.py @@ -9,6 +9,7 @@ from scripts.active_rectification_events import ( precision_weight, score_life_events, ) +from scripts.rectification.scoring_service import build_event_contribution_matrix def _row(time: str, score: float) -> CandidateScoreRow: @@ -130,6 +131,79 @@ def test_date_precision_weights_are_fixed() -> None: assert precision_weight("year") == 0.5 +def test_matrix_legacy_adapter_preserves_event_kind() -> None: + seen = [] + + def rows(value): + event = value["events"][0] + seen.append(event["event_kind"]) + return [{ + "time": "05:13", + "score": 1.0, + "evidence": [{ + "event_id": event["id"], + "domain": event["domain"], + "candidate_time": "05:13", + "rule_ids": [f"event_kind:{event['event_kind']}"], + "points": 1.0, + }], + "missing_layers": [], + }] + + build_event_contribution_matrix({ + "birth_date": "1993-04-17", + "start_time": "05:13", + "end_time": "05:13", + "lat": 36.683333, + "lon": 114.35, + "tz": 8.0, + "events": [{ + "id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", + "domain": "relationship", + "event_kind": "relationship_end", + "date_start": "2021-01-01", + "date_end": "2021-01-01", + "precision": "day", + }], + }, row_provider=rows) + + assert seen == ["relationship_end"] + + +def test_relationship_event_kinds_have_distinct_traceable_contributions(monkeypatch) -> None: + monkeypatch.setattr( + event_engine.functional_benefics, + "derive_functional_benefic_malefic", + lambda _sign: {"functional_benefics": [], "functional_malefics": []}, + ) + common = { + "candidate_time": "05:13", + "natal_chart": {"ascendant": {"lon": 0.0, "sign": "Aries"}, "planets": {}}, + "varga_charts": [], + "vimshottari": ("Sun", "Moon", "Mars"), + "narayana": (None, None), + "arudha_padas": {}, + } + + evidence = { + kind: event_engine._score_event( + **common, + event={ + "id": kind, + "domain": "relationship", + "event_kind": kind, + "date": "2021-01-01", + "precision": "day", + }, + ) + for kind in ("relationship_start", "relationship_end", "relationship_change") + } + + assert {item["points"] for item in evidence.values()} == {0.001, 0.002, 0.003} + for kind, item in evidence.items(): + assert f"event_kind:{kind}" in item["rule_ids"] + + def test_real_local_scoring_uses_dated_events_and_actual_candidate_minutes() -> None: result = score_life_events({ "birth_date": "1993-04-17", @@ -185,6 +259,7 @@ def test_event_summary_is_fingerprinted_without_unlocking_minute_application() - "events": [{ "id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", "domain": "career", + "event_kind": "career_change", "date": "2019-07", "precision": "month", "summary": "2019 年 7 月第一次承担团队管理职责", @@ -206,6 +281,7 @@ def test_event_summary_is_fingerprinted_without_unlocking_minute_application() - ) assert contract["schema_version"] == "rectification-candidate-input-v2" + assert contract["events"][0]["event_kind"] == "career_change" assert contract["events"][0]["summary"] == "2019 年 7 月第一次承担团队管理职责" assert changed_hash != input_hash assert result["canonical_input_hash"] == input_hash @@ -253,7 +329,7 @@ def test_finance_events_use_d2_d11_and_recompute_both_dashas_per_minute(monkeypa def test_missing_narayana_blocks_the_candidate_event_instead_of_using_partial_timing(monkeypatch) -> None: monkeypatch.setattr(event_engine, "_active_narayana", lambda *_args: (None, None)) - row = event_engine._candidate_row({ + request = { "birth_date": "1993-04-17", "start_time": "14:29", "end_time": "14:29", @@ -266,11 +342,13 @@ def test_missing_narayana_blocks_the_candidate_event_instead_of_using_partial_ti "date": "2019-07-01", "precision": "day", }], - }, datetime(1993, 4, 17, 14, 29)) + } + context = event_engine.build_candidate_static_context(request, datetime(1993, 4, 17, 14, 29)) + row = event_engine._candidate_row(request, context) assert row["evidence"] == [] assert row["score"] == 0 - assert row["missing_layers"] == ["Narayana_MD_AD"] + assert "Narayana_MD_AD" in row["missing_layers"] result = event_engine.compute_event_candidate_result({ "birth_date": "1993-04-17", @@ -286,7 +364,7 @@ def test_missing_narayana_blocks_the_candidate_event_instead_of_using_partial_ti "precision": "day", }], }) - assert result["missing_layers"] == ["Narayana_MD_AD"] + assert "Narayana_MD_AD" in result["missing_layers"] assert "missing_mandatory_layers" in result["reasons"] @@ -299,7 +377,7 @@ def test_relationship_scoring_receives_computed_ul(monkeypatch) -> None: return original_score_event(**kwargs) monkeypatch.setattr(event_engine, "_score_event", score_event) - event_engine._candidate_row({ + request = { "birth_date": "1993-04-17", "start_time": "14:29", "end_time": "14:29", @@ -312,6 +390,8 @@ def test_relationship_scoring_receives_computed_ul(monkeypatch) -> None: "date": "2021", "precision": "year", }], - }, datetime(1993, 4, 17, 14, 29)) + } + context = event_engine.build_candidate_static_context(request, datetime(1993, 4, 17, 14, 29)) + event_engine._candidate_row(request, context) assert seen_ul and seen_ul[0]["sign_idx"] >= 0