fix(rectification): preserve agent-led public responses
This commit is contained in:
@@ -1944,3 +1944,19 @@
|
||||
- 相关记录:BUG-104、BUG-107、BUG-109
|
||||
- 复发自:无
|
||||
- 修复版本:local / pending release
|
||||
|
||||
## BUG-111 | Director 校验器覆盖 Agent 公开回复且复合事件缺少公开语义
|
||||
|
||||
- 状态:resolved(local)
|
||||
- 首次发现:2026-07-31
|
||||
- 最近更新:2026-07-31
|
||||
- 影响面:V5 Director 公开承接、方法说明、下一问、手动重新生成与复合事件语义
|
||||
- 用户现象:Agent 已生成自然承接和下一问时,服务器仍会替换为固定模板;“离家去外地上大学”只暴露教育语义,公开解释无法同时说明教育与迁居层面。
|
||||
- 触发条件:最新事件同时包含多个可核对维度,或 Director / 手动 regenerate 返回合规的自然文案。
|
||||
- 根因:最终计划校验器同时承担验证和重写职责;事件账本只暴露单一主评分领域;手动 regenerate 路径完全忽略模型输出并直接调用服务器问题模板。
|
||||
- 修复:事件账本为同一事件增加只读 `publicSignals`,保留一个主评分身份并补充公开 secondary signals,不新增 Event、不重复计分;最终校验器只验证 grounding、技术边界、候选结论、隐私、单问题、重复问题和目标连续性,不再覆盖合规 Agent 文案;手动 regenerate 改为 Agent 生成、服务器两轮安全校验,不合规后才使用确定性 fallback。
|
||||
- 验证:回归覆盖“离家去外地上大学”只保留一条事件但公开 education + relocation、D24 + D4 合法 grounding、家人健康不投射为本人 D30、合规 Agent 文案原样保留、未 grounding 技法与候选结论被拒绝、手动 regenerate 的 repair 与 fallback。
|
||||
- 防复发:公开语义只解释用户原话中已存在的复合信号;服务器继续拥有事件身份、评分、事实 grounding 和安全门,正常措辞与提问归 Agent。
|
||||
- 相关记录:BUG-107、BUG-108、BUG-109、BUG-110
|
||||
- 复发自:BUG-109
|
||||
- 修复版本:local / pending release
|
||||
|
||||
@@ -34,6 +34,33 @@ const isoDatePattern = /(?:1\d{3}|20\d{2})-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d
|
||||
const unresolvedRelativeTimePattern = /(?:来年|次年|第二年|翌年|后来|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)/;
|
||||
const leadingRelativeTimePattern = /^\s*(?:(?:来年|次年|第二年|翌年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/;
|
||||
const missingEventSummary = "事件内容待补充";
|
||||
const selfHealthPattern = /确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|健康/;
|
||||
const educationPattern = /毕业|入学|升学|转学|学校|大学|专业|考试|考(?:了)?(?:一)?次?研|研究生(?:入学)?考试|留学|学业|学习/;
|
||||
const relocationPattern = /搬家|迁居|外地|异地|离乡|移居|出国|住所|居住|离家/;
|
||||
const relationshipPattern = /结婚|恋爱|分手|离婚|订婚|伴侣|关系/;
|
||||
const familyPattern = /生育|孩子|父亲|母亲|父母|家人|家庭|亲人/;
|
||||
const financePattern = /收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/;
|
||||
const careerPattern = /工作|实习|研究员|入职|离职|辞职|升职|创业|职业|职位|任职|负责|管理职责|公司|项目/;
|
||||
const selfDomainPatterns: readonly Readonly<{ domain: RectificationEvidenceDomain; pattern: RegExp }>[] = [
|
||||
{ domain: "health_pressure", pattern: selfHealthPattern },
|
||||
{ domain: "education", pattern: educationPattern },
|
||||
{ domain: "relocation", pattern: relocationPattern },
|
||||
{ domain: "relationship", pattern: relationshipPattern },
|
||||
{ domain: "finance", pattern: financePattern },
|
||||
{ domain: "career", pattern: careerPattern },
|
||||
];
|
||||
|
||||
export function publicEvidenceDomainsFor(input: Readonly<{
|
||||
summary: string;
|
||||
primaryDomain: RectificationEvidenceDomain;
|
||||
subject: "self" | "family" | "partner" | "other";
|
||||
}>): readonly RectificationEvidenceDomain[] {
|
||||
if (input.subject !== "self") return [input.primaryDomain];
|
||||
return [...new Set([
|
||||
input.primaryDomain,
|
||||
...selfDomainPatterns.flatMap(({ domain, pattern }) => pattern.test(input.summary) ? [domain] : []),
|
||||
])];
|
||||
}
|
||||
|
||||
export function parseDeclaredDateText(value: string, asOfDate: string): ParsedDate | null {
|
||||
const chinese = value.match(/^((?:1\d{3}|20\d{2}|\d{2}))\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/);
|
||||
@@ -121,25 +148,25 @@ function classifyEvent(summary: string): EventSemantics {
|
||||
scoreability: "context_only",
|
||||
};
|
||||
}
|
||||
if (/确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|健康/.test(summary)) {
|
||||
if (selfHealthPattern.test(summary)) {
|
||||
return { domain: "health_pressure", eventKind: "self_health_event", subject: "self", relatedPerson: null, scoreability: "scoreable" };
|
||||
}
|
||||
if (/毕业|入学|升学|转学|学校|大学|专业|考试|考(?:了)?(?:一)?次?研|研究生(?:入学)?考试|留学|学业|学习/.test(summary)) {
|
||||
if (educationPattern.test(summary)) {
|
||||
return { domain: "education", eventKind: "education_milestone", subject: "self", relatedPerson: null, scoreability: "scoreable" };
|
||||
}
|
||||
if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) {
|
||||
if (relocationPattern.test(summary)) {
|
||||
return { domain: "relocation", eventKind: "relocation", subject: "self", relatedPerson: null, scoreability: "scoreable" };
|
||||
}
|
||||
if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) {
|
||||
if (relationshipPattern.test(summary)) {
|
||||
return { domain: "relationship", eventKind: "relationship_change", subject: /伴侣|配偶/.test(summary) ? "partner" : "self", relatedPerson: /伴侣|配偶/.test(summary) ? "partner" : null, scoreability: "scoreable" };
|
||||
}
|
||||
if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) {
|
||||
if (familyPattern.test(summary)) {
|
||||
return { domain: "family", eventKind: "family_event", subject: "family", relatedPerson: null, scoreability: "context_only" };
|
||||
}
|
||||
if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) {
|
||||
if (financePattern.test(summary)) {
|
||||
return { domain: "finance", eventKind: "finance_change", subject: "self", relatedPerson: null, scoreability: "scoreable" };
|
||||
}
|
||||
if (/工作|实习|研究员|入职|离职|辞职|升职|创业|职业|职位|任职|负责|管理职责|公司|项目/.test(summary)) {
|
||||
if (careerPattern.test(summary)) {
|
||||
return { domain: "career", eventKind: "career_change", subject: "self", relatedPerson: null, scoreability: "scoreable" };
|
||||
}
|
||||
return { domain: "other", eventKind: "other", subject: "other", relatedPerson: null, scoreability: "unsupported" };
|
||||
|
||||
@@ -161,6 +161,11 @@ export const rectificationCaseDossierSchema = z.object({
|
||||
dateRange: z.object({ start: nonblank(10), end: nonblank(10), precision: nonblank(20), label: nonblank(80) }).strict(),
|
||||
scoreability: nonblank(40),
|
||||
status: z.enum(["active", "superseded", "pending"]),
|
||||
publicSignals: z.array(z.object({
|
||||
domain: evidenceDomainSchema,
|
||||
role: z.enum(["primary", "secondary"]),
|
||||
techniqueLayers: z.array(nonblank(80)).max(20),
|
||||
}).strict()).min(1).max(8),
|
||||
}).strict()),
|
||||
interviewState: z.object({
|
||||
currentTargetEventId: uuid.nullable(),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { publicEvidenceDomainsFor } from "../conversational-rectification/evidence-extractor.ts";
|
||||
import { hasPolicyInvalidScoreableEvents } from "../rectification-v4/evidence-ledger.ts";
|
||||
import { domainScorerRegistry } from "../rectification-v4/domain-scorers.ts";
|
||||
import { buildCandidateContrastPacket } from "./opportunity-builder.ts";
|
||||
@@ -16,6 +18,9 @@ const privatePattern = /(?:[0-9a-f]{8}-[0-9a-f-]{27,}|opportunity(?:id)?|snapsho
|
||||
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 genericAcknowledgementPattern = /^(?:好的|明白了|知道了|收到|已记录|我记下了|我已按你的描述整理这轮线索)[。!!]?$/u;
|
||||
const publicTechniquePattern = /(?:D\d{1,2}|A\d{1,2}|KP|Vimshottari|Narayana|Shadbala|Ashtakavarga|UL)/giu;
|
||||
const candidateConclusionPattern = /(?:(?:证明|显示|表明|判定|支持|意味着)[^。!?\n]{0,40}(?:候选|区间|方案|出生|较早|较晚|前者|后者|甲组|乙组|头一组)|(?:候选|区间|方案|较早|较晚|前者|后者|甲组|乙组|头一组)[^。!?\n]{0,40}(?:更可信|更符合|更合适|领先|占优|已经确定|已经确认))/u;
|
||||
const regeneratedQuestionSchema = z.object({ question: z.string().trim().min(1).max(240) }).strict();
|
||||
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 {
|
||||
@@ -53,7 +58,24 @@ function diagnosticResult(kind: RectificationDiagnostic, value: DiagnosticsSumma
|
||||
function projectEventLedger(events: readonly LifeEventRevision[]): RectificationCaseDossier["eventLedger"] {
|
||||
const latest = new Map<string, number>();
|
||||
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 }));
|
||||
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,
|
||||
publicSignals: publicEvidenceDomainsFor({ summary: event.summary, primaryDomain: event.domain, subject: event.subject }).map((domain, index) => ({
|
||||
domain,
|
||||
role: index === 0 ? "primary" as const : "secondary" as const,
|
||||
techniqueLayers: [...domainScorerRegistry[domain].techniqueLayers],
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -88,11 +110,6 @@ function latestGroundedEvent(dossier: Pick<RectificationCaseDossier, "eventLedge
|
||||
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<Record<Exclude<EvidenceDomain, "other">, string>> = {
|
||||
education: "学习路径变化",
|
||||
relocation: "居住地变化",
|
||||
@@ -167,9 +184,10 @@ function fallback(dossier: RectificationCaseDossier, latestAnswer: string): Rect
|
||||
? { 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 techniqueLayers = [...new Set(groundedEvent?.publicSignals.flatMap((signal) => signal.techniqueLayers) ?? [])]
|
||||
.map((layer) => ({ vimshottari: "Vimshottari", narayana: "Narayana" }[layer.toLocaleLowerCase()] ?? layer));
|
||||
const evidenceExplanation = safeSummary && techniqueLayers.length
|
||||
? `按当前校正能力,这类事件通常会参考 ${techniqueLayers.join("、")};这里只是在说明方法映射,尚未形成实际计算结果。`
|
||||
? `按当前校正能力,这类事件通常会参考 ${techniqueLayers.join("、")}。这条线索会与其他独立经历交叉核对;这里只是在说明方法映射,尚未形成实际计算结果。`
|
||||
: safeSummary ? "这条经历提供了可核对的时间和变化类型;目前只是整理证据,还不是实际计算结果。" : null;
|
||||
return {
|
||||
contractVersion: "rectification-turn-plan-v1",
|
||||
@@ -179,10 +197,10 @@ function fallback(dossier: RectificationCaseDossier, latestAnswer: string): Rect
|
||||
publicReply: {
|
||||
acknowledgement: safeSummary ? `你提到的是“${safeSummary}”。` : latestAnswer.trim() ? "我会保留你刚才的原始说法,不补写你没有确认的信息。" : "我们先从真实经历建立事件线索。",
|
||||
evidenceExplanation,
|
||||
candidateCommentary: safeSummary ? "这条线索有明确时间,也说明了具体发生的变化,可以和其他独立经历交叉比较候选范围。" : null,
|
||||
candidateCommentary: null,
|
||||
limitation: null,
|
||||
},
|
||||
publicExplanationGrounding: groundedEvent ? [{ source: "capability_matrix", factKey: `domain:${groundedEvent.domain}` }] : [],
|
||||
publicExplanationGrounding: groundedEvent ? groundedEvent.publicSignals.map((signal) => ({ source: "capability_matrix" as const, factKey: `domain:${signal.domain}` })) : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,6 +232,7 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
|
||||
if (input.phase === "evidence") return { plan: issues.length ? null : plan, issues };
|
||||
const groundedEvent = latestGroundedEvent(input.dossier, input.latestAnswer);
|
||||
const capabilityFacts = new Map(input.dossier.capabilities.publicTechniqueCapabilities.map((item) => [`domain:${item.domain}`, item]));
|
||||
const groundedDomains = new Set(groundedEvent?.publicSignals.map((signal) => signal.domain) ?? []);
|
||||
const observationFacts = new Map<"window_sensitivity" | "candidate_scan" | "diagnostic", Set<string>>([
|
||||
["window_sensitivity", new Set()], ["candidate_scan", new Set()], ["diagnostic", new Set()],
|
||||
]);
|
||||
@@ -227,73 +246,63 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
|
||||
if (observation.tool === "candidate_scan") direct.forEach((key) => observationFacts.get("candidate_scan")?.add(key));
|
||||
if (observation.tool === "diagnostic_read") direct.forEach((key) => observationFacts.get("diagnostic")?.add(key));
|
||||
});
|
||||
plan.publicExplanationGrounding.forEach((grounding) => {
|
||||
const groundedCapabilities = plan.publicExplanationGrounding.flatMap((grounding) => {
|
||||
if (grounding.source === "capability_matrix") {
|
||||
const capability = capabilityFacts.get(grounding.factKey);
|
||||
if (!capability || (groundedEvent && capability.domain !== groundedEvent.domain)) issues.push("public_grounding_invalid");
|
||||
return;
|
||||
if (!capability || (groundedEvent && !groundedDomains.has(capability.domain))) {
|
||||
issues.push("public_grounding_invalid");
|
||||
return [];
|
||||
}
|
||||
return [capability];
|
||||
}
|
||||
if (!observationFacts.get(grounding.source)?.has(grounding.factKey)) issues.push("public_grounding_invalid");
|
||||
return [];
|
||||
});
|
||||
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?.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 (groundedEvent && plan.action.type === "ask_question" && !groundedCapabilities.length) issues.push("event_explanation_grounding_missing");
|
||||
|
||||
const publicText = [
|
||||
plan.publicReply.acknowledgement,
|
||||
plan.publicReply.evidenceExplanation,
|
||||
plan.publicReply.candidateCommentary,
|
||||
plan.publicReply.limitation,
|
||||
plan.action.type === "ask_question" ? plan.action.question : null,
|
||||
...(plan.action.type === "ask_question" ? plan.action.optionalQuickReplies.flatMap((item) => [item.label, item.value]) : []),
|
||||
].filter((value): value is string => Boolean(value)).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);
|
||||
const nonCandidateCommentary = [
|
||||
plan.publicReply.acknowledgement,
|
||||
plan.publicReply.evidenceExplanation,
|
||||
plan.publicReply.limitation,
|
||||
plan.action.type === "ask_question" ? plan.action.question : null,
|
||||
].filter((value): value is string => Boolean(value)).join(" ");
|
||||
if (candidateConclusionPattern.test(nonCandidateCommentary)) issues.push("ungrounded_candidate_conclusion");
|
||||
const allowedTechniques = new Set(groundedCapabilities.flatMap((capability) => capability.techniqueLayers.map((layer) => layer.toLocaleLowerCase())));
|
||||
const referencedTechniques = [...publicText.matchAll(publicTechniquePattern)].map((match) => (match[0] ?? "").toLocaleLowerCase());
|
||||
if (referencedTechniques.some((technique) => !allowedTechniques.has(technique))) issues.push("public_technique_ungrounded");
|
||||
if (plan.publicReply.candidateCommentary && !plan.publicExplanationGrounding.some((grounding) => grounding.source !== "capability_matrix")) {
|
||||
issues.push("candidate_commentary_ungrounded");
|
||||
}
|
||||
if (plan.action.type === "ask_question") {
|
||||
if (asksMultipleQuestions(plan.action.question)) issues.push("multiple_questions");
|
||||
const nextQuestion = normalizedQuestion(plan.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 (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 (input.phase === "final" && groundedEvent && genericAcknowledgementPattern.test(plan.publicReply.acknowledgement.trim())) issues.push("event_acknowledgement_generic");
|
||||
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)
|
||||
&& normalizedPlan.action.focus.targetEventId !== currentTarget) issues.push("unresolved_target_abandoned");
|
||||
&& plan.action.focus.targetEventId !== currentTarget) issues.push("unresolved_target_abandoned");
|
||||
if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition)
|
||||
&& ((input.dossier.interviewState.currentTargetEventId !== null
|
||||
&& normalizedPlan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId)
|
||||
|| ["clarify_existing_event", "resolve_conflict"].includes(normalizedPlan.action.focus.mode))) issues.push("declined_target_reopened");
|
||||
&& plan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId)
|
||||
|| ["clarify_existing_event", "resolve_conflict"].includes(plan.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 : normalizedPlan, issues };
|
||||
return { plan: issues.length ? null : plan, issues };
|
||||
}
|
||||
|
||||
export async function regenerateDirectorQuestion(input: Readonly<{
|
||||
@@ -304,7 +313,60 @@ export async function regenerateDirectorQuestion(input: Readonly<{
|
||||
focus: Extract<RectificationTurnPlan["action"], { type: "ask_question" }>["focus"];
|
||||
generateQuestion?: (prompt: string, phase: "regenerate" | "repair") => Promise<Generated>;
|
||||
}>): Promise<string> {
|
||||
return publicQuestionForFocus({ eventLedger: projectEventLedger(input.acceptedEvents) }, input.latestAnswer, input.focus);
|
||||
const eventLedger = projectEventLedger(input.acceptedEvents);
|
||||
const fallbackQuestion = publicQuestionForFocus({ eventLedger }, input.latestAnswer, input.focus);
|
||||
const model = input.generateQuestion ? null : ((input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel());
|
||||
const agent = model ? new Agent({
|
||||
id: `rectification-question-regenerator-${model.id}`,
|
||||
name: "Birth Time Rectification Question Regenerator",
|
||||
model: model.model,
|
||||
skills: [skillPath],
|
||||
instructions: "Rewrite the current question as exactly one natural Simplified-Chinese question while preserving the supplied focus and target. Use the event ledger only as factual context. Do not expose technical methods, candidate conclusions, exact birth minutes, scores, ids, tool traces, or hidden reasoning. Do not repeat the current question. Return strict JSON only.",
|
||||
}) : 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("question_regenerator_model_unavailable");
|
||||
await skillReady;
|
||||
return agent.generate(prompt, {
|
||||
abortSignal: AbortSignal.timeout(15_000),
|
||||
structuredOutput: { schema: regeneratedQuestionSchema, jsonPromptInjection: "inline" },
|
||||
});
|
||||
});
|
||||
const context = {
|
||||
task: "Rewrite the current question without changing its semantic focus.",
|
||||
currentQuestion: input.currentQuestion,
|
||||
latestAnswer: input.latestAnswer,
|
||||
focus: input.focus,
|
||||
events: eventLedger.filter((event) => event.status === "active").slice(-8).map((event) => ({
|
||||
summary: event.summary,
|
||||
date: event.dateRange.label,
|
||||
domain: event.domain,
|
||||
subject: event.subject,
|
||||
})),
|
||||
};
|
||||
let issues: string[] = [];
|
||||
for (const phase of ["regenerate", "repair"] as const) {
|
||||
try {
|
||||
const generated = regeneratedQuestionSchema.safeParse((await generate(JSON.stringify({ ...context, previousIssues: issues }), phase)).object);
|
||||
if (!generated.success) {
|
||||
issues = ["question_schema_invalid"];
|
||||
continue;
|
||||
}
|
||||
const question = generated.data.question;
|
||||
issues = [];
|
||||
if (asksMultipleQuestions(question)) issues.push("multiple_questions");
|
||||
if (normalizedQuestion(question) === normalizedQuestion(input.currentQuestion)) issues.push("question_repeated");
|
||||
if (privatePattern.test(question)) issues.push("private_detail_exposed");
|
||||
if (containsExactMinute(question)) issues.push("exact_minute_claimed");
|
||||
if (quantifiedStructurePattern.test(question)) issues.push("ungrounded_numeric_structure_claim");
|
||||
if (candidateConclusionPattern.test(question)) issues.push("ungrounded_candidate_conclusion");
|
||||
if ([...question.matchAll(publicTechniquePattern)].length) issues.push("public_technique_in_question");
|
||||
if (!issues.length) return question;
|
||||
} catch {
|
||||
issues = ["question_generation_failed"];
|
||||
}
|
||||
}
|
||||
return fallbackQuestion;
|
||||
}
|
||||
|
||||
export async function runRectificationDirector(input: Readonly<{ caseValue: RectificationV4Case; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final"; diagnostics: DiagnosticsSummary; timeoutMs?: number; generatePlan?: RectificationDirectorGenerator }>) {
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { extractLifeEventEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts";
|
||||
import { extractLifeEventEvidence, publicEvidenceDomainsFor } from "../src/lib/conversational-rectification/evidence-extractor.ts";
|
||||
import { lifeEventEvidenceSchema } from "../src/lib/conversational-rectification/persistence-contracts.ts";
|
||||
|
||||
const sourceTurnId = "00000000-0000-4000-8000-000000000610";
|
||||
|
||||
test("exposes compound public domains without creating another evidence event", () => {
|
||||
assert.deepEqual(publicEvidenceDomainsFor({
|
||||
summary: "离家去外地上大学",
|
||||
primaryDomain: "education",
|
||||
subject: "self",
|
||||
}), ["education", "relocation"]);
|
||||
});
|
||||
|
||||
test("does not project a family health event as self health evidence", () => {
|
||||
assert.deepEqual(publicEvidenceDomainsFor({
|
||||
summary: "父亲住院接受手术",
|
||||
primaryDomain: "family",
|
||||
subject: "family",
|
||||
}), ["family"]);
|
||||
});
|
||||
|
||||
|
||||
test("preserves raw text and splits two clear facts sharing an explicit month", () => {
|
||||
const rawText = "2021年7月毕业并去外地工作";
|
||||
const evidence = extractLifeEventEvidence({
|
||||
|
||||
@@ -295,8 +295,9 @@ test("server rejects invented sources, private details, exact minutes, and ungat
|
||||
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|出生时间/);
|
||||
assert.ok(unsafeResult.issues.includes("private_detail_exposed"));
|
||||
assert.ok(unsafeResult.issues.includes("exact_minute_claimed"));
|
||||
assert.equal(unsafeResult.plan, null);
|
||||
|
||||
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"));
|
||||
@@ -543,7 +544,7 @@ test("final Director repairs a generic acknowledgement and missing evidence-valu
|
||||
prompts.push(prompt);
|
||||
return {
|
||||
object: phase === "repair"
|
||||
? plan({ publicReply: { acknowledgement: "你提到2020年4月去石油化工研究院实习做研究员。", evidenceExplanation: "这是一条职业状态变化线索,按能力矩阵可参考 D10 与 Vimshottari;目前只是方法映射,不是实际计算结果。", candidateCommentary: "这段经历的时间和工作状态变化都很明确,可以和其他独立事件交叉比较候选范围。", limitation: null }, publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }] })
|
||||
? plan({ action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["need_independent_event"] }, question: "这条职业变化已经有明确月份;为了交叉核对,你还愿意讲一件时间大致确定、与它不同的经历吗?", optionalQuickReplies: [] }, publicReply: { acknowledgement: "你提到2020年4月去石油化工研究院实习做研究员。", evidenceExplanation: "这是一条职业状态变化线索,方法层可参考 D10 与 Vimshottari;目前只是方法映射,不是实际计算结果。", candidateCommentary: null, limitation: null }, publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }] })
|
||||
: plan(),
|
||||
};
|
||||
},
|
||||
@@ -553,50 +554,90 @@ test("final Director repairs a generic acknowledgement and missing evidence-valu
|
||||
assert.deepEqual(phases, ["final", "repair"]);
|
||||
assert.match(JSON.parse(prompts[0]!).publicReplyRequirement, /acknowledge the exact event/);
|
||||
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.evidenceExplanation ?? "", /D10.*Vimshottari/);
|
||||
assert.equal(result.plan.publicReply.acknowledgement, "你提到2020年4月去石油化工研究院实习做研究员。");
|
||||
assert.equal(result.plan.publicReply.evidenceExplanation, "这是一条职业状态变化线索,方法层可参考 D10 与 Vimshottari;目前只是方法映射,不是实际计算结果。");
|
||||
assert.equal(result.plan.action.type === "ask_question" ? result.plan.action.question : null, "这条职业变化已经有明确月份;为了交叉核对,你还愿意讲一件时间大致确定、与它不同的经历吗?");
|
||||
assert.equal(result.plan.publicReply.candidateCommentary, null);
|
||||
});
|
||||
|
||||
test("manual question regeneration uses the server renderer and ignores model wording", async () => {
|
||||
test("manual question regeneration preserves safe Agent wording and repairs unsafe output", async () => {
|
||||
const eventValue = event({
|
||||
summary: "2016年9月离家去外地上大学",
|
||||
rawText: "2016年9月离家去外地上大学",
|
||||
dateRange: { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "2016年9月" },
|
||||
});
|
||||
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|分盘|测算|推演|甲组|头一组|前者占优/);
|
||||
}
|
||||
const safeQuestion = "在这段求学经历之外,你还愿意讲一件时间大致确定、对你影响明显的事情吗?";
|
||||
const phases: string[] = [];
|
||||
const question = await regenerateDirectorQuestion({
|
||||
caseValue,
|
||||
currentQuestion: "你还记得另一件经历吗?",
|
||||
latestAnswer: eventValue.rawText,
|
||||
acceptedEvents: [eventValue],
|
||||
focus: {
|
||||
mode: "collect_independent_event",
|
||||
targetEventId: null,
|
||||
domain: null,
|
||||
requestedFacts: ["independent_event"],
|
||||
rationaleCodes: ["candidate_contrast"],
|
||||
},
|
||||
generateQuestion: async (_prompt, phase) => {
|
||||
phases.push(phase);
|
||||
return phase === "regenerate"
|
||||
? { object: { question: "D10 已经证明较早候选更可信,你还能说一件事吗?" } }
|
||||
: { object: { question: safeQuestion } };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(phases, ["regenerate", "repair"]);
|
||||
assert.equal(question, safeQuestion);
|
||||
});
|
||||
|
||||
test("manual question regeneration falls back only after two unsafe Agent attempts", async () => {
|
||||
const eventValue = event({
|
||||
summary: "2016年9月离家去外地上大学",
|
||||
rawText: "2016年9月离家去外地上大学",
|
||||
dateRange: { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "2016年9月" },
|
||||
});
|
||||
let calls = 0;
|
||||
const question = await regenerateDirectorQuestion({
|
||||
caseValue,
|
||||
currentQuestion: "你还记得另一件经历吗?",
|
||||
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: "分盘已表明甲组更有把握,你还能说一件事吗?" } };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls, 2);
|
||||
assert.match(question, /工作状态变化/);
|
||||
assert.doesNotMatch(question, /D10|分盘|测算|推演|甲组|头一组|前者占优/);
|
||||
});
|
||||
|
||||
|
||||
test("public reply allows method names but rejects private internals", () => {
|
||||
test("the dossier exposes compound public signals without duplicating scoreable events", () => {
|
||||
const university = event({
|
||||
summary: "2016年9月离家去外地上大学",
|
||||
rawText: "2016年9月离家去外地上大学",
|
||||
});
|
||||
const built = dossier([university]);
|
||||
|
||||
assert.equal(built.eventLedger.length, 1);
|
||||
assert.deepEqual(built.eventLedger[0]?.publicSignals, [
|
||||
{ domain: "education", role: "primary", techniqueLayers: ["D24", "vimshottari", "narayana"] },
|
||||
{ domain: "relocation", role: "secondary", techniqueLayers: ["D4", "vimshottari", "narayana"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("valid grounded Agent copy is preserved verbatim", () => {
|
||||
const internship = event({
|
||||
domain: "career",
|
||||
eventKind: "career_change",
|
||||
@@ -604,27 +645,54 @@ test("public reply allows method names but rejects private internals", () => {
|
||||
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",
|
||||
const candidate = plan({
|
||||
action: {
|
||||
type: "ask_question",
|
||||
focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["cross_check"] },
|
||||
question: "这条职业变化已有明确月份;为了交叉核对,你还愿意讲一件时间大致确定、与它不同的经历吗?",
|
||||
optionalQuickReplies: [],
|
||||
},
|
||||
publicReply: {
|
||||
acknowledgement: "你提到2020年4月去研究院实习并担任研究员,这条时间线索很清楚。",
|
||||
evidenceExplanation: "工作角色开始变化时,方法层会参考 D10、A10 和 Vimshottari;这里只说明核对层面,还不是候选结论。",
|
||||
candidateCommentary: null,
|
||||
limitation: null,
|
||||
},
|
||||
publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }],
|
||||
});
|
||||
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 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",
|
||||
});
|
||||
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 result = validateRectificationTurnPlan({ plan: candidate, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" });
|
||||
assert.deepEqual(result.issues, []);
|
||||
assert.deepEqual(result.plan, candidate);
|
||||
});
|
||||
|
||||
test("public explanations are server-rendered from current-event capability grounding", () => {
|
||||
test("compound education and relocation explanations may cite D24 and D4", () => {
|
||||
const university = event({ summary: "2016年9月离家去外地上大学", rawText: "2016年9月离家去外地上大学" });
|
||||
const candidate = plan({
|
||||
action: {
|
||||
type: "ask_question",
|
||||
focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["cross_check"] },
|
||||
question: "这次经历同时有学习与离开原居住地两层变化;你还愿意讲一件时间大致确定、与它不同的经历吗?",
|
||||
optionalQuickReplies: [],
|
||||
},
|
||||
publicReply: {
|
||||
acknowledgement: "你提到2016年9月离家去外地上大学。",
|
||||
evidenceExplanation: "上大学属于教育路径变化,可参考 D24;离家到外地长期生活也包含居住基地变化,可参考 D4。这里只说明核对层面,不代表已经形成候选结论。",
|
||||
candidateCommentary: null,
|
||||
limitation: null,
|
||||
},
|
||||
publicExplanationGrounding: [
|
||||
{ source: "capability_matrix", factKey: "domain:education" },
|
||||
{ source: "capability_matrix", factKey: "domain:relocation" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = validateRectificationTurnPlan({ plan: candidate, dossier: dossier([university]), latestAnswer: university.rawText, phase: "final" });
|
||||
assert.deepEqual(result.issues, []);
|
||||
assert.deepEqual(result.plan, candidate);
|
||||
});
|
||||
|
||||
test("public reply rejects ungrounded methods, private internals, and candidate conclusions", () => {
|
||||
const internship = event({
|
||||
domain: "career",
|
||||
eventKind: "career_change",
|
||||
@@ -633,87 +701,45 @@ test("public explanations are server-rendered from current-event capability grou
|
||||
dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" },
|
||||
});
|
||||
const base = {
|
||||
acknowledgement: "你提到的是2020年4月去研究院实习做研究员。",
|
||||
candidateCommentary: "D10 已经显示较早候选更符合。",
|
||||
acknowledgement: "你提到2020年4月去研究院实习做研究员。",
|
||||
evidenceExplanation: "这条职业变化可参考 D10。",
|
||||
candidateCommentary: null,
|
||||
limitation: null,
|
||||
} as const;
|
||||
const madeUp = plan({
|
||||
publicReply: { ...base, evidenceExplanation: "这条职业变化可参考 D10。" },
|
||||
publicExplanationGrounding: [{ source: "capability_matrix", factKey: "made-up-key" }],
|
||||
});
|
||||
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。" },
|
||||
publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:education" }],
|
||||
const wrongDomain = validateRectificationTurnPlan({
|
||||
plan: plan({ publicReply: { ...base, evidenceExplanation: "这条职业变化可参考 D24。" }, publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:education" }] }),
|
||||
dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final",
|
||||
});
|
||||
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("event_explanation_grounding_missing"));
|
||||
assert.ok(wrongDomain.issues.includes("public_grounding_invalid"));
|
||||
assert.ok(wrongDomain.issues.includes("event_explanation_grounding_missing"));
|
||||
assert.ok(wrongDomain.issues.includes("public_technique_ungrounded"));
|
||||
|
||||
for (const arbitraryExplanation of [
|
||||
for (const privateDetail of ["原始评分 8.7", "权重 0.4", "贡献矩阵如下", "tool_call 原始输出", "我先调用 case_read,再调用 candidate_scan 和 diagnostic_read"]) {
|
||||
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",
|
||||
});
|
||||
assert.ok(result.issues.includes("private_detail_exposed"), privateDetail);
|
||||
}
|
||||
|
||||
for (const candidateClaim of [
|
||||
"D10 已经显示这次职业变化支持较早的候选区间。",
|
||||
"这只是方法映射;D10 已经证明这次经历更符合较早出生。",
|
||||
"这只是方法映射;D10 说明该经历让 A 方案更可信。",
|
||||
"D10 说明 A 方案更可信,你还记得另一件经历吗?",
|
||||
]) {
|
||||
const result = validateRectificationTurnPlan({
|
||||
plan: plan({
|
||||
publicReply: { ...base, evidenceExplanation: arbitraryExplanation },
|
||||
action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: candidateClaim.includes("吗") ? candidateClaim : "你还愿意讲一件时间大致确定的经历吗?", optionalQuickReplies: [] },
|
||||
publicReply: { ...base, evidenceExplanation: candidateClaim.includes("吗") ? base.evidenceExplanation : candidateClaim },
|
||||
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);
|
||||
assert.ok(result.issues.includes("ungrounded_candidate_conclusion"), candidateClaim);
|
||||
}
|
||||
|
||||
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.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 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 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.match(directedDomain.plan?.action.type === "ask_question" ? directedDomain.plan.action.question : "", /工作状态变化/);
|
||||
});
|
||||
|
||||
test("the final plan cannot repeat a previously asked question", () => {
|
||||
@@ -722,7 +748,7 @@ test("the final plan cannot repeat a previously asked question", () => {
|
||||
action: {
|
||||
type: "ask_question",
|
||||
focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] },
|
||||
question: "你还能想到一件时间大致确定的重要经历吗",
|
||||
question: previous,
|
||||
optionalQuickReplies: [],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user