diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 11d0dc2e..8b1034aa 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1885,7 +1885,7 @@ ## BUG-107 | 开放问题收集年份事件后先切换新事件、下一轮再回访旧事件 -- 状态:resolved(local) +- 状态:resolved(staging pending deployment) - 首次发现:2026-07-31 - 最近更新:2026-07-31 - 影响面:V8 Director 最终规划、确定性 fallback、年份/季度精度事件的访谈连续性 @@ -1897,3 +1897,18 @@ - 防复发:事件连续性由服务器的 `currentTargetEventId + targetDisposition` 约束,不能只靠 Agent prompt;新事件的必要日期精度应在切换话题前闭合,用户明确“不知道/跳过/换方向”时才解除目标。 - 相关记录:BUG-092、BUG-097、BUG-106 - 修复版本:local / pending release + +## BUG-108 | V8 丢失事件承接说明且服务器领域模板覆盖 Agent 自主选题 + +- 状态:resolved(staging pending deployment) +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V8 Director 最终回复、Candidate Contrast、确定性 fallback、前端可见问题历史与处理中动画 +- 用户现象:用户提供具体经历后,界面只显示下一问,没有显示 Agent 对新线索的承接和公开安全的价值说明;后续问题又容易落入预写的教育、迁居、关系、事业、财务、健康模板,甚至把验收案例中的“大学、实习、搬家”等措辞当成产品脚本。处理中还显示固定三步 checklist,而不是随真实 phase 变化。 +- 触发条件:`v5_agent` 最终规划生成了 `publicReply`,但持久化出口只保存裸问题;同时 Opportunity Builder 通过 `domainPolicy`、关键词、人工 `recallEase/privacyCost` 和领域 `fallbackPrompt` 预先决定候选问题,Renderer 再用领域词表校验模型输出,模型异常时 Director fallback 直接采用人工排序结果。 +- 根因:公开消息和可见问题使用了两个出口;更关键的是服务器同时承担了事实约束和访谈选题,形成“Builder 人工选题 → Director 采用排名 → Renderer 关键词裁决”的双重控制,Agent 实际只能改写模板。 +- 修复:持久化完整的 acknowledgement、公开安全的证据价值说明、limitation 与唯一问题;删除固定领域 `domainPolicy`、领域 recall cues、领域关键词匹配和 V8 Opportunity 排名传参。Candidate Contrast 仅提供完整、顺序稳定的事实观察,Director 根据完整账本、拒答记录、候选差异和只读工具自主决定方向与措辞。无当前目标且模型失败时使用 `domain:null` 的领域中立恢复问题;有未闭合目标时服务器只保护目标连续性并询问必要事实。保留拒答/隐私保护、事实来源验证、单问题、范围门、目标锚点和技术信息过滤。前端移除固定分析 checklist,并把真实 Job phase 映射到 `thinking-orbs` 状态。 +- 验证:回归覆盖 Builder 不再生成六领域问题、Candidate Contrast 返回全部可用缺口而不替 Agent 选题、Renderer 接受不在测试样例中的自然方向、Agent 自主问题不被领域 fallback 替换、拒答领域不能被重新打开、大学与研究院实习回放在模型失败时保持领域中立。`npx tsc --noEmit`、98 项聚焦测试、1160 项完整前端测试、touched-file ESLint 与 `git diff --check` 均通过。 +- 防复发:服务器只提供事实、能力和禁止项;正常 V8 选题与措辞归 Agent。测试案例只能验证不变量和复现回归,不能通过词表、权重或预写问题进入生产决策链。 +- 相关记录:BUG-105、BUG-106、BUG-107 +- 修复版本:staging branch / deployment manifest records exact SHA diff --git a/frontend/src/components/rectification-v4-panel.tsx b/frontend/src/components/rectification-v4-panel.tsx index 5393dc87..41f19b0c 100644 --- a/frontend/src/components/rectification-v4-panel.tsx +++ b/frontend/src/components/rectification-v4-panel.tsx @@ -2,11 +2,11 @@ import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { AgentActivityStatus } from "@/components/agent-activity-status"; +import { AgentActivityStatus, type AgentActivityState } from "@/components/agent-activity-status"; import { useRectificationV4 } from "@/hooks/use-rectification-v4"; import type { ChatMessageView } from "@/lib/chat-message-view"; import type { PublicLanguageModel } from "@/lib/public-models"; -import type { RectificationAnalysisItem, RectificationAnalysisTrace, RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts"; +import type { RectificationAnalysisItem, RectificationAnalysisTrace, RectificationAssistantMessage, RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts"; import { AgentAvatar, ChatMessageRow } from "./chat-message-row"; import { ModelSelector } from "./model-selector"; import { Button } from "./ui/button"; @@ -32,8 +32,11 @@ type RectificationV4PanelProps = Readonly<{ type RectificationChatMessageView = ChatMessageView & Readonly<{ analysisTrace?: RectificationAnalysisTrace; + activityState?: AgentActivityState; }>; +type RectificationPhase = NonNullable["phase"]; + const phaseLabels = { collecting_evidence: "正在准备继续收集经历…", extracting_evidence: "正在整理你刚才提到的经历…", @@ -45,22 +48,31 @@ const phaseLabels = { complete: "分析已完成", } as const; +const phaseActivityStates = { + collecting_evidence: "listening", + extracting_evidence: "working", + scoring_candidates: "searching", + checking_robustness: "solving", + planning_question: "shaping", + reasoning: "solving", + rendering: "composing", + complete: "shaping", +} as const satisfies Record; + export function rectificationPhaseLabel( - phase: NonNullable["phase"], + phase: RectificationPhase, ): string { return phaseLabels[phase]; } export function rectificationProgressLabel( - phase: NonNullable["phase"], + phase: RectificationPhase, ): string { - const activeStep = ["collecting_evidence", "extracting_evidence"].includes(phase) - ? 0 - : ["scoring_candidates", "checking_robustness"].includes(phase) - ? 1 - : 2; - const steps = ["整理已确认事件", "比较候选时间差异", "确定下一步验证方向"]; - return [`${rectificationPhaseLabel(phase)}\n正在分析:`, ...steps.map((step, index) => `${index < activeStep || phase === "complete" ? "✓" : index === activeStep ? "●" : "○"} ${step}`)].join("\n"); + return rectificationPhaseLabel(phase); +} + +export function rectificationPhaseActivityState(phase: RectificationPhase): AgentActivityState { + return phaseActivityStates[phase]; } function durationLabel(durationMs: number | null): string | null { @@ -144,7 +156,7 @@ function RectificationMessageRow({ message }: Readonly<{ message: RectificationC
- +
@@ -173,6 +185,13 @@ export function canRegenerateRectificationMessage(input: Readonly<{ && input.canAnswer; } + +export function composeRectificationAssistantMessage(message: RectificationAssistantMessage): string { + return [message.acknowledgement, message.evidenceExplanation, message.candidateUpdate, message.limitation, message.question] + .filter((value): value is string => Boolean(value?.trim())) + .join("\n\n"); +} + export function rectificationV4ChatMessages( data: RectificationV4ApiResponse | null, processing: boolean, @@ -188,6 +207,8 @@ export function rectificationV4ChatMessages( } const messages: RectificationChatMessageView[] = []; + const assistantResponses = data.case.deploymentMode === "v5_agent" ? data.assistantResponses ?? [] : []; + const responseBySourceTurnId = new Map(assistantResponses.map((item) => [item.sourceTurnId, item])); const analysis = data.case.deploymentMode === "v5_agent" ? (data as RectificationV4ApiResponse & { readonly analysis?: readonly RectificationAnalysisItem[]; @@ -203,15 +224,17 @@ export function rectificationV4ChatMessages( }); } - let previousTurnId: string | null = null; + let previousTurn: RectificationV4ApiResponse["turns"][number] | undefined; for (const turn of data.turns) { - messages.push({ - role: "assistant", - text: turn.question, - renderKey: `rectification-question-${turn.id}`, - state: "settled", - analysisTrace: previousTurnId ? analysisBySourceTurnId.get(previousTurnId) : undefined, - }); + if (!previousTurn || !responseBySourceTurnId.has(previousTurn.id)) { + messages.push({ + role: "assistant", + text: turn.question, + renderKey: `rectification-question-${turn.id}`, + state: "settled", + analysisTrace: previousTurn ? analysisBySourceTurnId.get(previousTurn.id) : undefined, + }); + } if (turn.answer) { messages.push({ role: "user", @@ -220,13 +243,25 @@ export function rectificationV4ChatMessages( state: "settled", }); } - previousTurnId = turn.id; + const response = responseBySourceTurnId.get(turn.id); + if (response) { + messages.push({ + role: "assistant", + text: composeRectificationAssistantMessage(response.message), + renderKey: `rectification-response-${turn.id}`, + state: "settled", + analysisTrace: response.trace ?? undefined, + }); + } + previousTurn = turn; } const caseValue = data.case; const primary = caseValue.latestSnapshot?.clusters[0]; - const latestTurnTrace = analysisBySourceTurnId.get(data.turns.at(-1)?.id ?? ""); - const terminalTrace = caseValue.currentQuestion ? undefined : latestTurnTrace; + const latestTurn = data.turns.at(-1); + const latestResponse = latestTurn ? responseBySourceTurnId.get(latestTurn.id) : undefined; + const latestTurnTrace = latestResponse?.trace ?? analysisBySourceTurnId.get(latestTurn?.id ?? ""); + const terminalTrace = caseValue.currentQuestion ? undefined : latestTurnTrace ?? undefined; if (caseValue.acceptedRange) { messages.push({ role: "assistant", @@ -235,7 +270,7 @@ export function rectificationV4ChatMessages( state: "settled", analysisTrace: terminalTrace, }); - } else if (caseValue.status === "range_ready" && primary) { + } else if (!latestResponse && caseValue.status === "range_ready" && primary) { messages.push({ role: "assistant", text: `根据目前这些经历,可以先把范围稳定缩小到 ${primary.startTime}–${primary.endTime}。这是候选范围,不是已确认的出生分钟;你可以保存它,也可以继续补充经历。`, @@ -245,24 +280,26 @@ export function rectificationV4ChatMessages( }); } - if (!processing && caseValue.currentQuestion && !caseValue.acceptedRange) { + if (!processing && caseValue.currentQuestion && !caseValue.acceptedRange && !latestResponse) { messages.push({ role: "assistant", text: caseValue.currentQuestion.prompt, renderKey: `rectification-current-${caseValue.currentQuestion.id}`, state: "settled", - analysisTrace: latestTurnTrace, + analysisTrace: latestTurnTrace ?? undefined, }); } if (processing) { + const phase = data.job?.phase ?? caseValue.phase; messages.push({ role: "assistant", - text: rectificationProgressLabel(data.job?.phase ?? caseValue.phase), + text: rectificationProgressLabel(phase), renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`, state: "thinking", + activityState: rectificationPhaseActivityState(phase), }); - } else if (caseValue.status === "paused") { + } else if (caseValue.status === "paused" && !latestResponse) { messages.push({ role: "assistant", text: "进度已经保存。准备好后,我们可以从这里继续。", diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts index 37f1e335..4dcc4dd3 100644 --- a/frontend/src/lib/rectification-agent/contracts.ts +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { clockTimeSchema, eventKindSchema, eventSubjectSchema, evidenceDomainSchema, relatedPersonSchema, rectificationAnalysisTraceSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts"; +import { clockTimeSchema, eventKindSchema, eventSubjectSchema, evidenceDomainSchema, rectificationAnalysisTraceSchema, rectificationAssistantMessageSchema, rectificationDeploymentModeSchema, relatedPersonSchema } from "../rectification-v4/contracts.ts"; const uuid = z.string().uuid(); const hash = z.string().regex(/^[a-f0-9]{64}$/); @@ -108,9 +108,14 @@ export const rectificationTurnPlanSchema = z.object({ action: directorActionSchema, publicReply: z.object({ acknowledgement: nonblank(1_000), + evidenceExplanation: nonblank(1_600).nullable().default(null), candidateCommentary: nonblank(1_000).nullable(), limitation: nonblank(1_000).nullable(), }).strict(), + publicExplanationGrounding: z.array(z.object({ + source: z.enum(["capability_matrix", "window_sensitivity", "candidate_scan", "diagnostic"]), + factKey: nonblank(160), + }).strict()).max(12).default([]), }).strict(); export type RectificationTurnPlan = z.infer; @@ -194,6 +199,10 @@ export const rectificationCaseDossierSchema = z.object({ capabilities: z.object({ supportedDomains: z.array(evidenceDomainSchema), supportedEventKinds: z.array(eventKindSchema), + publicTechniqueCapabilities: z.array(z.object({ + domain: evidenceDomainSchema, + techniqueLayers: z.array(nonblank(80)).max(20), + }).strict()).max(8), maxQuestionsPerTurn: z.literal(1), maxToolRounds: z.literal(10), forbiddenPublicClaims: z.array(z.string()), @@ -464,12 +473,7 @@ export const validatedDecisionSchema = z.object({ }).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 const publicMessageSchema = rectificationAssistantMessageSchema; export type PublicMessage = z.infer; export const storedPublicMessageSchema = publicMessageSchema.extend({ diff --git a/frontend/src/lib/rectification-agent/director-agent.ts b/frontend/src/lib/rectification-agent/director-agent.ts index e06054b8..d884b93f 100644 --- a/frontend/src/lib/rectification-agent/director-agent.ts +++ b/frontend/src/lib/rectification-agent/director-agent.ts @@ -5,15 +5,20 @@ 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 { hasPolicyInvalidScoreableEvents } from "../rectification-v4/evidence-ledger.ts"; +import { domainScorerRegistry } from "../rectification-v4/domain-scorers.ts"; import { buildCandidateContrastPacket } from "./opportunity-builder.ts"; import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationAgentTool, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace, type ToolObservation } from "./contracts.ts"; +import { assertRectificationSkillLoaded } from "./skill-runtime.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|\bD\d{1,2}\b|\bKP\b|Vimshottari|Narayana|Shadbala|Ashtakavarga)/iu; +const privatePattern = /(?:[0-9a-f]{8}-[0-9a-f-]{27,}|opportunity(?:id)?|snapshot(?:id)?|event(?:id)?|targetEventId|(?:raw|原始)?\s*(?:score|评分|得分)|权重|rule[_ -]?id|贡献矩阵|tool[_ -]?call|cluster[_ -]?id|工具原始(?:输出|轨迹)|内部推理链)/iu; +const quantifiedStructurePattern = /(?:(?:D\d{1,2}|KP|Vimshottari|Narayana|Shadbala|Ashtakavarga|Chaturvimshamsha|上升星座|宫位|分盘)[^。!?\n]{0,60}(?:切换|变化|变动|遍历)[^。!?\n]{0,16}(?:\d+|[一二三四五六七八九十百]+)\s*次|(?:\d+|[一二三四五六七八九十百]+)\s*次[^。!?\n]{0,60}(?:切换|变化|变动|遍历))/iu; const exactMinutePattern = /(?:\b(?:[01]?\d|2[0-3]):[0-5]\d\b|(?:凌晨|清晨|上午|中午|下午|傍晚|晚上)?\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*[点时]\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*分)/u; const questionClausePattern = /(?:请|你(?:还)?(?:记得|能否|是否|有没有)|再(?:说|补充|回忆)|哪(?:一|个|年|月|天)?|什么|多少|几(?:年|月|号|日)?|吗|呢)/u; +const genericAcknowledgementPattern = /^(?:好的|明白了|知道了|收到|已记录|我记下了|我已按你的描述整理这轮线索)[。!!]?$/u; +const groundedPublicReplyRequirement = "When the latest answer adds or refines a concrete event, the final public reply must: acknowledge the exact event and its date precision; summarize one to three evidence signals found in the user wording; use evidenceExplanation to map those signals to public method layers from dossier.capabilities.publicTechniqueCapabilities; distinguish a general method mapping from calculations actually present in tool observations; explain why the next question helps; and ask at most one question. Public method names such as D4, D24, Vimshottari, Narayana, UL, and A10 are allowed. Never expose internal ids, raw scores, weights, contribution matrices, raw tool traces, candidate minutes, or hidden reasoning. Never claim a numeric structural fact such as a division switching N times unless the dossier contains the matching window_sensitivity observation and publicExplanationGrounding cites its fact key."; function containsExactMinute(value: string): boolean { return exactMinutePattern.test(value); @@ -65,18 +70,29 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti return { candidateRank: cluster.rank, supportingEventIds: candidate?.supportingEventIds ?? [], conflictingEventIds: candidate?.conflictingEventIds ?? [] }; }), }, - capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxToolRounds: 10, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] }, + capabilities: { + supportedDomains: domains, + supportedEventKinds: kinds, + publicTechniqueCapabilities: domains.map((domain) => ({ domain, techniqueLayers: [...domainScorerRegistry[domain].techniqueLayers] })), + maxQuestionsPerTurn: 1, + maxToolRounds: 10, + forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "raw_technique_trace", "ungrounded_numeric_structure"], + }, }); } +function latestGroundedEvent(dossier: RectificationCaseDossier, latestAnswer: string) { + return [...dossier.eventLedger].reverse().find((event) => event.status === "active" && (event.rawText === latestAnswer || latestAnswer.includes(event.summary))) ?? null; +} + 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: "这仍不是对某个精确出生分钟的确认。" } }; + 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: "现有事件已经完成本轮复核。", evidenceExplanation: null, candidateCommentary: "候选范围已通过当前稳定性门槛,可以作为工作范围查看。", limitation: "这仍不是对某个精确出生分钟的确认。" }, publicExplanationGrounding: [] }; const targetEventId = dossier.interviewState.currentTargetEventId; const keepTarget = Boolean(targetEventId && ["unresolved", "answered_other_event"].includes(dossier.interviewState.targetDisposition)); const targetEvent = keepTarget ? dossier.eventLedger.find((event) => event.eventId === targetEventId && event.status === "active") : null; - const latestGroundedEvent = [...dossier.eventLedger].reverse().find((event) => event.status === "active" && (event.rawText === latestAnswer || latestAnswer.includes(event.summary))); - const safeSummary = latestGroundedEvent && !privatePattern.test(latestGroundedEvent.summary) && !containsExactMinute(latestGroundedEvent.summary) - ? latestGroundedEvent.summary.slice(0, 120) + const groundedEvent = latestGroundedEvent(dossier, latestAnswer); + const safeSummary = groundedEvent && !privatePattern.test(groundedEvent.summary) && !containsExactMinute(groundedEvent.summary) + ? groundedEvent.summary.slice(0, 120) : null; const safeTargetSummary = targetEvent && !privatePattern.test(targetEvent.summary) && !containsExactMinute(targetEvent.summary) ? `“${targetEvent.summary.slice(0, 120)}”` @@ -85,7 +101,27 @@ function fallback(dossier: RectificationCaseDossier, latestAnswer: string): Rect const targetQuestion = targetNeedsMonth ? `${safeTargetSummary}大概发生在哪个月,或一年中的哪个时间段?` : `关于${safeTargetSummary},你还记得更具体的时间或阶段吗?`; - 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 ? targetEventId : null, domain: keepTarget ? targetEvent?.domain ?? null : null, requestedFacts: keepTarget ? [targetNeedsMonth ? "month" : "day_or_period"] : ["independent_event", "year"], rationaleCodes: [keepTarget ? "unresolved_current_event" : "need_independent_dated_event"] }, question: keepTarget ? targetQuestion : "你还能想到一件发生在你本人身上、时间大致确定的重要经历吗?", optionalQuickReplies: [] }, publicReply: { acknowledgement: safeSummary ? `你提到的“${safeSummary}”已经纳入本轮事件线索。` : latestAnswer.trim() ? "我已按你刚才的描述继续整理事件线索。" : "我们先从真实经历建立事件线索。", candidateCommentary: null, limitation: "在证据通过稳定性门槛前,我不会把某个具体分钟当成确定出生时间。" } }; + const question = keepTarget ? targetQuestion : "你愿意再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。"; + const focus = keepTarget + ? { mode: "clarify_existing_event" as const, targetEventId, domain: targetEvent?.domain ?? null, requestedFacts: [targetNeedsMonth ? "month" as const : "day_or_period" as const], rationaleCodes: ["unresolved_current_event"] } + : { mode: "collect_independent_event" as const, targetEventId: null, domain: null, requestedFacts: ["independent_event" as const, "year" as const], rationaleCodes: ["model_unavailable_neutral_fallback"] }; + const techniqueLayers = groundedEvent ? domainScorerRegistry[groundedEvent.domain].techniqueLayers : []; + const evidenceExplanation = safeSummary && techniqueLayers.length + ? `按当前校正能力,这类事件通常会参考 ${techniqueLayers.join("、")};这里只是在说明方法映射,尚未把它写成某个候选时间的计算结论。` + : safeSummary ? "这条经历提供了可核对的时间和变化类型;目前只是整理证据,还不是候选时间的计算结论。" : null; + return { + contractVersion: "rectification-turn-plan-v1", + targetDisposition: dossier.interviewState.targetDisposition, + evidenceProposals: [], + action: { type: "ask_question", focus, question, optionalQuickReplies: [] }, + publicReply: { + acknowledgement: safeSummary ? `你提到的是“${safeSummary}”。` : latestAnswer.trim() ? "我会保留你刚才的原始说法,不补写你没有确认的信息。" : "我们先从真实经历建立事件线索。", + evidenceExplanation, + candidateCommentary: safeSummary ? "这条线索有明确时间,也说明了具体发生的变化,可以和其他独立经历交叉比较候选范围。" : null, + limitation: null, + }, + publicExplanationGrounding: groundedEvent ? [{ source: "capability_matrix", factKey: `domain:${groundedEvent.domain}` }] : [], + }; } export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final" }>): Readonly<{ plan: RectificationTurnPlan | null; issues: readonly string[] }> { @@ -113,12 +149,31 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d 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(" "); + const publicText = [plan.publicReply.acknowledgement, plan.publicReply.evidenceExplanation, 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 (containsExactMinute(publicText)) issues.push("exact_minute_claimed"); + if (quantifiedStructurePattern.test(publicText)) { + const windowFactKeys = new Set(plan.publicExplanationGrounding.filter((item) => item.source === "window_sensitivity").map((item) => item.factKey)); + const observedFactKeys = new Set(input.dossier.runtime.observations.flatMap((observation) => { + if (observation.outcome !== "succeeded") return []; + const result = observation.result; + const direct = Object.keys(result); + const nested = result.windowSensitivity && typeof result.windowSensitivity === "object" && !Array.isArray(result.windowSensitivity) + ? Object.keys(result.windowSensitivity as Record) + : []; + return [...direct, ...nested]; + })); + if (![...windowFactKeys].some((factKey) => observedFactKeys.has(factKey))) issues.push("ungrounded_numeric_structure_claim"); + } if (plan.action.type === "ask_question") { if (asksMultipleQuestions(plan.action.question)) issues.push("multiple_questions"); + if (input.phase === "final" && latestGroundedEvent(input.dossier, input.latestAnswer)) { + if (genericAcknowledgementPattern.test(plan.publicReply.acknowledgement.trim())) issues.push("event_acknowledgement_generic"); + if (!plan.publicReply.evidenceExplanation || plan.publicReply.evidenceExplanation.trim().length < 12) issues.push("event_explanation_missing"); + if (!plan.publicReply.candidateCommentary || plan.publicReply.candidateCommentary.trim().length < 12) issues.push("event_value_commentary_missing"); + } if (plan.action.focus.targetEventId && !known.has(plan.action.focus.targetEventId)) issues.push("focus_target_invalid"); + if (plan.action.focus.domain && input.dossier.interviewState.declinedDomains.includes(plan.action.focus.domain)) issues.push("declined_domain_reopened"); if (currentTarget && ["unresolved", "answered_other_event"].includes(plan.targetDisposition) && plan.action.focus.targetEventId !== currentTarget) issues.push("unresolved_target_abandoned"); if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition) @@ -139,8 +194,10 @@ export async function regenerateDirectorQuestion(input: Readonly<{ }>): 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 skillReady = agent ? assertRectificationSkillLoaded(agent, { caseId: input.caseValue.id, modelId: model?.id ?? null, deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null }) : null; const generate = input.generateQuestion ?? (async (prompt: string) => { - if (!agent) throw new Error("director_model_unavailable"); + if (!agent || !skillReady) throw new Error("director_model_unavailable"); + await skillReady; return agent.generate(prompt, { structuredOutput: { schema: regeneratedQuestionSchema, jsonPromptInjection: "inline" } }); }); const validate = (value: unknown) => { @@ -165,9 +222,11 @@ export async function regenerateDirectorQuestion(input: Readonly<{ 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 server-owned dossier and tool observations. Propose every explicit event in the latest answer, choose the current focus, and write the public reply plus at most one natural question. In final planning, use the server-owned read-only tools to inspect the case, candidate scan, evidence gaps, or one diagnostic at a time. Adapt after every observation, never repeat an immutable tool call in the same run, and converge as soon as another tool adds no value. Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output." }) : null; + 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 server-owned dossier and tool observations. Propose every explicit event in the latest answer, independently choose the current focus and wording from the full ledger, declined domains, candidate contrasts, and observations, and write the public reply plus at most one natural question. Do not follow a fixed domain rotation or treat examples, tests, or prior wording as a script. In final planning, use the server-owned read-only tools to inspect the case, candidate scan, evidence gaps, or one diagnostic at a time. Adapt after every observation, never repeat an immutable tool call in the same run, and converge as soon as another tool adds no value. ${groundedPublicReplyRequirement} Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output.` }) : null; + const skillReady = agent ? assertRectificationSkillLoaded(agent, { caseId: input.caseValue.id, modelId: model?.id ?? null, deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null }) : null; const generate = input.generatePlan ?? (async (prompt: string) => { - if (!agent) throw new Error("director_model_unavailable"); + if (!agent || !skillReady) throw new Error("director_model_unavailable"); + await skillReady; return agent.generate(prompt, { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 25_000), structuredOutput: { schema: rectificationTurnPlanSchema, jsonPromptInjection: "inline" } }); }); let inputTokens = 0, outputTokens = 0; @@ -209,7 +268,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect return { round, tool: request.tool, diagnostic: request.diagnostic, outcome: "succeeded", result, dossierRevision: dossier.runtime.revision + 1, errorCode: null }; }; 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. Use a read-only tool only when its observation can materially change the next action.", latestAnswer: input.latestAnswer, dossier: promptDossier() }), input.phase); + 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. Use a read-only tool only when its observation can materially change the next action.", publicReplyRequirement: input.phase === "final" ? groundedPublicReplyRequirement : undefined, latestAnswer: input.latestAnswer, dossier: promptDossier() }), input.phase); await addUsage(first); let candidate = rectificationTurnPlanSchema.parse(first.object); const observedTools = new Set(); @@ -235,6 +294,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect toolCalls.push({ tool: request.tool, diagnostic: request.diagnostic, outcome: observation.outcome, durationMs: Date.now() - toolStarted, errorCode: observation.errorCode }); const next = await generate(JSON.stringify({ task: "Read the updated dossier and latest observation. Request another unobserved read-only tool only if it can materially change the interview strategy; otherwise converge to one final non-tool action. evidenceProposals must stay empty.", + publicReplyRequirement: groundedPublicReplyRequirement, latestAnswer: input.latestAnswer, dossier: promptDossier(), latestObservation: observation, @@ -247,6 +307,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect if (input.phase === "final" && requestedTool(candidate.action)) { const converged = await generate(JSON.stringify({ task: "The tool loop has reached its convergence boundary. Return one safe final non-tool action now; do not request another tool and keep evidenceProposals empty.", + publicReplyRequirement: groundedPublicReplyRequirement, latestAnswer: input.latestAnswer, dossier: promptDossier(), loopState: { round: toolCalls.length, maxRounds: dossier.capabilities.maxToolRounds, observedTools: [...observedTools], convergenceReason: convergenceReason ?? "tool_round_limit" }, @@ -257,7 +318,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect } let validated = validateRectificationTurnPlan({ plan: candidate, 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: promptDossier(), rejectedPlan: candidate, validationIssues: validated.issues }), "repair"); + 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.", publicReplyRequirement: input.phase === "final" ? groundedPublicReplyRequirement : undefined, latestAnswer: input.latestAnswer, dossier: promptDossier(), rejectedPlan: candidate, validationIssues: validated.issues }), "repair"); await addUsage(repaired); candidate = rectificationTurnPlanSchema.parse(repaired.object); if (requestedTool(candidate.action)) throw new Error("director_repair_requested_tool"); diff --git a/frontend/src/lib/rectification-agent/opportunity-builder.ts b/frontend/src/lib/rectification-agent/opportunity-builder.ts index 12de0b5b..b775d392 100644 --- a/frontend/src/lib/rectification-agent/opportunity-builder.ts +++ b/frontend/src/lib/rectification-agent/opportunity-builder.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts"; +import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts"; import { domainScorerRegistry } from "../rectification-v4/domain-scorers.ts"; import { chronologicalEvents, latestEventRevisions } from "../rectification-v4/evidence-ledger.ts"; import type { TargetDisposition } from "../rectification-v4/extraction.ts"; @@ -10,64 +10,6 @@ const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [ "invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace", ]; -const domainPolicy: Readonly, Readonly<{ - goal: (anchor: string | null) => string; - fallbackPrompt: (anchor: string | null) => string; - recallCues: string; - signals: RegExp; - recallEase: number; - privacyCost: number; -}>>> = { - education: { - goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}引导用户回忆一件学习路径变化,用复读、转学、换专业、毕业或重要考试等非穷举线索,不预设一定发生。`, - fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}有没有一件学习路径明显变化的经历,比如复读、转学、换专业、毕业或重要考试改变去向;如果有,大概是哪年哪月,没有或记不清也可以换一类经历?`, - recallCues: "复读、转学、换专业、毕业或重要考试改变去向", - signals: /大学|学校|入学|升学|毕业|考试|专业|读书|复读|转学/, - recallEase: .82, - privacyCost: .03, - }, - relocation: { - goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}引导用户回忆一件真正改变居住基地的经历,用搬家、住校或到另一座城市长期生活等非穷举线索,不把当前事件换词重问。`, - fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}有没有一件真正改变居住地点的经历,比如独立搬家、住校或到另一座城市长期生活;如果有,大概是哪年哪月,没有或记不清也可以换一类经历?`, - recallCues: "独立搬家、住校或到另一座城市长期生活", - signals: /搬家|搬到|搬去|迁居|迁到|迁往|移居|定居|住校|长期居住|生活基地|离家|外地|异地/, - recallEase: .78, - privacyCost: .04, - }, - relationship: { - goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}在用户愿意的前提下,引导回忆一件关系状态变化,用关系确立、分开、结婚或共同生活等非穷举线索,不预设一定发生。`, - fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}如果你愿意,有没有一件关系状态明显变化的经历,比如关系确立、分开、结婚或开始共同生活;如果有,大概是哪年哪月,没有或不想回答可以换一类经历?`, - recallCues: "关系确立、分开、结婚或开始共同生活", - signals: /恋爱|关系|结婚|离婚|分手|伴侣|对象|共同生活/, - recallEase: .62, - privacyCost: .22, - }, - career: { - goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}引导用户回忆一件工作状态变化,用第一次正式入职、离职、换岗、创业或职责增加等非穷举线索,不预设一定发生。`, - fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}有没有一件工作状态明显变化的经历,比如第一次正式入职、离职、换岗、创业或职责明显增加;如果有,大概是哪年哪月,没有或记不清也可以换一类经历?`, - recallCues: "第一次正式入职、离职、换岗、创业或职责明显增加", - signals: /工作|实习|公司|研究院|职业|入职|离职|创业|负责|换岗|职责/, - recallEase: .85, - privacyCost: .03, - }, - finance: { - goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}在用户愿意的前提下,引导回忆一件财务结构变化,用收入来源、负债、购房或重大投资等非穷举线索,不预设一定发生。`, - fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}如果方便,有没有一件财务结构明显变化的经历,比如收入来源改变、开始或还清大额负债、购房或重大投资;如果有,大概是哪年哪月,没有或不想回答可以换一类经历?`, - recallCues: "收入来源改变、开始或还清大额负债、购房或重大投资", - signals: /收入|负债|投资|资产|财务|买房|卖房|购房/, - recallEase: .6, - privacyCost: .18, - }, - health_pressure: { - goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}在用户愿意的前提下,引导回忆一件本人健康或高压状态变化,用手术、住院、事故、确诊或明显恢复等非穷举线索,不预设一定发生。`, - fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}如果方便,你本人有没有一件健康或高压状态明显变化的经历,比如手术、住院、事故、确诊或明显恢复;如果有,大概是哪年哪月,没有或不想回答可以换一类经历?`, - recallCues: "手术、住院、事故、确诊或明显恢复", - signals: /住院|手术|事故|健康|生病|确诊|康复|高压|恢复/, - recallEase: .58, - privacyCost: .28, - }, -}; - function stableUuid(value: string): string { const hex = createHash("sha256").update(value).digest("hex").slice(0, 32).split(""); hex[12] = "4"; @@ -138,8 +80,9 @@ export function buildCandidateContrastPacket(input: Readonly<{ const missingEvidence = (Object.entries(domainScorerRegistry) as [EvidenceDomain, (typeof domainScorerRegistry)[EvidenceDomain]][]) .flatMap(([domain, policy]) => { if (!policy.techniqueLayers.some((layer) => discriminatingLayers.includes(layer))) return []; - const eventKind = policy.supportedKinds.find((kind) => !existingKinds.has(kind)); - return eventKind ? [{ domain, eventKind, reason: "highest_candidate_separation" as const }] : []; + return policy.supportedKinds + .filter((eventKind) => !existingKinds.has(eventKind)) + .map((eventKind) => ({ domain, eventKind, reason: "highest_candidate_separation" as const })); }); return { primaryClusterRank: input.snapshot?.clusters[0]?.rank ?? null, @@ -150,19 +93,6 @@ export function buildCandidateContrastPacket(input: Readonly<{ }; } -function contrastQuestion(eventKind: EventKind, anchor: string | null): Readonly<{ goal: string; fallbackPrompt: string }> | null { - const prefix = anchor ? `在“${anchor}”之外,` : ""; - if (eventKind === "relationship_start") return { - goal: `${prefix}在用户愿意的前提下,询问是否有一段关系正式确立或开始共同生活的经历及其大致年月,不预设一定发生。`, - fallbackPrompt: `${prefix}如果你愿意,有没有一段关系正式确立或开始共同生活的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`, - }; - if (eventKind === "relationship_change") return { - goal: `${prefix}在用户愿意的前提下,询问是否有一段关系状态明显改变的经历及其大致年月,不预设一定发生。`, - fallbackPrompt: `${prefix}如果你愿意,有没有一段关系状态明显改变的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`, - }; - return null; -} - export function buildQuestionOpportunities(input: Readonly<{ caseId: string; events: readonly LifeEventRevision[]; @@ -180,7 +110,6 @@ export function buildQuestionOpportunities(input: Readonly<{ const scoreableDomains = new Set(input.events.filter((event) => event.scoreability === "scoreable").map((event) => event.domain)); const refusedDomains = declinedSensitiveDomains(input.turns); const latestEvent = chronologicalEvents(input.events).at(-1); - const latestContext = input.turns.at(-1)?.answer ?? latestEvent?.rawText ?? ""; const opportunities: QuestionOpportunity[] = []; const contrastPacket = buildCandidateContrastPacket(input); @@ -266,40 +195,37 @@ export function buildQuestionOpportunities(input: Readonly<{ } const scoreableCount = input.events.filter((event) => event.scoreability === "scoreable").length; - for (const [domain, policy] of Object.entries(domainPolicy) as [Exclude, (typeof domainPolicy)[Exclude]][]) { - if (refusedDomains.has(domain)) continue; - const covered = scoreableDomains.has(domain); - const latestEventText = latestEvent ? `${latestEvent.summary} ${latestEvent.rawText}` : latestContext; - const semanticOverlap = Boolean(latestEvent && latestEvent.domain !== domain && policy.signals.test(latestEventText)); - const latestDomainContinuity = latestEvent?.domain === domain ? .22 : 0; - const pendingThemeBonus = !latestEvent && policy.signals.test(latestContext) ? .12 : 0; - const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId); - const latestAnchor = latestEvent ? anchorFor(latestEvent) : null; - const contrastEvidence = contrastPacket?.missingEvidence.find((item) => item.domain === domain) ?? null; - const targetedQuestion = contrastEvidence ? contrastQuestion(contrastEvidence.eventKind, latestAnchor) : null; - opportunities.push(opportunity(input.caseId, { - kind: "ask_new_event", domain, targetEventId: null, goal: targetedQuestion?.goal ?? policy.goal(latestAnchor), - requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [], - contextFacts: [ - `已有 ${scoreableCount} 件可评分事件。`, - `该领域${covered ? "已有覆盖" : "尚未覆盖"}。`, - `可使用${policy.recallCues}作为非穷举回忆线索。`, - "这是存在性询问,不得假定用户一定经历过该事件。", - "只询问一件带大致年月的新事件,不要求用户逐项回答例子。", - "允许用户回答没有、记不清、不想回答或换方向。", - "不得发明年龄或日期窗口,只能引用 anchors 中已确认的经历。", - ...(contrastEvidence ? ["该类证据对当前候选区分力最高,应优先确认是否存在。"] : []), - ...(semanticOverlap ? ["该领域与最新事件语义重叠,必须降低优先级,避免把同一经历换词重问。"] : []), - ], - fallbackPrompt: targetedQuestion?.fallbackPrompt ?? policy.fallbackPrompt(latestAnchor), reason: contrastEvidence ? "补足当前候选分离所需的关键证据。" : covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。", - expectedInformationGain: contrastEvidence ? .95 : covered ? .54 + latestDomainContinuity + pendingThemeBonus : .65 + pendingThemeBonus, - dateSensitivity: input.snapshot ? .5 : .35, - candidateSplitRelevance: contrastEvidence ? .98 : input.diagnostics?.candidateSplits.length ? .58 : .42, - domainCoverageGain: contrastEvidence ? 1 : covered ? 0 : scoreableDomains.size < 2 ? 1 : .15, - recallEase: policy.recallEase, novelty: contrastEvidence ? .95 : semanticOverlap ? .45 : alreadyAsked ? .35 : .9, - repetitionPenalty: contrastEvidence ? 0 : (alreadyAsked ? .3 : 0) + (semanticOverlap ? .2 : 0), privacyCost: policy.privacyCost, - }, contrastEvidence ? .08 : 0)); - } + const latestAnchor = latestEvent ? anchorFor(latestEvent) : null; + const contrastEvidence = contrastPacket?.missingEvidence.filter((item) => !refusedDomains.has(item.domain)) ?? []; + opportunities.push(opportunity(input.caseId, { + kind: "ask_new_event", + domain: "other", + targetEventId: null, + goal: "根据完整事件账本、用户拒答记录和候选差异,自主选择最有区分力且不重复的经历方向,再自然询问一件大致时间明确的新经历;不要按固定领域顺序轮询。", + requestedFields: ["new_dated_event"], + anchors: latestAnchor ? [latestAnchor] : [], + contextFacts: [ + `已有 ${scoreableCount} 件可评分事件。`, + `已覆盖领域:${[...scoreableDomains].sort().join(", ") || "无"}。`, + `已拒绝领域:${[...refusedDomains].sort().join(", ") || "无"}。`, + ...contrastEvidence.map((item) => `候选差异诊断建议优先考虑 ${item.domain}/${item.eventKind} 类型的独立证据;这是策略线索,不是必须照抄的公开问题。`), + "由 Agent 自主决定下一方向和措辞,不使用预写领域问题、关键词命中或测试样例作为脚本。", + "这是存在性询问,不得假定用户一定经历过该事件。", + "只询问一件带大致时间的新事件,不要求用户逐项回答例子。", + "允许用户回答没有、记不清、不想回答或换方向。", + "不得发明年龄或日期窗口,只能引用 anchors 中已确认的经历。", + ], + fallbackPrompt: `${latestAnchor ? `在“${latestAnchor}”之外,` : ""}你愿意再讲一件与已有经历不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。`, + reason: contrastEvidence.length ? "候选差异仍需要新的独立证据,由 Agent 决定最有价值的询问方向。" : "仍需要一件与现有记录不同的独立事件,由 Agent 决定询问方向。", + expectedInformationGain: contrastEvidence.length ? .9 : .65, + dateSensitivity: input.snapshot ? .5 : .35, + candidateSplitRelevance: contrastEvidence.length ? .95 : input.diagnostics?.candidateSplits.length ? .58 : .42, + domainCoverageGain: scoreableDomains.size < 2 ? 1 : .15, + recallEase: .7, + novelty: .9, + repetitionPenalty: 0, + privacyCost: 0, + }, contrastEvidence.length ? .08 : 0)); 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 index 48ba4742..c50c520f 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -84,6 +84,15 @@ type AnalysisPhase = keyof typeof analysisPhaseLabels; const closedTargetDispositions = new Set(["unknown", "declined", "direction_change"]); +export function composeRectificationPublicTurn(message: Pick): string { + const question = message.question?.trim() ?? ""; + const context = [message.acknowledgement, message.evidenceExplanation, message.candidateUpdate, message.limitation].filter((part): part is string => Boolean(part?.trim())).join("\n\n"); + if (!question) return context.slice(0, 1_000); + const availableContext = Math.max(0, 1_000 - question.length - 2); + const prefix = context.slice(0, availableContext).trim(); + return prefix ? `${prefix}\n\n${question}` : question; +} + export function mergeDirectorReconciliation(input: Readonly<{ server: ReconciledV4Evidence; staged: ReconciledV4Evidence; @@ -413,6 +422,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ }); const publicMessage: StoredPublicMessage = { acknowledgement: plan.publicReply.acknowledgement, + evidenceExplanation: plan.publicReply.evidenceExplanation, candidateUpdate: candidateUpdateFor({ snapshot, previousSnapshot: claimed.case.latestSnapshot, decisionAction: decision.action }) ?? plan.publicReply.candidateCommentary, limitation: plan.publicReply.limitation, question: action.type === "ask_question" ? action.question : null, @@ -428,7 +438,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ id: randomUUID(), domain: action.focus.domain ?? targetEvent?.domain ?? "other", targetEventId: action.focus.targetEventId, - prompt: action.question, + prompt: composeRectificationPublicTurn(publicMessage), recallCost: "medium", reason: action.focus.rationaleCodes.join(",").slice(0, 240) || "agent_directed_focus", } : null; diff --git a/frontend/src/lib/rectification-agent/reasoner-agent.ts b/frontend/src/lib/rectification-agent/reasoner-agent.ts index a493161f..dd839400 100644 --- a/frontend/src/lib/rectification-agent/reasoner-agent.ts +++ b/frontend/src/lib/rectification-agent/reasoner-agent.ts @@ -8,6 +8,7 @@ import { chronologicalEvents } from "../rectification-v4/evidence-ledger.ts"; import type { TargetDisposition } from "../rectification-v4/extraction.ts"; import { deterministicDecision } from "./fallback-policy.ts"; import { recordRectificationAgentTelemetry } from "./telemetry.ts"; +import { assertRectificationSkillLoaded } from "./skill-runtime.ts"; import { rectificationDecisionSchema, rectificationDiagnosticSchema, @@ -191,8 +192,10 @@ export async function runBoundedReasoner(input: Readonly<{ 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 isOpenAiProvider = model?.mode === "openai"; + const skillReady = agent ? assertRectificationSkillLoaded(agent, { caseId: input.caseValue.id, modelId, deploymentSha }) : null; const generate: RectificationReasonerGenerator = input.generateDecision ?? (async (prompt) => { - if (!agent) throw new Error("reasoner_model_unavailable"); + if (!agent || !skillReady) throw new Error("reasoner_model_unavailable"); + await skillReady; let reasoningSummary = ""; const stream = await agent.stream(prompt, { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 20_000), diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts index 2d488371..b211aca0 100644 --- a/frontend/src/lib/rectification-agent/renderer-agent.ts +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -5,6 +5,7 @@ import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts"; import { publicMessageSchema, type PublicMessage, type QuestionOpportunity, type ValidatedDecision } from "./contracts.ts"; import { recordRectificationAgentTelemetry } from "./telemetry.ts"; +import { assertRectificationSkillLoaded } from "./skill-runtime.ts"; const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification"); const agents = new Map(); @@ -15,16 +16,6 @@ const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便 const cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']?,?请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对|以[“\"']?.{0,80}[”\"']?为(?:时间)?参照|搬到新城市|长期离乡)/; const exactClockMinute = /(?:[01]?\d|2[0-3])[::][0-5]\d|(?:[零〇一二两三四五六七八九十]{1,3}|(?:[01]?\d|2[0-3]))点(?:[零〇一二两三四五六七八九十]{1,3}|[0-5]?\d)分/; const exactMinuteClaim = /(?:唯一|准确|精确|确切|确认|确定|代表).{0,12}(?:出生|生时)?(?:时间|时刻|分钟)|(?:出生|生时)(?:时间|时刻|分钟)?.{0,12}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/; -const distinctEventMove = /(?:除了|之外|另一(?:件|次)|下(?:一|1)次|之后|后来|此后|还记得)/; -const explicitAnchorReference = /(?:这次经历|这段经历|刚才那段|刚才这段|你刚说的|你刚提到的|刚说的|刚提到的|前面那段|这件事)/; -const newEventDomainTerms: Readonly>> = { - education: /(?:入学|升学|毕业|学校|大学|专业|考试|读书)/, - relocation: /(?:搬家|搬到|搬去|迁居|迁到|迁往|移居|定居)/, - relationship: /(?:恋爱|关系|结婚|离婚|分手|伴侣|对象)/, - career: /(?:工作|实习|公司|研究院|职业|入职|离职|创业|职责|负责)/, - finance: /(?:收入|负债|投资|资产|财务|买房|卖房)/, - health_pressure: /(?:住院|手术|事故|健康|生病|确诊|康复)/, -}; const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict(); const openingMessageSchema = z.object({ message: z.string().trim().min(1).max(1_000) }).strict(); const fixedChoiceStructure = /(?:从|在)[^。!??\n]{1,80}(?:、|,|,|或|或者)[^。!??\n]{1,80}(?:(?:中|里|方面)(?:选|选择|挑|说|讲|开始)|(?:选|选择|挑)(?:一|1)?(?:个|件|段))|按[^。!??\n]{1,80}(?:依次|逐一|分别)(?:回答|说|讲)/; @@ -75,8 +66,10 @@ export async function generateOpeningQuestion(input: Readonly<{ const modelId = selected?.id ?? input.modelId; const started = Date.now(); const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || process.env.VERCEL_GIT_COMMIT_SHA?.trim() || null; + const skillReady = selected ? assertRectificationSkillLoaded(selected.agent, { caseId: input.caseId, modelId, deploymentSha }) : null; const generate = input.generate ?? (async (prompt: string) => { - if (!selected) throw new Error("opening_model_unavailable"); + if (!selected || !skillReady) throw new Error("opening_model_unavailable"); + await skillReady; return selected.agent.generate(prompt, { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), structuredOutput: { schema: openingMessageSchema, jsonPromptInjection: "inline" }, @@ -113,34 +106,11 @@ function normalized(value: string): string { return value.normalize("NFKC").replace(/[“”"'\s,,。.!!??::;;]/g, ""); } -function matchingAnchorFragment(question: string, anchor: string): string | null { - const normalizedQuestion = normalized(question); - const normalizedAnchor = normalized(anchor); - if (!normalizedAnchor) return null; - if (normalizedQuestion.includes(normalizedAnchor)) return normalizedAnchor; - for (let length = Math.min(normalizedQuestion.length, normalizedAnchor.length); length >= 4; length -= 1) { - for (let start = 0; start <= normalizedAnchor.length - length; start += 1) { - const fragment = normalizedAnchor.slice(start, start + length); - if (normalizedQuestion.includes(fragment)) return fragment; - } - } - return null; -} - function includesStrictAnchor(question: string, anchor: string): boolean { const normalizedAnchor = normalized(anchor); return normalizedAnchor.length > 0 && normalized(question).includes(normalizedAnchor); } -function withoutMatchedAnchors(question: string, anchors: readonly string[]): string { - let remaining = normalized(question); - for (const anchor of anchors) { - const matched = matchingAnchorFragment(remaining, anchor); - if (matched) remaining = remaining.replace(matched, ""); - } - return remaining; -} - function visibleTextSafetyIssues(value: string): string[] { const issues: string[] = []; if (internalTerms.test(value)) issues.push("internal_information_exposed"); @@ -161,23 +131,6 @@ export function validateQuestionRealization(question: unknown, opportunity: Ques if (cannedQuestion.test(value)) issues.push("canned_question_forbidden"); if (opportunity.targetEventId) { if (!opportunity.anchors.some((anchor) => includesStrictAnchor(value, anchor))) issues.push("target_anchor_missing"); - } else if (opportunity.kind === "ask_new_event") { - const anchorMatched = opportunity.anchors.some((anchor) => matchingAnchorFragment(value, anchor) !== null); - if (opportunity.anchors.length > 0 && !anchorMatched && !explicitAnchorReference.test(value)) issues.push("target_anchor_missing"); - if (opportunity.anchors.length > 0 && !distinctEventMove.test(value)) issues.push("new_event_not_distinct"); - const domainTerms = newEventDomainTerms[opportunity.domain]; - const questionWithoutAnchor = withoutMatchedAnchors(value, opportunity.anchors); - if (domainTerms && !domainTerms.test(questionWithoutAnchor)) issues.push("new_event_domain_mismatch"); - } - for (const field of opportunity.requestedFields) { - if (field === "event_subject" && !/(?:本人|你自己|家人|伴侣|配偶)/.test(value)) issues.push("event_subject_not_requested"); - if (field === "event_month" && !/(?:月份|哪个月|几月|大概月份|时间段)/.test(value)) issues.push("event_month_not_requested"); - if (field === "event_day" && !/(?:哪一天|几号|具体日期|大概日期)/.test(value)) issues.push("event_day_not_requested"); - if (field === "event_range" && !/(?:大概时间|时间范围|什么时候|哪个时间|哪一段时间)/.test(value)) issues.push("event_range_not_requested"); - if (field === "event_stage" && !/(?:开始|高峰|结束|正式发生)/.test(value)) issues.push("event_stage_not_requested"); - if (field === "new_dated_event" && !/(?:哪次|哪件|一件|经历|变化|转折|发生)/.test(value)) issues.push("new_event_not_requested"); - if (field === "new_dated_event" && !/(?:时间|日期|什么时候|哪年|哪月|几月)/.test(value)) issues.push("new_event_date_not_requested"); - if (field === "event_year" && !/(?:哪年|年份|哪一年)/.test(value)) issues.push("event_year_not_requested"); } return { valid: issues.length === 0, issues }; } @@ -220,6 +173,7 @@ function deterministic(input: { }): PublicMessage { return { acknowledgement: naturalAcknowledgement(input), + evidenceExplanation: null, candidateUpdate: candidateUpdateFor({ snapshot: input.snapshot, previousSnapshot: input.previousSnapshot, decisionAction: input.validated.decision.action }), limitation: input.validated.decision.action === "stop_low_confidence" ? "现有证据不足以安全缩小范围,我会在这里停下,不把不稳定结果包装成确定时间。" @@ -244,6 +198,7 @@ export function realizePublicMessage(value: unknown, input: Parameters }>; + +export async function assertRectificationSkillLoaded( + agent: SkillAgent, + input: Readonly<{ caseId: string; modelId: string | null; deploymentSha: string | null }>, +): Promise { + const started = Date.now(); + try { + if (!await agent.getSkill(skillName)) throw new Error("missing"); + recordRectificationAgentTelemetry({ + caseId: input.caseId, phase: "skill", outcome: "succeeded", modelId: input.modelId, + toolName: null, decisionAction: null, durationMs: Date.now() - started, + errorCode: null, deploymentSha: input.deploymentSha, + skillName, skillVersion: CURRENT_RECTIFICATION_SKILL_VERSION, + promptVersion: CURRENT_RECTIFICATION_PROMPT_VERSION, loadStatus: "loaded", + }); + } catch { + recordRectificationAgentTelemetry({ + caseId: input.caseId, phase: "skill", outcome: "failed", modelId: input.modelId, + toolName: null, decisionAction: null, durationMs: Date.now() - started, + errorCode: "rectification_skill_not_loaded", deploymentSha: input.deploymentSha, + skillName, skillVersion: CURRENT_RECTIFICATION_SKILL_VERSION, + promptVersion: CURRENT_RECTIFICATION_PROMPT_VERSION, loadStatus: "failed", + }); + throw new Error("rectification_skill_not_loaded"); + } +} diff --git a/frontend/src/lib/rectification-agent/telemetry.ts b/frontend/src/lib/rectification-agent/telemetry.ts index ae372731..003323e8 100644 --- a/frontend/src/lib/rectification-agent/telemetry.ts +++ b/frontend/src/lib/rectification-agent/telemetry.ts @@ -2,7 +2,7 @@ import { z } from "zod"; const telemetryEventSchema = z.object({ caseId: z.string().uuid().nullable(), - phase: z.enum(["reasoner", "renderer", "tool", "fallback"]), + phase: z.enum(["reasoner", "renderer", "tool", "fallback", "skill"]), 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(), @@ -10,6 +10,10 @@ const telemetryEventSchema = z.object({ 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(), + skillName: z.string().trim().min(1).max(120).optional(), + skillVersion: z.string().trim().min(1).max(120).optional(), + promptVersion: z.string().trim().min(1).max(120).optional(), + loadStatus: z.enum(["loaded", "failed"]).optional(), }).strict(); export type RectificationAgentTelemetryEvent = z.infer; diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index 1f4d00fe..27f79200 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -32,11 +32,11 @@ export function createRectificationV4CaseService( const generateOpening = options.generateOpeningQuestion ?? generateOpeningQuestion; async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise { - const [events, turns, analysis, job] = await Promise.all([ + const [events, turns, assistantResponses, job] = await Promise.all([ store.loadEvents(userId, caseValue.id), store.loadTurns(userId, caseValue.id), caseValue.deploymentMode === "v5_agent" - ? store.loadAnalysisMessages(userId, caseValue.id) + ? store.loadAssistantResponses(userId, caseValue.id) : Promise.resolve([]), jobId ? store.loadJob(userId, jobId) @@ -47,7 +47,8 @@ export function createRectificationV4CaseService( job, events: [...events], turns: [...turns], - analysis: [...analysis], + analysis: assistantResponses.flatMap((item) => item.trace ? [{ sourceTurnId: item.sourceTurnId, trace: item.trace }] : []), + assistantResponses: [...assistantResponses], }; } diff --git a/frontend/src/lib/rectification-v4/client.ts b/frontend/src/lib/rectification-v4/client.ts index b0fe65ff..034f9c65 100644 --- a/frontend/src/lib/rectification-v4/client.ts +++ b/frontend/src/lib/rectification-v4/client.ts @@ -24,7 +24,7 @@ export class RectificationV4RequestError extends Error { } } -async function json(response: Response, schema: z.ZodType): Promise { +async function json(response: Response, schema: z.ZodType): Promise { const payload = await response.json().catch(() => null); if (!response.ok) { throw new RectificationV4RequestError( diff --git a/frontend/src/lib/rectification-v4/contracts.ts b/frontend/src/lib/rectification-v4/contracts.ts index 2af856c7..16d38338 100644 --- a/frontend/src/lib/rectification-v4/contracts.ts +++ b/frontend/src/lib/rectification-v4/contracts.ts @@ -348,12 +348,29 @@ export const rectificationAnalysisItemSchema = z.object({ }).strict(); export type RectificationAnalysisItem = z.infer; +export const rectificationAssistantMessageSchema = z.object({ + acknowledgement: z.string().trim().min(1).max(1_000), + evidenceExplanation: z.string().trim().min(1).max(1_600).nullable().default(null), + candidateUpdate: z.string().trim().min(1).max(1_000).nullable(), + limitation: z.string().trim().min(1).max(1_000).nullable(), + question: z.string().trim().min(1).max(1_000).nullable(), +}).strict(); +export type RectificationAssistantMessage = z.infer; + +export const rectificationAssistantResponseSchema = z.object({ + sourceTurnId: z.string().uuid(), + message: rectificationAssistantMessageSchema, + trace: rectificationAnalysisTraceSchema.nullable(), +}).strict(); +export type RectificationAssistantResponse = z.infer; + export const rectificationV4ApiResponseSchema = z.object({ case: rectificationV4CaseSchema, job: rectificationV4JobSchema.nullable(), events: z.array(lifeEventRevisionSchema), turns: z.array(rectificationV4TurnSchema), analysis: z.array(rectificationAnalysisItemSchema).optional(), + assistantResponses: z.array(rectificationAssistantResponseSchema).optional(), }).strict(); export type RectificationV4ApiResponse = z.infer; diff --git a/frontend/src/lib/rectification-v4/legacy-projector.ts b/frontend/src/lib/rectification-v4/legacy-projector.ts index 753554a2..382aa7fe 100644 --- a/frontend/src/lib/rectification-v4/legacy-projector.ts +++ b/frontend/src/lib/rectification-v4/legacy-projector.ts @@ -56,6 +56,7 @@ export function projectLegacyV4Turn(input: Readonly<{ : input.latestAnswer ? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。" : "我会继续根据已确认的人生事件比较候选范围。", + evidenceExplanation: null, candidateUpdate: primary ? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。` : null, diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts index 8e02d4be..8684a208 100644 --- a/frontend/src/lib/rectification-v4/memory-store.ts +++ b/frontend/src/lib/rectification-v4/memory-store.ts @@ -76,6 +76,16 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { .sort((left, right) => turns.get(left.turnId)!.caseVersion - turns.get(right.turnId)!.caseVersion) .map((job) => ({ sourceTurnId: job.turnId, trace: publicMessages.get(job.id)!.analysisTrace! })); }, + async loadAssistantResponses(userId, caseId) { + owned(userId, caseId); + return [...jobs.values()] + .filter((job) => job.caseId === caseId && publicMessages.has(job.id)) + .sort((left, right) => turns.get(left.turnId)!.caseVersion - turns.get(right.turnId)!.caseVersion) + .map((job) => { + const { analysisTrace, ...message } = publicMessages.get(job.id)!; + return { sourceTurnId: job.turnId, message, trace: analysisTrace ?? null }; + }); + }, async loadLatestValidatedDecision(userId, caseId) { const caseValue = cases.get(caseId); if (!caseValue || caseValue.userId !== userId) return null; diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts index af9a0f8d..377d6ae7 100644 --- a/frontend/src/lib/rectification-v4/store.ts +++ b/frontend/src/lib/rectification-v4/store.ts @@ -4,6 +4,7 @@ import type { LifeEventRevision, PendingEvidence, RectificationAnalysisItem, + RectificationAssistantResponse, RectificationV4Case, RectificationV4Job, RectificationV4Phase, @@ -59,6 +60,7 @@ export interface RectificationV4Store { loadEvents(userId: string, caseId: string): Promise; loadTurns(userId: string, caseId: string): Promise; loadAnalysisMessages(userId: string, caseId: string): Promise; + loadAssistantResponses(userId: string, caseId: string): Promise; loadLatestValidatedDecision(userId: string, caseId: string): Promise; loadActionCase(userId: string, actionId: string): Promise; createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise; diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index 9e778317..96e9c648 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -5,6 +5,7 @@ import { lifeEventRevisionSchema, pendingEvidenceSchema, rectificationAnalysisItemSchema, + rectificationAssistantResponseSchema, rectificationV4CaseSchema, rectificationV4JobSchema, rectificationV4TurnSchema, @@ -12,6 +13,7 @@ import { type LifeEventRevision, type PendingEvidence, type RectificationAnalysisItem, + type RectificationAssistantResponse, type RectificationV4Case, type RectificationV4Job, type RectificationV4Turn, @@ -26,24 +28,35 @@ import { evidenceSetHash, rectificationFingerprint } from "./fingerprints.ts"; type Row = Record; -export function projectAnalysisMessages( +export function projectAssistantResponses( publicMessageRows: readonly Readonly[], jobRows: readonly Readonly[], -): readonly RectificationAnalysisItem[] { +): readonly RectificationAssistantResponse[] { const turnByJob = new Map(jobRows.map((row) => [String(row.id), row.turn_id])); return [...publicMessageRows] .sort((left, right) => timestamp(left.created_at).localeCompare(timestamp(right.created_at))) .flatMap((row) => { const message = storedPublicMessageSchema.safeParse(row.message); - if (!message.success || !message.data.analysisTrace) return []; - const item = rectificationAnalysisItemSchema.safeParse({ + if (!message.success) return []; + const { analysisTrace, ...publicMessage } = message.data; + const item = rectificationAssistantResponseSchema.safeParse({ sourceTurnId: turnByJob.get(String(row.job_id)), - trace: message.data.analysisTrace, + message: publicMessage, + trace: analysisTrace ?? null, }); return item.success ? [item.data] : []; }); } +export function projectAnalysisMessages( + publicMessageRows: readonly Readonly[], + jobRows: readonly Readonly[], +): readonly RectificationAnalysisItem[] { + return projectAssistantResponses(publicMessageRows, jobRows).flatMap((item) => item.trace + ? [rectificationAnalysisItemSchema.parse({ sourceTurnId: item.sourceTurnId, trace: item.trace })] + : []); +} + function timestamp(value: unknown): string { return value instanceof Date ? value.toISOString() : String(value); } @@ -234,7 +247,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re return ((data ?? []) as Row[]).map(pendingEvidenceValue); } - async function loadAnalysisMessagesByCase(userId: string, caseId: string): Promise { + async function loadAssistantResponsesByCase(userId: string, caseId: string): Promise { if (!await loadCaseById(userId, caseId)) throw new RectificationV4StoreError("not_found"); const { data, error } = await supabase.from("birth_time_rectification_public_messages") .select("job_id,message,created_at").eq("case_id", caseId).eq("user_id", userId) @@ -246,7 +259,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re const { data: jobData, error: jobError } = await supabase.from("birth_time_rectification_v4_jobs") .select("id,turn_id").eq("case_id", caseId).eq("user_id", userId).in("id", jobIds); if (jobError) throw storeError(jobError); - return projectAnalysisMessages(rows, (jobData ?? []) as Row[]); + return projectAssistantResponses(rows, (jobData ?? []) as Row[]); } async function rpc(name: string, args: Row): Promise { @@ -266,7 +279,12 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re loadCase: loadCaseById, loadEvents: loadEventsByCase, loadTurns: loadTurnsByCase, - loadAnalysisMessages: loadAnalysisMessagesByCase, + async loadAnalysisMessages(userId, caseId) { + return (await loadAssistantResponsesByCase(userId, caseId)).flatMap((item) => item.trace + ? [{ sourceTurnId: item.sourceTurnId, trace: item.trace }] + : []); + }, + loadAssistantResponses: loadAssistantResponsesByCase, async loadLatestValidatedDecision(userId, caseId): Promise { const { data, error } = await supabase.from("birth_time_rectification_agent_runs") .select("validated_decision_json").eq("case_id", caseId).eq("user_id", userId) diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index 4bfb1d67..d639e52c 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { canRegenerateRectificationMessage, + rectificationPhaseActivityState, rectificationV4ChatMessages, rectificationPhaseLabel, rectificationProgressLabel, toggleRectificationFeedback, @@ -246,13 +247,13 @@ test("processing follows every server job phase returned by polling", () => { } as const; let data = { ...base, job } as unknown as RectificationV4ApiResponse; const phases = [ - ["extracting_evidence", "正在整理你刚才提到的经历…"], - ["planning_question", "正在生成语义问题机会…"], - ["reasoning", "正在选择下一步动作…"], - ["rendering", "正在生成安全回复…"], + ["extracting_evidence", "正在整理你刚才提到的经历…", "working"], + ["planning_question", "正在生成语义问题机会…", "shaping"], + ["reasoning", "正在选择下一步动作…", "solving"], + ["rendering", "正在生成安全回复…", "composing"], ] as const; - for (const [phase, label] of phases) { + for (const [phase, label, activityState] of phases) { const updated = applyRectificationV4JobUpdate(data, { ...job, phase }); assert.ok(updated); data = updated; @@ -260,12 +261,16 @@ test("processing follows every server job phase returned by polling", () => { assert.equal(message?.role, "assistant"); assert.equal(message?.state, "thinking"); assert.equal(message?.text, rectificationProgressLabel(phase)); - assert.match(message?.text ?? "", new RegExp(label)); + assert.equal(message?.text, label); + assert.equal(message?.activityState, activityState); } assert.equal(rectificationPhaseLabel("checking_robustness"), "正在检查候选范围的稳定性…"); - assert.equal(rectificationProgressLabel("extracting_evidence"), "正在整理你刚才提到的经历…\n正在分析:\n● 整理已确认事件\n○ 比较候选时间差异\n○ 确定下一步验证方向"); - assert.equal(rectificationProgressLabel("checking_robustness"), "正在检查候选范围的稳定性…\n正在分析:\n✓ 整理已确认事件\n● 比较候选时间差异\n○ 确定下一步验证方向"); - assert.equal(rectificationProgressLabel("reasoning"), "正在选择下一步动作…\n正在分析:\n✓ 整理已确认事件\n✓ 比较候选时间差异\n● 确定下一步验证方向"); + assert.equal(rectificationProgressLabel("extracting_evidence"), "正在整理你刚才提到的经历…"); + assert.equal(rectificationProgressLabel("checking_robustness"), "正在检查候选范围的稳定性…"); + assert.equal(rectificationProgressLabel("reasoning"), "正在选择下一步动作…"); + assert.equal(rectificationPhaseActivityState("collecting_evidence"), "listening"); + assert.equal(rectificationPhaseActivityState("scoring_candidates"), "searching"); + assert.doesNotMatch(rectificationProgressLabel("reasoning"), /正在分析:|整理已确认事件|比较候选时间差异|确定下一步验证方向/); }); test("polling ignores an older job response so the visible phase cannot move backward", () => { diff --git a/frontend/tests/rectification-agent-contracts.test.ts b/frontend/tests/rectification-agent-contracts.test.ts index 33d9bc2c..61c1c27b 100644 --- a/frontend/tests/rectification-agent-contracts.test.ts +++ b/frontend/tests/rectification-agent-contracts.test.ts @@ -6,6 +6,7 @@ import { type DiagnosticsSummary, type QuestionOpportunity, } from "../src/lib/rectification-agent/contracts.ts"; +import { assertRectificationSkillLoaded } from "../src/lib/rectification-agent/skill-runtime.ts"; import { recordRectificationAgentTelemetry } from "../src/lib/rectification-agent/telemetry.ts"; const caseId = "00000000-0000-4000-8000-000000000800"; @@ -131,6 +132,28 @@ test("agent telemetry rejects malformed events and warns on failures", () => { assert.match(warnings[0] ?? "", /\[rectification-agent\].*"outcome":"failed"/); }); +test("skill loading emits explicit success or failure telemetry without leaking a path", async () => { + 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 { + await assertRectificationSkillLoaded({ getSkill: async () => ({ name: "birth-time-rectification" }) }, { caseId, modelId: "test-model", deploymentSha: "test-sha" }); + await assert.rejects( + assertRectificationSkillLoaded({ getSkill: async () => null }, { caseId, modelId: "test-model", deploymentSha: "test-sha" }), + /rectification_skill_not_loaded/, + ); + } finally { + console.info = originalInfo; + console.warn = originalWarn; + } + assert.match(info[0] ?? "", /"phase":"skill".*"loadStatus":"loaded"/); + assert.match(warnings[0] ?? "", /"phase":"skill".*"errorCode":"rectification_skill_not_loaded"/); + assert.doesNotMatch([...info, ...warnings].join("\n"), /private\/tmp|SKILL\.md/); +}); + test("durable event semantics migration validates and persists subject fields", () => { const migration = readFileSync(new URL( "../supabase/migrations/20260728010000_conversational_event_semantics.sql", diff --git a/frontend/tests/rectification-agent-v5.test.ts b/frontend/tests/rectification-agent-v5.test.ts index 77050819..c4615d15 100644 --- a/frontend/tests/rectification-agent-v5.test.ts +++ b/frontend/tests/rectification-agent-v5.test.ts @@ -193,17 +193,17 @@ test("SHA-256 canary assignment is stable and deployment modes are explicit", () }), "v4_legacy"); }); -test("opportunities are ordered only by their published utility", () => { - const target = event(); +test("new-event fallback is a single domain-neutral contract", () => { const values = buildQuestionOpportunities({ caseId, - events: [target], + events: [event()], 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)); + }).filter((value) => value.kind === "ask_new_event"); + assert.equal(values.length, 1); + assert.equal(values[0]?.domain, "other"); + assert.doesNotMatch(values[0]?.fallbackPrompt ?? "", /教育|迁居|关系|职业|财务|健康/); }); test("a previously asked unresolved target does not monopolize the next-question route", () => { diff --git a/frontend/tests/rectification-agent-v6.test.ts b/frontend/tests/rectification-agent-v6.test.ts index a85b4e07..5030fd34 100644 --- a/frontend/tests/rectification-agent-v6.test.ts +++ b/frontend/tests/rectification-agent-v6.test.ts @@ -190,75 +190,7 @@ test("稳定候选范围相同不重复提示,实际变化才提示且不确 assert.equal(changed.canConfirmExactMinute, false); }); -test("Builder 的领域排序不受事件输入数组顺序影响", () => { - const education = event(); - const career = event({ domain: "career", eventKind: "career_change", summary: "2023年开始负责商业巡演经纪公司", rawText: "2023年9月开始负责一家商业巡演经纪公司", dateRange: { start: "2023-09-01", end: "2023-09-30", precision: "month", label: "2023年9月" } }); - const domains = (events: readonly LifeEventRevision[]) => buildQuestionOpportunities({ caseId, events, turns: [], snapshot: null, diagnostics: null }).map((item) => [item.kind, item.domain, item.utility]); - assert.deepEqual(domains([education, career]), domains([career, education])); -}); - -test("外地上大学不会被换词提升为迁居问题", () => { - const education = event({ - summary: "离家去外地上大学", - rawText: "2016 年 9 月离家去外地上大学", - }); - const opportunities = buildQuestionOpportunities({ - caseId, - events: [education], - turns: [turn({ answer: education.rawText })], - snapshot: null, - diagnostics: null, - }); - - assert.notEqual(opportunities[0]?.domain, "relocation"); - const relocation = opportunities.find((item) => item.kind === "ask_new_event" && item.domain === "relocation"); - assert.ok(relocation); - assert.doesNotMatch(relocation.fallbackPrompt, /搬到新城市|长期离乡|以.*为(?:时间)?参照/); - assert.match(relocation.fallbackPrompt, /离家去外地上大学.*真正改变居住地点/); - assert.match(relocation.fallbackPrompt, /没有|记不清|换一类经历/); - - const repeated = "以“离家去外地上大学”为时间参照,你哪次搬到新城市或长期离乡的年月最确定?"; - assert.equal(validateQuestionRealization(repeated, relocation).valid, false); - assert.equal(validateQuestionRealization("离家去外地上大学这件事大概发生在哪年哪月?", relocation).valid, false); - assert.equal(validateQuestionRealization("除了离家去外地上大学,你哪次工作变化发生在哪年哪月?", relocation).valid, false); - const message = realizePublicMessage({ acknowledgement: "你提到的是 2016 年 9 月离家去外地上大学。", candidateUpdate: null, limitation: null, question: repeated }, { - latestAnswer: education.rawText, - acceptedEvents: [education], - pendingEvidence: [], - snapshot: null, - previousSnapshot: null, - validated: validated(relocation), - }); - assert.equal(message.question, relocation.fallbackPrompt); -}); - -test("外地上大学场景的机会排序不受事件数组顺序影响", () => { - const education = event({ - summary: "离家去外地上大学", - rawText: "2016 年 9 月离家去外地上大学", - createdAt: "2026-07-29T02:00:00.000Z", - }); - const career = event({ - domain: "career", - eventKind: "career_change", - summary: "开始第一份工作", - rawText: "2019 年 7 月开始第一份工作", - dateRange: { start: "2019-07-01", end: "2019-07-31", precision: "month", label: "2019年7月" }, - createdAt: "2026-07-29T01:00:00.000Z", - }); - const ranked = (events: readonly LifeEventRevision[]) => buildQuestionOpportunities({ - caseId, - events, - turns: [turn({ answer: education.rawText, createdAt: education.createdAt })], - snapshot: null, - diagnostics: null, - }).map((item) => [item.kind, item.domain, item.utility, item.fallbackPrompt]); - - assert.deepEqual(ranked([education, career]), ranked([career, education])); - assert.doesNotMatch(ranked([education, career]).map((item) => item[3]).join("\n"), /搬到新城市|长期离乡|以.*为(?:时间)?参照/); -}); - -test("研究院实习后优先延续最新主题,不被旧教育事件的离家关键词拉回迁居问卷", () => { +test("Builder 只提供领域中立的新事件降级契约,不根据测试语料选题", () => { const education = event({ summary: "离家去外地上大学", rawText: "2016年9月离家去外地上大学", @@ -267,45 +199,64 @@ test("研究院实习后优先延续最新主题,不被旧教育事件的离 const career = event({ domain: "career", eventKind: "career_change", - summary: "去石油化工研究院实习做研究员", - rawText: "2020年4月去石油化工研究院实习做研究员", + summary: "去研究院实习", + rawText: "2020年4月去研究院实习", dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" }, createdAt: "2026-07-29T02:00:00.000Z", }); - const opportunities = buildQuestionOpportunities({ - caseId, - events: [education, career], - turns: [ - turn({ answer: education.rawText, createdAt: "2026-07-29T01:00:00.000Z" }), - turn({ answer: career.rawText, createdAt: "2026-07-29T02:00:00.000Z" }), - ], - snapshot: null, - diagnostics: null, - }); + const collect = (events: readonly LifeEventRevision[]) => buildQuestionOpportunities({ + caseId, events, turns: [], snapshot: null, diagnostics: null, + }).filter((item) => item.kind === "ask_new_event"); - assert.equal(opportunities.some((item) => item.kind === "refine_event_date"), false); - assert.equal(opportunities[0]?.kind, "ask_new_event"); - assert.equal(opportunities[0]?.domain, "career"); - assert.match(opportunities[0]?.fallbackPrompt ?? "", /研究院实习/); - assert.doesNotMatch(opportunities[0]?.fallbackPrompt ?? "", /承接.*请再说一件|哪次搬家、离乡或长期迁居/); + const forward = collect([education, career]); + const reversed = collect([career, education]); + assert.equal(forward.length, 1); + assert.equal(forward[0]?.domain, "other"); + assert.deepEqual(forward, reversed); + assert.match(forward[0]?.goal ?? "", /Agent|自主选择/); + assert.match(forward[0]?.fallbackPrompt ?? "", /愿意再讲一件/); + assert.doesNotMatch([forward[0]?.goal, forward[0]?.fallbackPrompt].join(" "), /复读|住校|搬家|关系确立|石油化工研究院|商业巡演/); }); -test("Renderer 接受锚定最新事件的自然新事件问题并拒绝旧固定模板", () => { +test("Renderer 不再用领域关键词限制 Agent 的新事件选题", () => { + const baseOpportunity = buildQuestionOpportunities({ + caseId, + events: [event()], + turns: [], + snapshot: null, + diagnostics: null, + }).find((item) => item.kind === "ask_new_event"); + assert.ok(baseOpportunity); + + for (const question of [ + "你是否有过获得长期资格或重要证书的经历,大概发生在什么时候?", + "有没有一次你公开发布长期创作项目的经历,大概是哪年哪月?", + "如果你愿意,哪次生活责任明显改变的时间你记得最清楚?", + ]) { + assert.equal(validateQuestionRealization(question, baseOpportunity).valid, true, question); + } + assert.equal(validateQuestionRealization("请说说获得证书的时间?另外哪次创作发布最重要?", baseOpportunity).valid, false); + assert.equal(validateQuestionRealization("请根据 snapshotId 选择最高 score 的事件?", baseOpportunity).valid, false); +}); + +test("Agent 自主问题被保留,不会被服务器替换成领域 fallback", () => { const latest = event({ domain: "career", eventKind: "career_change", - summary: "去石油化工研究院实习做研究员", - rawText: "2020年4月去石油化工研究院实习做研究员", + summary: "去研究院实习", + rawText: "2020年4月去研究院实习", dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" }, }); - const opportunity = buildQuestionOpportunities({ caseId, events: [latest], turns: [turn({ answer: latest.rawText })], snapshot: null, diagnostics: null }) - .find((item) => item.kind === "ask_new_event" && item.domain === "career"); + const opportunity = buildQuestionOpportunities({ caseId, events: [latest], turns: [], snapshot: null, diagnostics: null }) + .find((item) => item.kind === "ask_new_event"); assert.ok(opportunity); - const naturalQuestion = "研究院实习之后,下一次工作发生明显变化大概是什么时候?"; - const canned = `承接“${latest.summary}”,请再说一件时间相对明确的经历:哪次工作变化的时间你比较确定?`; - assert.equal(validateQuestionRealization(naturalQuestion, opportunity).valid, true); - assert.equal(validateQuestionRealization(canned, opportunity).valid, false); - const message = realizePublicMessage({ acknowledgement: `你提到的是“${latest.summary}”。`, candidateUpdate: null, limitation: null, question: naturalQuestion }, { + const question = "有没有一次你取得长期资格或身份变化的经历,大概发生在什么时候?"; + const message = realizePublicMessage({ + acknowledgement: "你提到的是 2020年4月去研究院实习。", + candidateUpdate: null, + limitation: null, + question, + }, { latestAnswer: latest.rawText, acceptedEvents: [latest], pendingEvidence: [], @@ -313,44 +264,7 @@ test("Renderer 接受锚定最新事件的自然新事件问题并拒绝旧固 previousSnapshot: null, validated: validated(opportunity), }); - assert.equal(message.question, naturalQuestion); - assert.notEqual(message.question, opportunity.fallbackPrompt); -}); - -test("ask_new_event 领域验证忽略承接 anchor,拒绝实际询问的跨领域事件", () => { - const latest = event({ - domain: "career", - eventKind: "career_change", - summary: "去石油化工研究院实习做研究员", - rawText: "2020年4月去石油化工研究院实习做研究员", - dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" }, - }); - const opportunity = buildQuestionOpportunities({ caseId, events: [latest], turns: [turn({ answer: latest.rawText })], snapshot: null, diagnostics: null }) - .find((item) => item.kind === "ask_new_event" && item.domain === "career"); - assert.ok(opportunity); - - const result = validateQuestionRealization("研究院实习之后,下一次升学大概发生在什么时候?", opportunity); - assert.equal(result.valid, false); - assert.ok(result.issues.includes("new_event_domain_mismatch")); -}); - -test("ask_new_event 允许明确代词承接并识别教育领域的升学事件", () => { - const latest = event({ - domain: "career", - eventKind: "career_change", - summary: "去石油化工研究院实习做研究员", - rawText: "2020年4月去石油化工研究院实习做研究员", - dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" }, - }); - const baseOpportunity = buildQuestionOpportunities({ caseId, events: [latest], turns: [turn({ answer: latest.rawText })], snapshot: null, diagnostics: null }) - .find((item) => item.kind === "ask_new_event" && item.domain === "career"); - assert.ok(baseOpportunity); - const opportunity: QuestionOpportunity = { ...baseOpportunity, domain: "education" }; - - for (const reference of ["这次经历", "刚才那段", "你刚说的"]) { - const result = validateQuestionRealization(`${reference}之后,下一次升学大概发生在什么时候?`, opportunity); - assert.equal(result.valid, true, `${reference}: ${result.issues.join(",")}`); - } + assert.equal(message.question, question); }); test("targetEventId 非空时只接受完整真实 anchor,不接受代词或四字片段", () => { @@ -450,128 +364,14 @@ test("V8 Director migration advances only unfinished Agent cases", () => { assert.doesNotMatch(migration, /profiles\s*\.\s*active_birth_time|active_birth_time/i); }); -test("新事件机会提供具体回忆线索和退出方式,不把离家上大学换词重问成迁居", () => { - const university = event({ summary: "离家去外地上大学", rawText: "2016年9月离家去外地上大学" }); - const opportunities = buildQuestionOpportunities({ - caseId, - events: [university], - turns: [turn()], - snapshot: null, - diagnostics: null, - }); - const career = opportunities.find((item) => item.kind === "ask_new_event" && item.domain === "career"); - const relocation = opportunities.find((item) => item.kind === "ask_new_event" && item.domain === "relocation"); - assert.ok(career); - assert.ok(relocation); - assert.ok(career.utility > relocation.utility); - assert.match(career.fallbackPrompt, /第一次正式入职|离职|换岗|创业|职责明显增加/); - assert.match(career.fallbackPrompt, /没有|记不清|换一类经历/); - assert.equal((career.fallbackPrompt.match(/[??]/g) ?? []).length, 1); - assert.doesNotMatch(career.fallbackPrompt, /\b20\d{2}\b|\d+岁|A\/B\/C\/D/); - assert.ok(career.contextFacts.some((fact) => /不得假定/.test(fact))); - assert.ok(career.contextFacts.some((fact) => /没有、记不清、不想回答或换方向/.test(fact))); - assert.equal(validateQuestionRealization(career.fallbackPrompt, career).valid, true); -}); - -test("D9 候选差异优先请求缺失的关系事件语义而不是继续领域轮询", () => { - const events = [ - event({ summary: "2015年复读", rawText: "2015年复读" }), - event({ eventId: randomUUID(), domain: "career", eventKind: "career_change", summary: "2020年开始工作", rawText: "2020年开始工作" }), - event({ eventId: randomUUID(), domain: "finance", eventKind: "finance_change", summary: "2026年开始负债", rawText: "2026年开始负债" }), - ]; - const splitDiagnostics = diagnostics({ - candidateSplits: [{ - leftCluster: { start: "05:10", end: "05:14" }, - rightCluster: { start: "05:16", end: "05:20" }, - techniqueLayers: ["D9", "vimshottari"], - eventIds: [], - }], - }); - const currentSnapshot = snapshot(["05:10", "05:14"], { - clusters: [ - { rank: 1, startTime: "05:10", endTime: "05:14", representativeTime: "05:12", widthMinutes: 5, peakScore: 10, scoreMass: .55 }, - { rank: 2, startTime: "05:16", endTime: "05:20", representativeTime: "05:18", widthMinutes: 5, peakScore: 9.8, scoreMass: .45 }, - ], - }); - - const packet = buildCandidateContrastPacket({ events, snapshot: currentSnapshot, diagnostics: splitDiagnostics }); - assert.deepEqual(packet?.missingEvidence[0], { +test("Candidate Contrast 暴露全部缺失证据事实,但不替 Agent 选定公开领域", () => { + const relationshipEnd = event({ domain: "relationship", - eventKind: "relationship_start", - reason: "highest_candidate_separation", + eventKind: "relationship_end", + summary: "一段关系结束", + rawText: "2024年7月一段关系结束", + dateRange: { start: "2024-07-01", end: "2024-07-31", precision: "month", label: "2024年7月" }, }); - assert.deepEqual(packet?.discriminatingLayers, ["D9"]); - - const opportunities = buildQuestionOpportunities({ caseId, events, turns: [], snapshot: currentSnapshot, diagnostics: splitDiagnostics }); - assert.equal(opportunities.some((item) => item.kind === "disambiguate_candidate_split" && item.targetEventId === null), false); - assert.equal(opportunities[0]?.kind, "ask_new_event"); - assert.equal(opportunities[0]?.domain, "relationship"); - assert.ok(opportunities[0]?.contextFacts.some((fact) => /候选区分力最高/.test(fact))); - assert.doesNotMatch(opportunities[0]?.contextFacts.join(" ") ?? "", /D9|05:1/); - - const withStart = buildCandidateContrastPacket({ - events: [...events, event({ eventId: randomUUID(), domain: "relationship", eventKind: "relationship_start", summary: "2022年确定关系", rawText: "2022年确定关系" })], - snapshot: currentSnapshot, - diagnostics: splitDiagnostics, - }); - assert.equal(withStart?.missingEvidence[0]?.eventKind, "relationship_change"); -}); - -test("真实用户回放按候选差异追问关系证据且不泄露内部候选", () => { - const replay = [ - { rawText: "2015年复读", domain: "education", eventKind: "education_milestone" }, - { rawText: "2016年离家去外地上大学", domain: "education", eventKind: "education_milestone" }, - { rawText: "2020年开始工作", domain: "career", eventKind: "career_change" }, - { rawText: "2024年分手", domain: "relationship", eventKind: "relationship_end" }, - { rawText: "2026年开始负债", domain: "finance", eventKind: "finance_change" }, - ] as const; - let events: LifeEventRevision[] = []; - const turns: RectificationV4Turn[] = []; - - replay.forEach((item, index) => { - const sourceTurnId = randomUUID(); - const dateText = item.rawText.slice(0, 5); - const assisted = validatedModelAssistedEvidence({ - rawText: item.rawText, - sourceTurnId, - asOfDate: "2026-07-31", - extraction: { - sourceSpan: item.rawText, - summary: item.rawText.slice(5), - domain: item.domain, - eventKind: item.eventKind, - subject: "self", - relatedPerson: null, - dateText, - }, - }); - assert.ok(assisted); - const reconciled = reconcileV4Evidence({ - caseId, - answer: item.rawText, - sourceTurnId, - asOfDate: "2026-07-31", - existing: events, - assistedEvidence: [assisted], - now: new Date(`2026-07-31T0${index}:00:00.000Z`), - }); - assert.equal(reconciled.pending.length, 0); - events = [...events, ...reconciled.revisions]; - turns.push(turn({ - id: sourceTurnId, - caseVersion: index + 1, - questionDomain: item.domain, - answer: item.rawText, - createdAt: `2026-07-31T0${index}:00:00.000Z`, - })); - }); - - const breakup = events.find((item) => item.eventKind === "relationship_end"); - assert.ok(breakup); - assert.equal(breakup.scoreability, "pending_review"); - assert.equal(events.some((item) => item.eventKind === "relationship_end" && item.scoreability === "scoreable"), false); - assert.equal(events.some((item) => item.domain === "relocation"), false); - const currentSnapshot = snapshot(["05:10", "05:14"], { canAcceptRange: false, gateReasons: ["insufficient_candidate_separation"], @@ -585,28 +385,25 @@ test("真实用户回放按候选差异追问关系证据且不泄露内部候 leftCluster: { start: "05:10", end: "05:14" }, rightCluster: { start: "05:16", end: "05:20" }, techniqueLayers: ["D9", "vimshottari"], - eventIds: [breakup.eventId], + eventIds: [relationshipEnd.eventId], }], }); - const packet = buildCandidateContrastPacket({ events, snapshot: currentSnapshot, diagnostics: splitDiagnostics }); - assert.equal(packet?.missingEvidence[0]?.eventKind, "relationship_start"); - const opportunities = buildQuestionOpportunities({ caseId, events, turns, snapshot: currentSnapshot, diagnostics: splitDiagnostics }); - const next = opportunities[0]; + const packet = buildCandidateContrastPacket({ events: [relationshipEnd], snapshot: currentSnapshot, diagnostics: splitDiagnostics }); + assert.ok(packet?.missingEvidence.some((item) => item.eventKind === "relationship_start")); + assert.ok(packet?.missingEvidence.some((item) => item.eventKind === "relationship_change")); + + const next = buildQuestionOpportunities({ caseId, events: [relationshipEnd], turns: [], snapshot: currentSnapshot, diagnostics: splitDiagnostics }) + .find((item) => item.kind === "ask_new_event"); assert.ok(next); - assert.equal(next.kind, "ask_new_event"); - assert.equal(next.domain, "relationship"); - assert.match(next.fallbackPrompt, /关系正式确立|开始共同生活/); - assert.match(next.fallbackPrompt, /没有/); - assert.match(next.fallbackPrompt, /不知道/); - assert.match(next.fallbackPrompt, /不想回答/); - assert.match(next.fallbackPrompt, /换方向/); - assert.doesNotMatch(next.fallbackPrompt, /搬家|迁居|离乡|外地/); - assert.doesNotMatch([next.goal, next.fallbackPrompt, ...next.contextFacts].join(" "), /D9|vimshottari|05:1[037]/i); - assert.equal(validateQuestionRealization(next.fallbackPrompt, next).valid, true); + assert.equal(next.domain, "other"); + const fallbackWithoutAnchor = next.fallbackPrompt.replace("一段关系结束", ""); + assert.doesNotMatch(fallbackWithoutAnchor, /关系|恋爱|分手|D9|05:1[037]/i); + assert.match(next.contextFacts.join(" "), /relationship_start/); }); -test("研究院实习后的迁居机会以存在性问题主动引导,不要求用户自己发明事件", () => { +test("真实回放不会把大学和实习样例固化为下一问模板", () => { + const education = event({ summary: "离家去外地上大学", rawText: "2016年9月离家去外地上大学" }); const internship = event({ domain: "career", eventKind: "career_change", @@ -614,18 +411,11 @@ test("研究院实习后的迁居机会以存在性问题主动引导,不要 rawText: "2020年4月去石油化工研究院实习做研究员", dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" }, }); - const relocation = buildQuestionOpportunities({ - caseId, - events: [internship], - turns: [], - snapshot: null, - diagnostics: null, - }).find((item) => item.kind === "ask_new_event" && item.domain === "relocation"); - assert.ok(relocation); - assert.match(relocation.fallbackPrompt, /有没有一件/); - assert.match(relocation.fallbackPrompt, /独立搬家|住校|另一座城市长期生活/); - assert.match(relocation.fallbackPrompt, /大概是哪年哪月/); - assert.match(relocation.fallbackPrompt, /没有|记不清|换一类经历/); - assert.doesNotMatch(relocation.fallbackPrompt, /你还记得哪次独立搬家|请继续讲另一件/); - assert.equal(validateQuestionRealization(relocation.fallbackPrompt, relocation).valid, true); + const next = buildQuestionOpportunities({ caseId, events: [education, internship], turns: [], snapshot: null, diagnostics: null }) + .find((item) => item.kind === "ask_new_event"); + assert.ok(next); + assert.equal(next.domain, "other"); + const fallbackWithoutAnchor = next.anchors.reduce((text, anchor) => text.replace(anchor, ""), next.fallbackPrompt); + assert.doesNotMatch(fallbackWithoutAnchor, /大学|实习|研究院|搬家|关系|工作|健康|财务/); + assert.equal(validateQuestionRealization("有没有一次你取得长期资格或公开发布作品的经历,大概发生在什么时候?", next).valid, true); }); diff --git a/frontend/tests/rectification-analysis-trace.test.ts b/frontend/tests/rectification-analysis-trace.test.ts index 571e1195..fda6aee5 100644 --- a/frontend/tests/rectification-analysis-trace.test.ts +++ b/frontend/tests/rectification-analysis-trace.test.ts @@ -557,6 +557,41 @@ test("a newly collected year-only event stays current until its month is refined assert.equal(created.dateRange.precision, "year"); assert.equal(result.nextQuestion?.targetEventId, created.eventId); assert.match(result.nextQuestion?.prompt ?? "", /离家去外地上大学/); + assert.match(result.nextQuestion?.prompt ?? "", /交叉比较候选范围/); assert.match(result.nextQuestion?.prompt ?? "", /哪个月|时间段/); assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /还能想到一件/); }); + +test("a completed internship answer is acknowledged, explained, and followed by one contrast-driven domain question", async () => { + const university = event("education", "education_milestone", "离家去外地上大学", "2016-09"); + const base = makeClaimed([university]); + const earlierTurn: RectificationV4Turn = { + ...base.turn, + id: randomUUID(), + caseVersion: 1, + questionDomain: "education", + question: "请说一件时间比较确定的经历。", + answer: university.rawText, + }; + const turn: RectificationV4Turn = { + ...base.turn, + id: randomUUID(), + caseVersion: 2, + questionDomain: "career", + question: "有没有一件工作状态明显变化的经历?", + answer: "2020 年 4 月去石油化工研究院实习做研究员", + }; + const result = await processRectificationAgentTurn({ + claimed: { ...base, turn, turns: [earlierTurn, turn] }, + engine: { score: async () => { throw new Error("candidate_engine_should_not_run"); } }, + now: new Date(now), + }); + + assert.equal(result.newEventRevisions.at(-1)?.domain, "career"); + assert.notEqual(result.nextQuestion?.domain, "education"); + assert.notEqual(result.nextQuestion?.domain, "career"); + assert.match(result.nextQuestion?.prompt ?? "", /石油化工研究院|实习|研究员/); + assert.match(result.nextQuestion?.prompt ?? "", /交叉比较候选范围/); + assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /你还能想到一件发生在你本人身上、时间大致确定的重要经历吗/); + assert.equal((result.nextQuestion?.prompt.match(/[??]/g) ?? []).length, 1); +}); diff --git a/frontend/tests/rectification-director.test.ts b/frontend/tests/rectification-director.test.ts index 8cc93a7e..57fb0e18 100644 --- a/frontend/tests/rectification-director.test.ts +++ b/frontend/tests/rectification-director.test.ts @@ -4,7 +4,7 @@ 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 { mergeDirectorReconciliation } from "../src/lib/rectification-agent/orchestrator.ts"; +import { composeRectificationPublicTurn, mergeDirectorReconciliation } from "../src/lib/rectification-agent/orchestrator.ts"; import type { CalculationSpec, CandidateSnapshot, LifeEventRevision, PendingEvidence, 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"; @@ -105,9 +105,11 @@ function plan(overrides: Partial = {}): RectificationTurn }, publicReply: { acknowledgement: "我已按你的描述整理这轮线索。", + evidenceExplanation: null, candidateCommentary: null, limitation: "目前仍不足以确认具体出生分钟。", }, + publicExplanationGrounding: [], ...overrides, }; } @@ -174,6 +176,58 @@ test("dossier keeps recent raw turns, useful earlier context, refusals, pending assert.equal(value.case.location.timezoneId, "Asia/Shanghai"); }); +test("a declined domain is a server-owned boundary, not a keyword rule", () => { + const turns = [turn(0, { + questionDomain: "relationship", + question: "如果你愿意,可以聊聊一段关系变化吗?", + answer: "不想回答,换个方向。", + })]; + const value = dossier([], turns); + assert.deepEqual(value.interviewState.declinedDomains, ["relationship"]); + + const reopened = plan({ + action: { + type: "ask_question", + focus: { + mode: "collect_independent_event", + targetEventId: null, + domain: "relationship", + requestedFacts: ["independent_event"], + rationaleCodes: ["agent_selected_direction"], + }, + question: "有没有一段关系状态变化的经历?", + optionalQuickReplies: [], + }, + }); + assert.ok(validateRectificationTurnPlan({ + plan: reopened, + dossier: value, + latestAnswer: turns[0]!.answer, + phase: "final", + }).issues.includes("declined_domain_reopened")); + + const changedDirection = plan({ + action: { + type: "ask_question", + focus: { + mode: "collect_independent_event", + targetEventId: null, + domain: null, + requestedFacts: ["independent_event"], + rationaleCodes: ["agent_selected_direction"], + }, + question: "你愿意从另一段时间大致明确的经历继续吗?", + optionalQuickReplies: [], + }, + }); + assert.deepEqual(validateRectificationTurnPlan({ + plan: changedDirection, + dossier: value, + latestAnswer: turns[0]!.answer, + phase: "final", + }).issues, []); +}); + test("a natural question and multiple grounded event proposals pass without domain keywords or anchors", () => { const latestAnswer = "2018年9月搬到北京,2020年4月开始第一份工作。"; const value = plan({ @@ -193,7 +247,7 @@ test("server rejects invented sources, private details, exact minutes, and ungat 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 unsafe = plan({ publicReply: { acknowledgement: "内部 eventId 是 00000000-0000-4000-8000-000000000799。", evidenceExplanation: null, 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")); @@ -421,6 +475,41 @@ test("the same Director gets one repair attempt before deterministic fallback", }); +test("final Director repairs a generic acknowledgement and missing evidence-value explanation", async () => { + const internship = event({ + domain: "career", + eventKind: "career_change", + summary: "2020年4月去石油化工研究院实习做研究员", + rawText: "2020年4月去石油化工研究院实习做研究员", + dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" }, + }); + const phases: string[] = []; + const prompts: string[] = []; + const result = await runRectificationDirector({ + caseValue, + dossier: dossier([internship]), + latestAnswer: internship.rawText, + phase: "final", + diagnostics, + generatePlan: async (prompt, phase) => { + phases.push(phase); + prompts.push(prompt); + return { + object: phase === "repair" + ? plan({ publicReply: { acknowledgement: "你提到2020年4月去石油化工研究院实习做研究员。", evidenceExplanation: "这是一条职业状态变化线索,按能力矩阵可参考 D10 与 Vimshottari;目前只是方法映射,不是候选结论。", candidateCommentary: "这段经历的时间和工作状态变化都很明确,可以和其他独立事件交叉比较候选范围。", limitation: null } }) + : plan(), + }; + }, + }); + + assert.equal(result.mode, "agent"); + assert.deepEqual(phases, ["final", "repair"]); + assert.match(JSON.parse(prompts[0]!).publicReplyRequirement, /acknowledge the exact event/); + assert.deepEqual(JSON.parse(prompts[1]!).validationIssues, ["event_acknowledgement_generic", "event_explanation_missing", "event_value_commentary_missing"]); + assert.match(result.plan.publicReply.acknowledgement, /石油化工研究院/); + assert.match(result.plan.publicReply.candidateCommentary ?? "", /交叉比较候选范围/); +}); + test("manual question regeneration preserves focus and repairs unsafe text once", async () => { const phases: string[] = []; const question = await regenerateDirectorQuestion({ @@ -445,14 +534,24 @@ test("manual question regeneration preserves focus and repairs unsafe text once" }); -test("public reply rejects technique names and multiple independent questions without rejecting one natural question", () => { - for (const technique of ["D2", "D4", "D9", "D10", "D11", "D24", "D30", "KP", "Vimshottari", "Narayana", "Shadbala", "Ashtakavarga"]) { +test("public reply allows method names but rejects private internals and ungrounded numeric structure claims", () => { + for (const technique of ["D2", "D4", "D9", "D10", "D11", "D24", "D30", "4 宫", "宫位", "分盘", "上升星座", "Chaturvimshamsha", "KP", "Vimshottari", "Narayana", "Shadbala", "Ashtakavarga"]) { const issues = validateRectificationTurnPlan({ - plan: plan({ publicReply: { acknowledgement: `${technique} 更支持这段经历。`, candidateCommentary: null, limitation: null } }), + plan: plan({ publicReply: { acknowledgement: "这条经历已经保留。", evidenceExplanation: `${technique} 是这类事件可参考的公开方法层;目前只是方法映射。`, candidateCommentary: null, limitation: null } }), dossier: dossier(), latestAnswer: "", phase: "final", }).issues; - assert.ok(issues.includes("private_detail_exposed"), technique); + assert.ok(!issues.includes("private_detail_exposed"), technique); } + for (const privateDetail of ["原始评分 8.7", "权重 0.4", "贡献矩阵如下", "tool_call 原始输出"]) { + const issues = validateRectificationTurnPlan({ + plan: plan({ publicReply: { acknowledgement: "这条经历已经保留。", evidenceExplanation: privateDetail, candidateCommentary: null, limitation: null } }), + dossier: dossier(), latestAnswer: "", phase: "final", + }).issues; + assert.ok(issues.includes("private_detail_exposed"), privateDetail); + } + const numeric = plan({ publicReply: { acknowledgement: "这条经历已经保留。", evidenceExplanation: "D24 在候选窗内切换了 3 次。", candidateCommentary: null, limitation: null } }); + assert.ok(validateRectificationTurnPlan({ plan: numeric, dossier: dossier(), latestAnswer: "", phase: "final" }).issues.includes("ungrounded_numeric_structure_claim")); + const multiple = plan({ action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: "你记得它发生在哪一年。那时发生了什么。", optionalQuickReplies: [] } }); assert.ok(validateRectificationTurnPlan({ plan: multiple, dossier: dossier(), latestAnswer: "", phase: "final" }).issues.includes("multiple_questions")); const single = plan({ action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: "你还记得一件时间大致确定的重要经历吗?", optionalQuickReplies: [] } }); @@ -547,3 +646,66 @@ test("deterministic fallback refines the known year before collecting another ev assert.match(result.plan.action.question, /哪个月|时间段/); assert.doesNotMatch(result.plan.action.question, /另一件|还能想到一件/); }); + +test("deterministic fallback stays domain-neutral when the Agent is unavailable", async () => { + const university = event({ + summary: "2016年9月离家去外地上大学", + rawText: "2016年9月离家去外地上大学", + createdAt: "2026-07-30T01:00:00.000Z", + }); + const internship = event({ + domain: "career", + eventKind: "career_change", + summary: "2020年4月去石油化工研究院实习做研究员", + rawText: "2020年4月去石油化工研究院实习做研究员", + dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" }, + createdAt: "2026-07-30T02:00:00.000Z", + }); + const turns = [ + turn(0, { questionDomain: "education", question: "请说一件时间比较确定的经历。", answer: university.rawText }), + turn(1, { questionDomain: "career", question: "你是否有过工作状态变化?", answer: internship.rawText }), + ]; + + const result = await runRectificationDirector({ + caseValue, + dossier: buildRectificationCaseDossier({ + caseValue, + turns, + events: [university, internship], + snapshot: null, + diagnostics: null, + targetDisposition: "not_applicable", + currentTargetEventId: null, + }), + latestAnswer: internship.rawText, + phase: "final", + diagnostics, + generatePlan: async () => { throw new Error("forced_fallback"); }, + }); + + assert.equal(result.mode, "deterministic_fallback"); + assert.equal(result.plan.action.type, "ask_question"); + if (result.plan.action.type !== "ask_question") return; + assert.equal(result.plan.action.focus.targetEventId, null); + assert.equal(result.plan.action.focus.domain, null); + assert.deepEqual(result.plan.action.focus.rationaleCodes, ["model_unavailable_neutral_fallback"]); + assert.match(result.plan.action.question, /愿意再讲一件/); + assert.match(result.plan.action.question, /没有|记不清|不想回答|换/); + assert.doesNotMatch(result.plan.action.question, /教育|迁居|关系|职业|财务|健康|大学|实习|搬家/); + assert.match(result.plan.publicReply.acknowledgement, /2020年4月|石油化工研究院|实习|研究员/); +}); + +test("the visible assistant turn includes acknowledgement, evidence value, and exactly one question", () => { + const text = composeRectificationPublicTurn({ + acknowledgement: "你提到的是“2020年4月去石油化工研究院实习做研究员”。", + evidenceExplanation: "这条职业变化可参考 D10 与 Vimshottari;目前只是方法映射。", + candidateUpdate: "这条线索有明确时间,也说明了具体发生的变化,可以和其他独立经历交叉比较候选范围。", + limitation: null, + question: "如果你愿意,有没有一次长期资格或身份发生变化的经历,大概是哪年哪月?", + }); + + assert.match(text, /石油化工研究院/); + assert.match(text, /交叉比较候选范围/); + assert.match(text, /长期资格|身份发生变化/); + assert.equal((text.match(/[??]/g) ?? []).length, 1); +}); diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index 0c04a6d0..5064f497 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import test from "node:test"; +import { composeRectificationPublicTurn } from "../src/lib/rectification-agent/orchestrator.ts"; import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts"; import type { CalculationSpec, CandidateSnapshot, LifeEventRevision, PendingEvidence } from "../src/lib/rectification-v4/contracts.ts"; import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts"; @@ -174,7 +175,10 @@ test("V5 agent fallback persists the Director decision, Public Message and next assert.equal(done?.case.currentQuestion?.targetEventId, null); 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.currentQuestion?.prompt, composeRectificationPublicTurn(message)); + assert.match(done?.case.currentQuestion?.prompt ?? "", /离家去外地上大学/); + assert.match(done?.case.currentQuestion?.prompt ?? "", /交叉比较候选范围/); + assert.ok(message.question && done?.case.currentQuestion?.prompt.includes(message.question)); assert.equal(done?.case.latestSnapshot, null); })); diff --git a/skills/birth-time-rectification/SKILL.md b/skills/birth-time-rectification/SKILL.md index a8841887..b413e832 100644 --- a/skills/birth-time-rectification/SKILL.md +++ b/skills/birth-time-rectification/SKILL.md @@ -56,7 +56,7 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re 1. During evidence interpretation, read the complete Case Dossier: recent raw turns, full revision ledger, current target disposition, pending evidence, candidate contrasts, event sensitivity, and range gate. During final planning, do not assume those server-owned views are already loaded; request only the focused tool observations needed for the decision. 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. Read focused server observations through `case_read`, `candidate_scan`, `evidence_gap`, or `diagnostic_read`; each result advances the in-run Dossier revision and must inform the next decision. Stop the loop as soon as the evidence supports one useful question, a gated range, or an honest low-confidence result. Do not rotate through domains or ask for finer dates unless the observations 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. +4. Write the public reply and one short natural question in the same TurnPlan. When the latest answer adds or refines a concrete event, first summarize that event in the user's own terms, then add one short public-safe sentence explaining why its date and type of change help compare candidate ranges, and only then ask the question. Do not expose internal IDs, scores, contribution details, tools, candidate minutes, chart divisions, houses, or technique names. 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. @@ -72,7 +72,7 @@ A user who answers with a different complete event may have that event saved wit ## Public language -Use a brief acknowledgement tied to the user's actual event, an optional gated candidate update, an optional limitation, and at most one question. Do not repeat an unchanged range, over-interpret the event, or turn sparse/conflicting evidence into certainty. +Use a brief acknowledgement tied to the user's actual event, a brief high-level explanation of why that event helps candidate comparison when an event was added or refined, an optional gated candidate update or limitation, and at most one question. The explanation should describe only observable features such as date precision, event type, or cross-event comparison; it must not name D-charts, houses, astrology techniques, scores, candidate minutes, or hidden reasoning. Do not repeat an unchanged range, over-interpret the event, or turn sparse/conflicting evidence into certainty. ## Analysis process receipt diff --git a/skills/birth-time-rectification/references/failure-policy.md b/skills/birth-time-rectification/references/failure-policy.md index d6e621f2..3fd9db4c 100644 --- a/skills/birth-time-rectification/references/failure-policy.md +++ b/skills/birth-time-rectification/references/failure-policy.md @@ -3,7 +3,7 @@ ## Conversation failures - Invalid Reasoner output, an unavailable model, or exhausted diagnostic budget uses the deterministic server policy. -- Invalid Renderer output uses the selected opportunity's validated `fallbackPrompt`. +- Invalid V8 Director output gets one repair attempt, then uses a server-owned fallback. Without a current target the fallback is domain-neutral; with a current target it asks only the necessary anchored factual clarification. Legacy Renderer paths may still use a validated opportunity fallback for compatibility. - A failed or unavailable model-assisted event extraction leaves deterministic extraction and pending evidence intact; it must not fabricate an event or date. - `unknown`, `declined`, and `direction_change` are valid conversation outcomes, not parsing failures and not life events. - After a refusal or direction change, close the target and do not repeat it. diff --git a/skills/birth-time-rectification/references/output-contract.md b/skills/birth-time-rectification/references/output-contract.md index 752055f7..54633444 100644 --- a/skills/birth-time-rectification/references/output-contract.md +++ b/skills/birth-time-rectification/references/output-contract.md @@ -15,20 +15,24 @@ Public output keeps the existing shape: Use at most one or two short sentences and refer to the user's concrete experience. Do not repeatedly begin with “已记录” or “我记下了”. Do not use “这个信息很有用”, “它不是单纯的……”, “而是把……”, “接下来最有价值的是……”, or “这样可以避免……”. Do not interpret an ordinary event as a confirmed life turning point. +## Evidence-value explanation + +When the latest answer adds or refines a concrete event and the final action asks another question, include one short explanation before the question. Explain only why the event's date precision, event type, or relationship to other independent events helps compare candidate ranges. This explanation is required for such turns, but it must remain high-level: do not name chart divisions, houses, astrology techniques, scores, candidate minutes, or hidden reasoning. + ## Question -When a validated opportunity is selected, `question` is required; otherwise it is `null`. The question must: +For V8, the Director independently chooses the focus and wording from the complete dossier, declined domains, candidate contrast observations, and read-only tool results. The server does not select a domain from a fixed rotation, require domain keywords, or substitute a prewritten domain prompt. `question` is required only when the final action is `ask_question`; otherwise it is `null`. + +The question must: - be 8-180 characters, at most two sentences, and contain at most one question mark; -- ask one thing only and match the opportunity's requested fields; -- include a valid anchor when `targetEventId` is present; -- ask self/family/partner only for `event_subject`; -- ask month, approximate month, or range for `event_month`; -- ask start, peak, end, or formal stage for `event_stage`; -- ask for one new roughly dated event for `new_dated_event`; +- ask one thing only and remain consistent with the structured focus; +- include a valid full event anchor when `targetEventId` is present; +- avoid reopening a declined event or declined domain; +- ask about one possible new event without presuming it occurred when the focus is independent evidence; - contain no internal ID/field, score, snapshot, opportunity, tool call, model name, technique trace such as `D9`/`D60`, or unapproved `HH:MM` birth time. -Reject multi-question transitions such as “另外”, “还有”, “同时再说”, or “并且告诉我” when they introduce another request. On validation failure, use the selected opportunity's short, anchored `fallbackPrompt`. +Validate semantic consistency and safety, not fixed Chinese words. Reject multi-question transitions such as “另外”, “还有”, “同时再说”, or “并且告诉我” only when they introduce another request. When no current target exists and the model is unavailable, use a domain-neutral recovery question; when a current target requires a factual clarification, the fallback may ask only that necessary anchored fact. ## Candidate update @@ -43,7 +47,7 @@ Do not describe LOEO/LODO as an independent holdout, prospective validation, or ## Deterministic fallback -Fallback follows the same public rules as model output: acknowledge the actual event naturally, ask one anchored question, avoid repetition and over-interpretation, and never claim exact-minute certainty. +Fallback follows the same public rules as model output: acknowledge the actual event naturally, briefly explain its public-safe evidence value when present, ask one anchored question, avoid repetition and over-interpretation, and never claim exact-minute certainty. ## Persisted analysis process diff --git a/skills/birth-time-rectification/references/question-policy.md b/skills/birth-time-rectification/references/question-policy.md index 2337e977..87506e66 100644 --- a/skills/birth-time-rectification/references/question-policy.md +++ b/skills/birth-time-rectification/references/question-policy.md @@ -1,21 +1,20 @@ # Question policy -## Semantic opportunities +## Agent-directed focus -Question opportunities describe meaning, not final prose. New opportunities use `semantic-question-v2` and carry a goal, requested fields, anchors, context facts, forbidden moves, a natural fallback prompt, utility inputs, target event, and active state. Historical opportunities with only `prompt` remain readable by normalizing that text to `fallbackPrompt`. +In V8, the dossier and read-only tools expose facts, constraints, and candidate-contrast observations. The Director decides which evidence direction is most useful and writes the question. The server must not rotate through a fixed domain list, rank domains with hand-authored recall/privacy weights, require domain keywords, or turn test transcripts and example events into production scripts. -The builder produces several candidates and publishes at most five active opportunities. Rank them by evidence and context: expected information gain, candidate-split relevance, date sensitivity, domain coverage, recent user topics, recall ease, novelty, repetition penalty, and privacy cost. Never select the first missing domain from a fixed education/relocation/relationship/career/finance/health sequence. +Candidate contrast may identify discriminating technique layers and all supported missing event kinds. Treat these as observations the Agent can weigh against the complete event ledger, recent conversation, refusals, privacy, and expected value. They are not a server-selected question and must not be copied mechanically into public wording. -Use the latest answer and latest accepted event as the current topic. Do not let keywords from older turns pull the conversation back to a stale domain, and do not give an uncovered domain both a coverage reward and a second topic reward from the same older event. Once the minimum domain coverage is already present, continuity and information gain should outweigh collecting another domain merely because it is missing. +Legacy semantic opportunities remain readable for compatibility and deterministic target clarification. Their `fallbackPrompt` is a failure-recovery surface, not the normal V8 topic selector. A no-target fallback must stay domain-neutral; a targeted fallback may ask only the server-known missing fact for that event. -### New-event existence and recall cues +### New-event questions -- Ask whether a relevant event exists before asking for its details. Use an existence form such as “过去是否有过……” or “如果有……”, not a presuppositional form that implies the user must have had that event. -- Offer 2–5 concrete recall cues as non-exhaustive examples. Make it explicit that they are examples, allow any other relevant event, and allow the user to say that none occurred. -- Recall cues may name ordinary event types supported by the selected domain, but must not assert that any cue happened to this user. -- Do not invent an age, life stage, year, month, date range, or relative time window. A time or age may appear only when it already comes from accepted user evidence or another server-owned fact allowed by the opportunity contract. -- Compare every new-event opportunity with the latest accepted event even when their domain labels differ. If they overlap semantically in subject, action, transition, or outcome, apply a utility penalty before ranking. If the candidate is merely a cross-domain paraphrase of the latest event, suppress it instead of asking the same event again with different words. -- Domain coverage must not override semantic continuity or duplicate-event protection. A missing domain is not sufficient reason to ask a semantically overlapping question. +- Ask whether one relevant event exists; do not imply the user must have experienced a particular category. +- The Agent may use its own natural recall cues when useful, but examples are non-exhaustive and never server-required keywords. +- Do not invent an age, life stage, year, month, date range, or relative time window. A time or age may appear only when it already comes from accepted user evidence or another server-owned fact. +- Compare a proposed direction with the complete event ledger so the next question does not paraphrase an event already supplied. +- A missing domain alone is not a reason to ask about it. If no safe, useful question remains, stop with low confidence. ## One-turn rule