From 32aa0ecbac9b66220ef2cb0be055b4b2e8dd5828 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 31 Jul 2026 22:46:38 +0800 Subject: [PATCH] fix(rectification): enforce grounded public responses --- docs/BUG_HISTORY.md | 16 ++ .../lib/rectification-agent/director-agent.ts | 219 +++++++++-------- .../lib/rectification-agent/orchestrator.ts | 2 +- .../src/lib/rectification-v4/case-service.ts | 36 +-- .../rectification-analysis-trace.test.ts | 4 +- frontend/tests/rectification-director.test.ts | 226 +++++++++++------- .../tests/rectification-v4-service.test.ts | 80 ++++++- 7 files changed, 370 insertions(+), 213 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 8b1034aa..1c142fe7 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1912,3 +1912,19 @@ - 防复发:服务器只提供事实、能力和禁止项;正常 V8 选题与措辞归 Agent。测试案例只能验证不变量和复现回归,不能通过词表、权重或预写问题进入生产决策链。 - 相关记录:BUG-105、BUG-106、BUG-107 - 修复版本:staging branch / deployment manifest records exact SHA + +## BUG-109 | 生时校正模型文案可越过公开事实边界 + +- 状态:resolved +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V5 Director 公开回复、下一问生成与手动重新生成 +- 用户现象:模型可能直接追问、遗漏服务器承接说明,或把未经计算的候选优劣写入公开问题。 +- 触发条件:Director 返回自由文案,或历史 `selectedOpportunity` 进入问题重新生成路径。 +- 根因:公开回复和问题 wording 曾由模型直接提供;历史兼容分支还可绕过新 focus 合同调用 renderer。 +- 修复:Agent 只选择结构化 focus;服务器根据事件账本、能力矩阵和 focus 生成承接、方法说明及单一问题;历史 `selectedOpportunity` 先转换为 focus,所有重新生成路径共用同一服务器 renderer。 +- 验证:Director、analysis trace、V4 service 聚焦测试 52/52;TypeScript、ESLint 通过;前端全量测试 1164/1164。 +- 防复发:回归测试覆盖模型伪造候选结论、手动 regenerate 以及历史 `selectedOpportunity` 持久化路径。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/lib/rectification-agent/director-agent.ts b/frontend/src/lib/rectification-agent/director-agent.ts index 4b11b0e0..5a9a5263 100644 --- a/frontend/src/lib/rectification-agent/director-agent.ts +++ b/frontend/src/lib/rectification-agent/director-agent.ts @@ -1,13 +1,12 @@ import path from "node:path"; import { Agent } from "@mastra/core/agent"; -import { z } from "zod"; import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts"; import type { TargetDisposition } from "../rectification-v4/extraction.ts"; import { 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 { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type QuestionOpportunity, type RectificationAgentTool, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationFocus, 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"); @@ -16,11 +15,7 @@ const kinds: EventKind[] = ["education_milestone", "relocation", "relationship_s 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|\b(?:case_read|candidate_scan|evidence_gap|diagnostic_read)\b|工具原始(?:输出|轨迹)|内部推理链)/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 methodMappingPattern = /(?:方法(?:论)?映射|一般(?:关联|参考)|通常(?:关联|参考|用于|检查)|可(?:以)?参考|主要关联|方法层|尚未计算|还没有计算|未计算)/u; -const observedResultClaimPattern = /(?:(?:已(?:经)?|实际|结果|显示|表明|验证|证明|支持|排除|倾向|更符合|领先|指向|确认|确定)[^。!?\n]{0,48}(?:这次|该(?:事件|经历)|候选|区间|范围|分钟|出生|较早|较晚)|(?:这次|该(?:事件|经历)|候选|区间|范围|分钟|出生|较早|较晚)[^。!?\n]{0,48}(?:显示|表明|验证|证明|支持|排除|倾向|更符合|领先|指向|确认|确定))/u; -const candidateConclusionPattern = /(?:候选|区间|范围|分钟|出生|时段|较早|较晚|更早|更晚|偏前|偏后|靠前|靠后|前一组|后一组|第一组|第二组)/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; keep evidenceExplanation as a general method mapping rather than a calculated candidate conclusion; put only server-verifiable candidate updates in candidateCommentary; 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 { @@ -28,23 +23,15 @@ function containsExactMinute(value: string): boolean { } function asksMultipleQuestions(value: string): boolean { - if ((value.match(/[??]/g) ?? []).length > 1) return true; - return value.split(/[。;;!!\n]+|[,,]\s*(?=(?:请|你|再))/u) - .filter((part) => questionClausePattern.test(part)) - .length > 1; + return (value.match(/[??]/g) ?? []).length > 1; } function normalizedQuestion(value: string): string { return value.normalize("NFKC").toLocaleLowerCase().replace(/[\s??。!!,,、;;::“”‘’'"()()]/gu, ""); } -function mentionsTechnique(value: string, technique: string): boolean { - const escaped = technique.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`(? }>; -const regeneratedQuestionSchema = z.object({ question: z.string().trim().min(8).max(500) }).strict(); export type RectificationDirectorGenerator = (prompt: string, phase: "evidence" | "final" | "after_observation" | "converge" | "repair") => Promise; function summarizeEarlierTurns(turns: readonly RectificationV4Turn[]): string | null { @@ -63,15 +50,19 @@ function diagnosticResult(kind: RectificationDiagnostic, value: DiagnosticsSumma } } -export function buildRectificationCaseDossier(input: Readonly<{ caseValue: RectificationV4Case; turns: readonly RectificationV4Turn[]; events: readonly LifeEventRevision[]; pendingEvidence?: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; previousSnapshot?: CandidateSnapshot | null; diagnostics: DiagnosticsSummary | null; targetDisposition: TargetDisposition; currentTargetEventId: string | null }>): RectificationCaseDossier { +function projectEventLedger(events: readonly LifeEventRevision[]): RectificationCaseDossier["eventLedger"] { const latest = new Map(); - input.events.forEach((event) => latest.set(event.eventId, Math.max(latest.get(event.eventId) ?? 0, event.revision))); + events.forEach((event) => latest.set(event.eventId, Math.max(latest.get(event.eventId) ?? 0, event.revision))); + return events.map((event) => ({ eventId: event.eventId, revision: event.revision, summary: event.summary, rawText: event.rawText, domain: event.domain, eventKind: event.eventKind, subject: event.subject, relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, status: latest.get(event.eventId) === event.revision ? "active" as const : "superseded" as const })); +} + +export function buildRectificationCaseDossier(input: Readonly<{ caseValue: RectificationV4Case; turns: readonly RectificationV4Turn[]; events: readonly LifeEventRevision[]; pendingEvidence?: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; previousSnapshot?: CandidateSnapshot | null; diagnostics: DiagnosticsSummary | null; targetDisposition: TargetDisposition; currentTargetEventId: string | null }>): RectificationCaseDossier { const recent = input.turns.slice(-12); const publicRangeAllowed = Boolean(input.snapshot?.canAcceptRange) && !hasPolicyInvalidScoreableEvents(input.events); return rectificationCaseDossierSchema.parse({ case: { candidateWindow: input.caseValue.calculationSpec.candidateRange, birthDate: input.caseValue.calculationSpec.birthDate, location: { latitude: input.caseValue.calculationSpec.latitude, longitude: input.caseValue.calculationSpec.longitude, timezoneId: input.caseValue.calculationSpec.timezoneId ?? null, timezoneOffsetHours: input.caseValue.calculationSpec.timezoneOffsetHours }, birthTimeSource: input.caseValue.calculationSpec.birthTimeSource ?? null, algorithmVersion: input.caseValue.algorithmVersion }, conversation: { recentRawTurns: recent.map(({ question, answer }) => ({ question, answer })), earlierConversationSummary: summarizeEarlierTurns(input.turns) }, - eventLedger: input.events.map((event) => ({ eventId: event.eventId, revision: event.revision, summary: event.summary, rawText: event.rawText, domain: event.domain, eventKind: event.eventKind, subject: event.subject, relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, status: latest.get(event.eventId) === event.revision ? "active" : "superseded" })), + eventLedger: projectEventLedger(input.events), interviewState: { currentTargetEventId: input.currentTargetEventId, declinedDomains: [...new Set(input.turns.flatMap((turn) => turn.questionDomain && declinedPattern.test(turn.answer) ? [turn.questionDomain] : []))], unresolvedTargets: [...new Set([...(input.currentTargetEventId && ["unresolved", "answered_other_event"].includes(input.targetDisposition) ? [input.currentTargetEventId] : []), ...(input.pendingEvidence ?? []).flatMap((item) => item.targetEventId ? [item.targetEventId] : [])])], pendingEvidence: (input.pendingEvidence ?? []).filter((item) => !item.resolvedAt).map(({ rawText, reasonCode, targetEventId, createdAt }) => ({ rawText, reasonCode, targetEventId, createdAt })), askedTopics: input.turns.slice(-50).map((turn) => turn.question), turnCount: input.turns.length, targetDisposition: input.targetDisposition }, candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: publicRangeAllowed ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), contrastIntelligence: buildCandidateContrastPacket({ events: input.events, snapshot: input.snapshot, diagnostics: input.diagnostics }), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null }, runtime: { @@ -93,12 +84,77 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti }); } -function latestGroundedEvent(dossier: RectificationCaseDossier, latestAnswer: string) { +function latestGroundedEvent(dossier: Pick, latestAnswer: string) { return [...dossier.eventLedger].reverse().find((event) => event.status === "active" && (event.rawText === latestAnswer || latestAnswer.includes(event.summary))) ?? null; } +function groundedEvidenceExplanation(event: RectificationCaseDossier["eventLedger"][number], techniqueLayers: readonly string[]): string { + const labels = techniqueLayers.map((layer) => ({ vimshottari: "Vimshottari", narayana: "Narayana" }[layer.toLocaleLowerCase()] ?? layer)); + return `“${event.summary.slice(0, 120)}”包含可核对的时间和事件变化;方法层通常参考 ${labels.join("、")}。这条线索可以和其他独立经历交叉核对;这里只说明校正时会检查的层面,不代表已经形成计算结论。`; +} + +const publicDomainLabels: Readonly, string>> = { + education: "学习路径变化", + relocation: "居住地变化", + relationship: "关系状态变化", + career: "工作状态变化", + finance: "财务结构变化", + health_pressure: "本人健康或高压状态变化", + family: "家庭变化", +}; + +export function focusForQuestionOpportunity(opportunity: QuestionOpportunity): RectificationFocus { + const targetEventId = opportunity.targetEventId; + const mode: RectificationFocus["mode"] = opportunity.kind === "pair_related_event" + ? "pair_related_event" + : opportunity.kind === "resolve_event_conflict" + ? "resolve_conflict" + : opportunity.kind === "disambiguate_candidate_split" + ? "distinguish_candidate_clusters" + : targetEventId ? "clarify_existing_event" : "collect_independent_event"; + const requestedFacts: RectificationFocus["requestedFacts"] = opportunity.kind === "refine_event_date" + ? [opportunity.requestedFields.includes("event_month") ? "month" : "day_or_period"] + : opportunity.kind === "clarify_event_subject" + ? ["subject"] + : opportunity.kind === "pair_related_event" + ? ["paired_event"] + : opportunity.kind === "disambiguate_candidate_split" + ? ["event_stage"] + : opportunity.kind === "ask_new_event" + ? ["independent_event"] + : ["event_type"]; + return { mode, targetEventId, domain: opportunity.domain, requestedFacts, rationaleCodes: ["legacy_opportunity_migration"] }; +} + +function publicQuestionForFocus( + dossier: Pick, + latestAnswer: string, + focus: Extract["focus"], +): string { + const target = focus.targetEventId + ? dossier.eventLedger.find((event) => event.eventId === focus.targetEventId && event.status === "active") ?? null + : null; + if (target) { + const anchor = !privatePattern.test(target.summary) && !containsExactMinute(target.summary) ? `“${target.summary.slice(0, 120)}”` : "刚才那件事"; + if (focus.requestedFacts.includes("month")) return `你还记得${anchor}大概发生在哪个月,或一年中的哪个时间段吗?`; + if (focus.requestedFacts.includes("subject")) return `${anchor}主要发生在你本人、家人还是伴侣身上?`; + if (focus.requestedFacts.includes("event_type")) return `关于${anchor},你能再说明当时具体发生了什么变化吗?`; + if (focus.requestedFacts.includes("paired_event")) return `在${anchor}前后,你还记得另一件与它相关、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。`; + if (focus.requestedFacts.includes("event_stage")) return `关于${anchor},当时更接近开始、结束还是明显转折的阶段?`; + return `关于${anchor},你还记得更具体的时间或阶段吗?`; + } + const groundedEvent = latestGroundedEvent(dossier, latestAnswer); + const anchor = groundedEvent && !privatePattern.test(groundedEvent.summary) && !containsExactMinute(groundedEvent.summary) + ? `在“${groundedEvent.summary.slice(0, 120)}”之外,` + : ""; + const domainLabel = focus.domain && focus.domain !== "other" ? publicDomainLabels[focus.domain] : null; + return domainLabel + ? `如果你愿意,${anchor}你还记得一件发生在你本人身上、时间大致明确的${domainLabel}吗?没有、记不清或不想回答也可以换个方向。` + : `你愿意${anchor}再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。`; +} + 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: "现有事件已经完成本轮复核。", evidenceExplanation: null, candidateCommentary: "候选范围已通过当前稳定性门槛,可以作为工作范围查看。", limitation: "这仍不是对某个精确出生分钟的确认。" }, publicExplanationGrounding: [] }; + 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: null, 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; @@ -106,21 +162,11 @@ function fallback(dossier: RectificationCaseDossier, latestAnswer: string): Rect 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)}”` - : "刚才那件事"; const targetNeedsMonth = targetEvent?.dateRange.precision === "year" || targetEvent?.dateRange.precision === "quarter"; - const targetQuestion = targetNeedsMonth - ? `${safeTargetSummary}大概发生在哪个月,或一年中的哪个时间段?` - : `关于${safeTargetSummary},你还记得更具体的时间或阶段吗?`; - const activeEventCount = dossier.eventLedger.filter((event) => event.status === "active").length; - const neutralLead = ["unknown", "declined", "direction_change"].includes(dossier.interviewState.targetDisposition) - ? "那就先跳过刚才那一项。" - : `目前已有 ${activeEventCount} 条可继续核对的经历。`; - const question = keepTarget ? targetQuestion : `${neutralLead}你愿意再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。`; 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 question = publicQuestionForFocus(dossier, latestAnswer, focus); const techniqueLayers = groundedEvent ? domainScorerRegistry[groundedEvent.domain].techniqueLayers : []; const evidenceExplanation = safeSummary && techniqueLayers.length ? `按当前校正能力,这类事件通常会参考 ${techniqueLayers.join("、")};这里只是在说明方法映射,尚未形成实际计算结果。` @@ -165,9 +211,6 @@ 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.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"); const groundedEvent = latestGroundedEvent(input.dossier, input.latestAnswer); const capabilityFacts = new Map(input.dossier.capabilities.publicTechniqueCapabilities.map((item) => [`domain:${item.domain}`, item])); const observationFacts = new Map<"window_sensitivity" | "candidate_scan" | "diagnostic", Set>([ @@ -191,55 +234,65 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d } if (!observationFacts.get(grounding.source)?.has(grounding.factKey)) issues.push("public_grounding_invalid"); }); - const hasCapabilityGrounding = plan.publicExplanationGrounding.some((grounding) => grounding.source === "capability_matrix"); - const evidenceExplanation = plan.publicReply.evidenceExplanation; - if (evidenceExplanation && (observedResultClaimPattern.test(evidenceExplanation) || candidateConclusionPattern.test(evidenceExplanation))) { - issues.push("ungrounded_candidate_claim"); - } - if (hasCapabilityGrounding && evidenceExplanation && !methodMappingPattern.test(evidenceExplanation)) { - issues.push("capability_claim_not_mapping"); - } - if (groundedEvent && plan.publicReply.evidenceExplanation) { - const citedCapabilities = plan.publicExplanationGrounding.flatMap((grounding) => { + const groundedCapability = groundedEvent && plan.action.type === "ask_question" + ? plan.publicExplanationGrounding.flatMap((grounding) => { const capability = grounding.source === "capability_matrix" ? capabilityFacts.get(grounding.factKey) : null; - return capability && capability.domain === groundedEvent.domain ? [capability] : []; - }); - const allowedTechniques = new Set(citedCapabilities.flatMap((item) => item.techniqueLayers.map((layer) => layer.toLocaleLowerCase()))); - const knownTechniques = [...new Set([ - ...input.dossier.capabilities.publicTechniqueCapabilities.flatMap((item) => item.techniqueLayers), - "KP", "Shadbala", "Ashtakavarga", "Chaturvimshamsha", - ])]; - if (knownTechniques.some((technique) => mentionsTechnique(plan.publicReply.evidenceExplanation ?? "", technique) - && !allowedTechniques.has(technique.toLocaleLowerCase()))) issues.push("public_technique_not_grounded"); - } - if (quantifiedStructurePattern.test(publicText)) { - const windowFactKeys = new Set(plan.publicExplanationGrounding.filter((item) => item.source === "window_sensitivity").map((item) => item.factKey)); - const observedFactKeys = observationFacts.get("window_sensitivity") ?? new Set(); - 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"); - const nextQuestion = normalizedQuestion(plan.action.question); + return capability?.domain === groundedEvent.domain ? [capability] : []; + })[0] ?? null + : null; + if (groundedEvent && plan.action.type === "ask_question" && !groundedCapability) issues.push("event_explanation_grounding_missing"); + const normalizedPlan: RectificationTurnPlan = { + ...plan, + action: plan.action.type === "ask_question" + ? { ...plan.action, question: publicQuestionForFocus(input.dossier, input.latestAnswer, plan.action.focus) } + : plan.action, + publicReply: { + acknowledgement: groundedEvent + ? `你提到的是“${groundedEvent.summary.slice(0, 120)}”。` + : plan.action.type === "offer_candidate_range" + ? "现有事件已经完成本轮复核。" + : input.latestAnswer.trim() + ? "我会保留你刚才的原始说法,不补写你没有确认的信息。" + : "我们先从真实经历建立事件线索。", + evidenceExplanation: groundedEvent && plan.action.type === "ask_question" && groundedCapability + ? groundedEvidenceExplanation(groundedEvent, groundedCapability.techniqueLayers) + : null, + candidateCommentary: null, + limitation: plan.action.type === "offer_candidate_range" + ? "这仍不是对某个精确出生分钟的确认。" + : plan.action.type === "stop_low_confidence" + ? "当前证据不足以安全缩小候选范围。" + : null, + }, + publicExplanationGrounding: groundedEvent && plan.action.type === "ask_question" && groundedCapability + ? [{ source: "capability_matrix", factKey: `domain:${groundedEvent.domain}` }] + : [], + }; + const publicText = [normalizedPlan.publicReply.acknowledgement, normalizedPlan.publicReply.evidenceExplanation, normalizedPlan.publicReply.limitation, normalizedPlan.action.type === "ask_question" ? normalizedPlan.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)) issues.push("ungrounded_numeric_structure_claim"); + if (normalizedPlan.action.type === "ask_question") { + if (asksMultipleQuestions(normalizedPlan.action.question)) issues.push("multiple_questions"); + const nextQuestion = normalizedQuestion(normalizedPlan.action.question); if (nextQuestion && input.dossier.interviewState.askedTopics.some((question) => { const previousQuestion = normalizedQuestion(question); return previousQuestion.endsWith(nextQuestion) || nextQuestion.endsWith(previousQuestion); })) issues.push("question_repeated"); if (input.phase === "final" && groundedEvent) { 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 (normalizedPlan.action.focus.targetEventId && !known.has(normalizedPlan.action.focus.targetEventId)) issues.push("focus_target_invalid"); + if (normalizedPlan.action.focus.domain && input.dossier.interviewState.declinedDomains.includes(normalizedPlan.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"); + && normalizedPlan.action.focus.targetEventId !== currentTarget) issues.push("unresolved_target_abandoned"); if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition) && ((input.dossier.interviewState.currentTargetEventId !== null - && plan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId) - || ["clarify_existing_event", "resolve_conflict"].includes(plan.action.focus.mode))) issues.push("declined_target_reopened"); + && normalizedPlan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId) + || ["clarify_existing_event", "resolve_conflict"].includes(normalizedPlan.action.focus.mode))) issues.push("declined_target_reopened"); } if (plan.action.type === "offer_candidate_range" && (!input.dossier.candidateState.publicRangeAllowed || plan.action.snapshotId !== input.dossier.candidateState.currentSnapshotId)) issues.push("candidate_range_gate_failed"); - return { plan: issues.length ? null : plan, issues }; + return { plan: issues.length ? null : normalizedPlan, issues }; } export async function regenerateDirectorQuestion(input: Readonly<{ @@ -250,31 +303,7 @@ export async function regenerateDirectorQuestion(input: Readonly<{ focus: Extract["focus"]; generateQuestion?: (prompt: string, phase: "regenerate" | "repair") => Promise; }>): Promise { - const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel(); - const agent = model ? new Agent({ id: `rectification-director-regenerate-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Rewrite one natural interview question while preserving the supplied structured focus. Do not expose internal ids, scores, tools, or a birth minute. Return only structured output." }) : null; - const 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 || !skillReady) throw new Error("director_model_unavailable"); - await skillReady; - return agent.generate(prompt, { structuredOutput: { schema: regeneratedQuestionSchema, jsonPromptInjection: "inline" } }); - }); - const validate = (value: unknown) => { - const parsed = regeneratedQuestionSchema.safeParse(value); - if (!parsed.success) return { question: null, issues: ["question_schema_invalid"] }; - const issues: string[] = []; - if (asksMultipleQuestions(parsed.data.question)) issues.push("multiple_questions"); - if (privatePattern.test(parsed.data.question)) issues.push("private_detail_exposed"); - if (containsExactMinute(parsed.data.question)) issues.push("exact_minute_claimed"); - return { question: issues.length ? null : parsed.data.question, issues }; - }; - try { - const context = { task: "Rewrite the current question without changing its structured focus.", currentQuestion: input.currentQuestion, latestAnswer: input.latestAnswer, focus: input.focus, acceptedEvents: input.acceptedEvents.map(({ summary, domain, eventKind, dateRange }) => ({ summary, domain, eventKind, dateRange })) }; - let result = validate((await generate(JSON.stringify(context), "regenerate")).object); - if (!result.question) result = validate((await generate(JSON.stringify({ ...context, task: "Repair the rejected rewrite once.", validationIssues: result.issues }), "repair")).object); - return result.question ?? input.currentQuestion; - } catch { - return input.currentQuestion; - } + return publicQuestionForFocus({ eventLedger: projectEventLedger(input.acceptedEvents) }, input.latestAnswer, input.focus); } export async function runRectificationDirector(input: Readonly<{ caseValue: RectificationV4Case; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final"; diagnostics: DiagnosticsSummary; timeoutMs?: number; generatePlan?: RectificationDirectorGenerator }>) { diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index c50c520f..1874b5f1 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -423,7 +423,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, + candidateUpdate: candidateUpdateFor({ snapshot, previousSnapshot: claimed.case.latestSnapshot, decisionAction: decision.action }), limitation: plan.publicReply.limitation, question: action.type === "ask_question" ? action.question : null, analysisTrace: { diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index 27f79200..52e133bf 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -8,8 +8,8 @@ import type { import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts"; import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts"; import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts"; -import { regenerateDirectorQuestion } from "../rectification-agent/director-agent.ts"; -import { generateOpeningQuestion, regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts"; +import { focusForQuestionOpportunity, regenerateDirectorQuestion } from "../rectification-agent/director-agent.ts"; +import { generateOpeningQuestion } from "../rectification-agent/renderer-agent.ts"; import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; import { hasPolicyInvalidScoreableEvents } from "./evidence-ledger.ts"; import { openingQuestion } from "./opening-question.ts"; @@ -21,13 +21,11 @@ export function createRectificationV4CaseService( store: RectificationV4Store, options: { readonly now?: () => Date; - readonly regenerateQuestion?: typeof regenerateQuestionRealization; readonly regenerateDirectorQuestion?: typeof regenerateDirectorQuestion; readonly generateOpeningQuestion?: typeof generateOpeningQuestion; } = {}, ) { const now = options.now ?? (() => new Date()); - const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization; const redirectQuestion = options.regenerateDirectorQuestion ?? regenerateDirectorQuestion; const generateOpening = options.generateOpeningQuestion ?? generateOpeningQuestion; @@ -155,25 +153,17 @@ export function createRectificationV4CaseService( store.loadEvents(input.userId, input.caseId), store.loadTurns(input.userId, input.caseId), ]); - let prompt: string; - if (validated.selectedOpportunity) { - prompt = await realizeQuestion({ - caseValue: current, - currentPrompt: current.currentQuestion.prompt, - latestAnswer: turns.at(-1)?.answer ?? "", - acceptedEvents: events, - opportunity: validated.selectedOpportunity, - }); - } else { - if (!("focus" in validated.decision)) return null; - prompt = await redirectQuestion({ - caseValue: current, - currentQuestion: current.currentQuestion.prompt, - latestAnswer: turns.at(-1)?.answer ?? "", - acceptedEvents: events, - focus: validated.decision.focus, - }); - } + const focus = validated.selectedOpportunity + ? focusForQuestionOpportunity(validated.selectedOpportunity) + : "focus" in validated.decision ? validated.decision.focus : null; + if (!focus) return null; + const prompt = await redirectQuestion({ + caseValue: current, + currentQuestion: current.currentQuestion.prompt, + latestAnswer: turns.at(-1)?.answer ?? "", + acceptedEvents: events, + focus, + }); return store.replaceCurrentQuestion({ ...input, question: { ...current.currentQuestion, id: randomUUID(), prompt }, diff --git a/frontend/tests/rectification-analysis-trace.test.ts b/frontend/tests/rectification-analysis-trace.test.ts index fda6aee5..4e1220d5 100644 --- a/frontend/tests/rectification-analysis-trace.test.ts +++ b/frontend/tests/rectification-analysis-trace.test.ts @@ -557,7 +557,7 @@ 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.match(result.nextQuestion?.prompt ?? "", /哪个月|时间段/); assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /还能想到一件/); }); @@ -591,7 +591,7 @@ test("a completed internship answer is acknowledged, explained, and followed by assert.notEqual(result.nextQuestion?.domain, "education"); assert.notEqual(result.nextQuestion?.domain, "career"); assert.match(result.nextQuestion?.prompt ?? "", /石油化工研究院|实习|研究员/); - 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 fbe6a7c5..4969998f 100644 --- a/frontend/tests/rectification-director.test.ts +++ b/frontend/tests/rectification-director.test.ts @@ -100,7 +100,7 @@ function plan(overrides: Partial = {}): RectificationTurn requestedFacts: ["independent_event"], rationaleCodes: ["need_independent_event"], }, - question: "接下来想从哪段变化继续聊?", + question: "你愿意从哪段变化继续聊?", optionalQuickReplies: [], }, publicReply: { @@ -247,10 +247,12 @@ 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。", 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")); + const unsafe = plan({ + action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: "内部 eventId 是 00000000-0000-4000-8000-000000000799,出生时间是05:13吗?", optionalQuickReplies: [] }, + }); + const unsafeResult = validateRectificationTurnPlan({ plan: unsafe, dossier: dossier(), latestAnswer, phase: "final" }); + assert.deepEqual(unsafeResult.issues, []); + assert.doesNotMatch(unsafeResult.plan?.action.type === "ask_question" ? unsafeResult.plan.action.question : "", /eventId|05:13|出生时间/); const range = plan({ action: { type: "offer_candidate_range", snapshotId: "00000000-0000-4000-8000-000000000704" } }); assert.ok(validateRectificationTurnPlan({ plan: range, dossier: dossier(), latestAnswer, phase: "final" }).issues.includes("candidate_range_gate_failed")); @@ -506,77 +508,79 @@ test("final Director repairs a generic acknowledgement and missing evidence-valu 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.deepEqual(JSON.parse(prompts[1]!).validationIssues, ["event_explanation_grounding_missing", "event_acknowledgement_generic"]); assert.match(result.plan.publicReply.acknowledgement, /石油化工研究院/); - assert.match(result.plan.publicReply.candidateCommentary ?? "", /交叉比较候选范围/); + assert.match(result.plan.publicReply.evidenceExplanation ?? "", /D10.*Vimshottari/); + assert.equal(result.plan.publicReply.candidateCommentary, null); }); -test("manual question regeneration preserves focus and repairs unsafe text once", async () => { - const phases: string[] = []; - const question = await regenerateDirectorQuestion({ - caseValue, - currentQuestion: "除了这段经历,你还想从哪件事继续?", - latestAnswer: "2016年9月大学入学", - acceptedEvents: [event()], - focus: { - mode: "collect_independent_event", - targetEventId: null, - domain: null, - requestedFacts: ["independent_event"], - rationaleCodes: ["need_independent_event"], - }, - generateQuestion: async (_prompt, phase) => { - phases.push(phase); - return { object: { question: phase === "regenerate" ? "请确认出生时间05:13?" : "除了这段经历,你还想从哪件事继续?" } }; - }, +test("manual question regeneration uses the server renderer and ignores model wording", async () => { + const eventValue = event({ + summary: "2016年9月离家去外地上大学", + rawText: "2016年9月离家去外地上大学", + dateRange: { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "2016年9月" }, }); - assert.equal(question, "除了这段经历,你还想从哪件事继续?"); - assert.deepEqual(phases, ["regenerate", "repair"]); + const unsafeQuestions = [ + "D10 说明 A 方案更可信", + "分盘已表明甲组更有把握", + "测算已证明头一组", + "推演已判定前者占优", + ]; + for (const unsafeQuestion of unsafeQuestions) { + let calls = 0; + const question = await regenerateDirectorQuestion({ + caseValue, + currentQuestion: `${unsafeQuestion}\n\n旧问题?`, + latestAnswer: eventValue.rawText, + acceptedEvents: [eventValue], + focus: { + mode: "collect_independent_event", + targetEventId: null, + domain: "career", + requestedFacts: ["independent_event"], + rationaleCodes: ["candidate_contrast"], + }, + generateQuestion: async () => { + calls += 1; + return { object: { question: unsafeQuestion } }; + }, + }); + assert.equal(calls, 0); + assert.match(question, /工作状态变化/); + assert.doesNotMatch(question, /D10|分盘|测算|推演|甲组|头一组|前者占优/); + } }); -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: "这条经历已经保留。", evidenceExplanation: `${technique} 是这类事件可参考的公开方法层;目前只是方法映射。`, candidateCommentary: null, limitation: null } }), - dossier: dossier(), latestAnswer: "", phase: "final", - }).issues; - assert.ok(!issues.includes("private_detail_exposed"), technique); - } +test("public reply allows method names but rejects private internals", () => { + 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 grounded = validateRectificationTurnPlan({ + plan: plan({ + publicReply: { acknowledgement: "你提到2020年4月去研究院实习做研究员。", evidenceExplanation: "模型任意解释。", candidateCommentary: null, limitation: null }, + publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }], + }), + dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final", + }); + assert.deepEqual(grounded.issues, []); + assert.match(grounded.plan?.publicReply.evidenceExplanation ?? "", /D10.*Vimshottari/); + for (const privateDetail of ["原始评分 8.7", "权重 0.4", "贡献矩阵如下", "tool_call 原始输出", "我先调用 case_read,再调用 candidate_scan 和 diagnostic_read"]) { - const issues = validateRectificationTurnPlan({ - plan: plan({ publicReply: { acknowledgement: "这条经历已经保留。", evidenceExplanation: privateDetail, candidateCommentary: null, limitation: null } }), + const result = validateRectificationTurnPlan({ + plan: plan({ action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: `${privateDetail},你还记得另一件经历吗?`, optionalQuickReplies: [] } }), dossier: dossier(), latestAnswer: "", phase: "final", - }).issues; - assert.ok(issues.includes("private_detail_exposed"), privateDetail); + }); + assert.deepEqual(result.issues, [], privateDetail); + assert.doesNotMatch(result.plan?.action.type === "ask_question" ? result.plan.action.question : "", /评分|权重|贡献矩阵|tool_call|case_read|candidate_scan|diagnostic_read/); } - 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 ungroundedCandidateClaim = plan({ publicReply: { acknowledgement: "已记录。", evidenceExplanation: "D10 已经显示较早候选更符合。", candidateCommentary: null, limitation: null } }); - assert.ok(validateRectificationTurnPlan({ plan: ungroundedCandidateClaim, dossier: dossier(), latestAnswer: "", phase: "final" }).issues.includes("ungrounded_candidate_claim")); - - const emptyCandidateObservation = { - ...dossier(), - runtime: { - revision: 1, - hypotheses: [], - observations: [{ round: 1, tool: "candidate_scan" as const, diagnostic: null, outcome: "succeeded" as const, result: { candidateState: { hasSnapshot: false, publicRangeAllowed: false, topClusters: [], contrasts: [] } }, dossierRevision: 1, errorCode: null }], - }, - }; - const falselyObservedClaim = plan({ - publicReply: { acknowledgement: "已记录。", evidenceExplanation: "D10 已经显示较早候选更符合。", candidateCommentary: null, limitation: null }, - publicExplanationGrounding: [{ source: "candidate_scan", factKey: "candidateState" }], - }); - assert.ok(validateRectificationTurnPlan({ plan: falselyObservedClaim, dossier: emptyCandidateObservation, latestAnswer: "", phase: "final" }).issues.includes("ungrounded_candidate_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: [] } }); - assert.deepEqual(validateRectificationTurnPlan({ plan: single, dossier: dossier(), latestAnswer: "", phase: "final" }).issues, []); }); -test("public explanations require real capability grounding for the current event", () => { +test("public explanations are server-rendered from current-event capability grounding", () => { const internship = event({ domain: "career", eventKind: "career_change", @@ -586,50 +590,90 @@ test("public explanations require real capability grounding for the current even }); const base = { acknowledgement: "你提到的是2020年4月去研究院实习做研究员。", - candidateCommentary: "这条职业变化有明确月份,可以和其他独立事件交叉比较候选范围。", + candidateCommentary: "D10 已经显示较早候选更符合。", limitation: null, } as const; const madeUp = plan({ - publicReply: { ...base, evidenceExplanation: "这条职业变化可参考 D10;目前只是方法映射。" }, + publicReply: { ...base, evidenceExplanation: "这条职业变化可参考 D10。" }, publicExplanationGrounding: [{ source: "capability_matrix", factKey: "made-up-key" }], }); - assert.ok(validateRectificationTurnPlan({ plan: madeUp, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues.includes("public_grounding_invalid")); + const madeUpIssues = validateRectificationTurnPlan({ plan: madeUp, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues; + assert.ok(madeUpIssues.includes("public_grounding_invalid")); + assert.ok(madeUpIssues.includes("event_explanation_grounding_missing")); const wrongDomain = plan({ - publicReply: { ...base, evidenceExplanation: "这条职业变化可参考 D24;目前只是方法映射。" }, + publicReply: { ...base, evidenceExplanation: "这条职业变化可参考 D24。" }, publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:education" }], }); const wrongDomainIssues = validateRectificationTurnPlan({ plan: wrongDomain, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues; assert.ok(wrongDomainIssues.includes("public_grounding_invalid")); - assert.ok(wrongDomainIssues.includes("public_technique_not_grounded")); + assert.ok(wrongDomainIssues.includes("event_explanation_grounding_missing")); - const grounded = plan({ - publicReply: { ...base, evidenceExplanation: "这条职业变化可参考 D10 与 Vimshottari;目前只是方法映射。" }, - publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }], - }); - assert.deepEqual(validateRectificationTurnPlan({ plan: grounded, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues, []); + for (const arbitraryExplanation of [ + "D10 已经显示这次职业变化支持较早的候选区间。", + "这只是方法映射;D10 已经证明这次经历更符合较早出生。", + "这只是方法映射;D10 说明该经历让 A 方案更可信。", + ]) { + const result = validateRectificationTurnPlan({ + plan: plan({ + publicReply: { ...base, evidenceExplanation: arbitraryExplanation }, + publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }], + }), + dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final", + }); + assert.deepEqual(result.issues, []); + assert.match(result.plan?.publicReply.evidenceExplanation ?? "", /方法层通常参考 D10、A10、Vimshottari/); + assert.doesNotMatch(result.plan?.publicReply.evidenceExplanation ?? "", /较早|证明|A 方案/); + assert.equal(result.plan?.publicReply.candidateCommentary, null); + } - const inventedResult = plan({ - publicReply: { ...base, evidenceExplanation: "D10 已经显示这次职业变化支持较早的候选区间。" }, - publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }], + const migratedClaim = validateRectificationTurnPlan({ + plan: plan({ + publicReply: { acknowledgement: "D10 说明该经历让 A 方案更可信。", evidenceExplanation: "任意模型解释", candidateCommentary: "较早候选更可信。", limitation: "较早候选已经领先。" }, + publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }], + }), + dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final", }); - assert.ok(validateRectificationTurnPlan({ plan: inventedResult, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues.includes("ungrounded_candidate_claim")); + assert.deepEqual(migratedClaim.issues, []); + assert.equal(migratedClaim.plan?.publicReply.acknowledgement, "你提到的是“2020年4月去研究院实习做研究员”。"); + assert.equal(migratedClaim.plan?.publicReply.candidateCommentary, null); + assert.equal(migratedClaim.plan?.publicReply.limitation, null); - const disguisedInventedResult = plan({ - publicReply: { ...base, evidenceExplanation: "这只是方法映射;D10 已经证明这次经历更符合较早出生。" }, - publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }], - }); - assert.ok(validateRectificationTurnPlan({ plan: disguisedInventedResult, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues.includes("ungrounded_candidate_claim")); + const unsafeQuestions = [ + "D10 说明 A 方案更可信,你还记得另一件经历吗?", + "分盘已经表明甲组更有把握,你还记得另一件经历吗?", + "测算已经证明头一组更合适。", + "你还记得测算已经证明头一组更合适之后发生的另一件经历吗?", + "你还记得推演已判定前者占优之后发生的另一件经历吗?", + ]; + for (const unsafeQuestion of unsafeQuestions) { + const result = validateRectificationTurnPlan({ + plan: plan({ + action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: unsafeQuestion, optionalQuickReplies: [] }, + publicReply: { ...base, evidenceExplanation: "任意模型解释" }, + publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }], + }), + dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final", + }); + assert.deepEqual(result.issues, []); + const publicQuestion = result.plan?.action.type === "ask_question" ? result.plan.action.question : ""; + assert.match(publicQuestion, /在“2020年4月去研究院实习做研究员”之外/); + assert.doesNotMatch(publicQuestion, /D10|分盘|测算|推演|甲组|头一组|前者占优/); + } - const uncaughtSynonym = plan({ - publicReply: { ...base, evidenceExplanation: "这只是方法映射;D10 说明该经历对应出生时段偏前。" }, - publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }], + const directedDomain = validateRectificationTurnPlan({ + plan: plan({ + action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: "career", requestedFacts: ["independent_event"], rationaleCodes: ["candidate_contrast"] }, question: "任意模型措辞", optionalQuickReplies: [] }, + publicReply: { ...base, evidenceExplanation: "任意模型解释" }, + publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }], + }), + dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final", }); - assert.ok(validateRectificationTurnPlan({ plan: uncaughtSynonym, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues.includes("ungrounded_candidate_claim")); + assert.match(directedDomain.plan?.action.type === "ask_question" ? directedDomain.plan.action.question : "", /工作状态变化/); }); test("the final plan cannot repeat a previously asked question", () => { - const previous = "你还能想到一件时间大致确定的重要经历吗?"; + const previous = "你愿意再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。"; const repeated = plan({ action: { type: "ask_question", @@ -749,7 +793,7 @@ test("deterministic fallback refines the known year before collecting another ev }); test("deterministic fallback pauses instead of repeating a persisted public question", async () => { - const repeatedQuestion = "目前已有 0 条可继续核对的经历。你愿意再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。"; + const repeatedQuestion = "你愿意再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。"; const persisted = composeRectificationPublicTurn({ acknowledgement: "本轮信息已经保留。", evidenceExplanation: null, @@ -822,9 +866,9 @@ test("deterministic fallback stays domain-neutral when the Agent is unavailable" 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.match(result.plan.action.question, /没有|记不清|不想回答|换/); - assert.doesNotMatch(result.plan.action.question, /教育|迁居|关系|职业|财务|健康|大学|实习|搬家/); + assert.doesNotMatch(result.plan.action.question.split("之外").at(-1) ?? result.plan.action.question, /教育|迁居|关系|职业|财务|健康|大学|实习|搬家/); assert.match(result.plan.publicReply.acknowledgement, /2020年4月|石油化工研究院|实习|研究员/); }); diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index 5064f497..58d575f1 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -177,7 +177,7 @@ test("V5 agent fallback persists the Director decision, Public Message and next assert.doesNotMatch(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.match(done?.case.currentQuestion?.prompt ?? "", /交叉核对/); assert.ok(message.question && done?.case.currentQuestion?.prompt.includes(message.question)); assert.equal(done?.case.latestSnapshot, null); })); @@ -319,6 +319,84 @@ test("V5 Agent regenerate rewrites only the current semantic question and replay assert.equal(regenerated.case.latestSnapshot?.canConfirmExactMinute ?? false, false); })); +test("V5 Agent regenerate converts historical selected opportunities into server-rendered questions", async () => withMode("v5_agent", async () => { + const store = createRectificationV4MemoryStore(); + const service = createTestCaseService(store, { now: fixedNow }); + const userId = randomUUID(); + const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + const queued = await service.answer({ + userId, + caseId: created.case.id, + actionId: randomUUID(), + expectedCaseVersion: created.case.version, + answer: "2020年4月去石油化工研究院实习做研究员", + }); + assert.ok(queued?.job); + const worker = createRectificationV4Worker({ + store, + now: fixedNow, + engine: { async score() { throw new Error("engine must not run before enough events"); } }, + }); + assert.equal(await worker.runOnce(), true); + + const current = await service.loadCase(userId, created.case.id); + const event = current?.events.find((item) => item.domain === "career"); + const run = [...store.agentRuns.values()][0]; + assert.ok(current?.case.currentQuestion && event && run && queued.job); + + const opportunityId = randomUUID(); + store.validatedDecisions.set(queued.job.id, { + decision: { action: "ask_question", opportunityId, narrativeFocus: ["date_precision"] }, + mode: "agent", + validationIssues: [], + selectedOpportunity: { + contractVersion: "semantic-question-v2", + opportunityId, + kind: "refine_event_date", + domain: "career", + targetEventId: event.eventId, + goal: "核对这段实习经历的月份", + requestedFields: ["event_month"], + anchors: [event.summary], + contextFacts: [], + forbiddenMoves: ["expose_technique_trace"], + fallbackPrompt: "分盘已表明甲组更有把握,请确认 D10 的结论。", + reason: "历史问题机会兼容测试", + expectedInformationGain: .5, + dateSensitivity: .5, + candidateSplitRelevance: .5, + domainCoverageGain: 0, + recallEase: .8, + novelty: .5, + repetitionPenalty: 0, + privacyCost: 0, + utility: .5, + active: true, + }, + }); + store.cases.set(created.case.id, { + ...current.case, + currentQuestion: { + ...current.case.currentQuestion, + domain: "career", + targetEventId: event.eventId, + prompt: "分盘已表明甲组更有把握,请确认 D10 的结论。", + }, + }); + + const regenerated = await service.regenerateQuestion({ + userId, + caseId: created.case.id, + actionId: randomUUID(), + expectedCaseVersion: current.case.version, + }); + assert.ok(regenerated?.case.currentQuestion); + assert.equal(regenerated.case.currentQuestion.domain, "career"); + assert.equal(regenerated.case.currentQuestion.targetEventId, event.eventId); + assert.match(regenerated.case.currentQuestion.prompt, /2020年4月|石油化工研究院|实习|研究员/); + assert.doesNotMatch(regenerated.case.currentQuestion.prompt, /D10|分盘|测算|推演|甲组|头一组|前者占优/); +})); + test("legacy and shadow cases cannot call the V5 Agent question renderer", async () => { for (const mode of ["v4_legacy", "v5_shadow"] as const) { await withMode(mode, async () => {