feat: make rectification agent conversational
This commit is contained in:
@@ -5,6 +5,9 @@ const uuid = z.string().uuid();
|
||||
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const nonblank = (max: number) => z.string().trim().min(1).max(max);
|
||||
|
||||
export const CURRENT_RECTIFICATION_SKILL_VERSION = "birth-time-rectification-v6" as const;
|
||||
export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-agent-v6-1" as const;
|
||||
|
||||
export const rectificationDiagnosticSchema = z.enum([
|
||||
"leave_one_event_out",
|
||||
"leave_one_domain_out",
|
||||
@@ -26,21 +29,41 @@ export const rectificationDecisionSchema = z.discriminatedUnion("action", [
|
||||
]);
|
||||
export type RectificationDecision = z.infer<typeof rectificationDecisionSchema>;
|
||||
|
||||
export const questionOpportunitySchema = z.object({
|
||||
opportunityId: uuid,
|
||||
kind: z.enum([
|
||||
"clarify_intake",
|
||||
"clarify_event_subject",
|
||||
"refine_event_date",
|
||||
"pair_related_event",
|
||||
"ask_new_event",
|
||||
"resolve_event_conflict",
|
||||
"disambiguate_candidate_split",
|
||||
]),
|
||||
domain: evidenceDomainSchema,
|
||||
targetEventId: uuid.nullable(),
|
||||
prompt: nonblank(1_000),
|
||||
reason: nonblank(240),
|
||||
export const semanticQuestionKindSchema = z.enum([
|
||||
"clarify_intake",
|
||||
"clarify_event_subject",
|
||||
"refine_event_date",
|
||||
"pair_related_event",
|
||||
"ask_new_event",
|
||||
"resolve_event_conflict",
|
||||
"disambiguate_candidate_split",
|
||||
]);
|
||||
export type SemanticQuestionKind = z.infer<typeof semanticQuestionKindSchema>;
|
||||
|
||||
export const requestedQuestionFieldSchema = z.enum([
|
||||
"event_year",
|
||||
"event_month",
|
||||
"event_day",
|
||||
"event_range",
|
||||
"event_subject",
|
||||
"event_stage",
|
||||
"new_dated_event",
|
||||
]);
|
||||
export type RequestedQuestionField = z.infer<typeof requestedQuestionFieldSchema>;
|
||||
|
||||
export const forbiddenQuestionMoveSchema = z.enum([
|
||||
"switch_target_event",
|
||||
"ask_multiple_questions",
|
||||
"claim_exact_birth_minute",
|
||||
"invent_event",
|
||||
"invent_date",
|
||||
"expose_private_score",
|
||||
"expose_internal_id",
|
||||
"expose_technique_trace",
|
||||
]);
|
||||
export type ForbiddenQuestionMove = z.infer<typeof forbiddenQuestionMoveSchema>;
|
||||
|
||||
const opportunityMetrics = {
|
||||
expectedInformationGain: z.number().finite().min(0).max(1),
|
||||
dateSensitivity: z.number().finite().min(0).max(1),
|
||||
candidateSplitRelevance: z.number().finite().min(0).max(1),
|
||||
@@ -51,8 +74,103 @@ export const questionOpportunitySchema = z.object({
|
||||
privacyCost: z.number().finite().min(0).max(1),
|
||||
utility: z.number().finite(),
|
||||
active: z.boolean(),
|
||||
} as const;
|
||||
|
||||
export const semanticQuestionOpportunitySchema = z.object({
|
||||
contractVersion: z.literal("semantic-question-v2"),
|
||||
opportunityId: uuid,
|
||||
kind: semanticQuestionKindSchema,
|
||||
domain: evidenceDomainSchema,
|
||||
targetEventId: uuid.nullable(),
|
||||
goal: nonblank(500),
|
||||
requestedFields: z.array(requestedQuestionFieldSchema).min(1).max(4),
|
||||
anchors: z.array(nonblank(240)).max(8),
|
||||
contextFacts: z.array(nonblank(500)).max(16),
|
||||
forbiddenMoves: z.array(forbiddenQuestionMoveSchema).min(1).max(8),
|
||||
fallbackPrompt: nonblank(1_000),
|
||||
reason: nonblank(500),
|
||||
...opportunityMetrics,
|
||||
}).strict();
|
||||
export type QuestionOpportunity = z.infer<typeof questionOpportunitySchema>;
|
||||
export type SemanticQuestionOpportunity = z.infer<typeof semanticQuestionOpportunitySchema>;
|
||||
|
||||
const legacyQuestionOpportunitySchema = z.object({ prompt: nonblank(1_000) }).passthrough();
|
||||
|
||||
function legacyUuid(value: string): string {
|
||||
let hashValue = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hashValue ^= value.charCodeAt(index);
|
||||
hashValue = Math.imul(hashValue, 16777619);
|
||||
}
|
||||
const block = (hashValue >>> 0).toString(16).padStart(8, "0");
|
||||
return `${block}-${block.slice(0, 4)}-4${block.slice(1, 4)}-8${block.slice(1, 4)}-${block}${block.slice(0, 4)}`;
|
||||
}
|
||||
|
||||
const defaultForbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [
|
||||
"switch_target_event",
|
||||
"ask_multiple_questions",
|
||||
"claim_exact_birth_minute",
|
||||
"invent_event",
|
||||
"invent_date",
|
||||
"expose_private_score",
|
||||
"expose_internal_id",
|
||||
"expose_technique_trace",
|
||||
];
|
||||
|
||||
function numberFrom(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function requestedFieldsFor(kind: SemanticQuestionKind): SemanticQuestionOpportunity["requestedFields"] {
|
||||
if (kind === "clarify_event_subject") return ["event_subject"];
|
||||
if (kind === "refine_event_date") return ["event_month"];
|
||||
if (kind === "disambiguate_candidate_split") return ["event_stage"];
|
||||
if (kind === "ask_new_event" || kind === "pair_related_event") return ["new_dated_event"];
|
||||
return ["event_range"];
|
||||
}
|
||||
|
||||
export function normalizeQuestionOpportunity(value: unknown): SemanticQuestionOpportunity {
|
||||
const semantic = semanticQuestionOpportunitySchema.safeParse(value);
|
||||
if (semantic.success) return semantic.data;
|
||||
const legacy = legacyQuestionOpportunitySchema.parse(value) as Record<string, unknown> & { prompt: string };
|
||||
const kind = semanticQuestionKindSchema.safeParse(legacy.kind).success
|
||||
? semanticQuestionKindSchema.parse(legacy.kind)
|
||||
: "clarify_intake";
|
||||
const domain = evidenceDomainSchema.safeParse(legacy.domain).success
|
||||
? evidenceDomainSchema.parse(legacy.domain)
|
||||
: "other";
|
||||
const targetEventId = uuid.safeParse(legacy.targetEventId).success ? uuid.parse(legacy.targetEventId) : null;
|
||||
const reason = typeof legacy.reason === "string" && legacy.reason.trim() ? legacy.reason.trim().slice(0, 500) : "历史问题机会兼容读取。";
|
||||
return semanticQuestionOpportunitySchema.parse({
|
||||
contractVersion: "semantic-question-v2",
|
||||
opportunityId: uuid.safeParse(legacy.opportunityId).success ? legacy.opportunityId : legacyUuid(legacy.prompt),
|
||||
kind,
|
||||
domain,
|
||||
targetEventId,
|
||||
goal: reason,
|
||||
requestedFields: requestedFieldsFor(kind),
|
||||
anchors: [],
|
||||
contextFacts: [],
|
||||
forbiddenMoves: defaultForbiddenMoves,
|
||||
fallbackPrompt: legacy.prompt,
|
||||
reason,
|
||||
expectedInformationGain: numberFrom(legacy.expectedInformationGain, .5),
|
||||
dateSensitivity: numberFrom(legacy.dateSensitivity, .5),
|
||||
candidateSplitRelevance: numberFrom(legacy.candidateSplitRelevance, .5),
|
||||
domainCoverageGain: numberFrom(legacy.domainCoverageGain, 0),
|
||||
recallEase: numberFrom(legacy.recallEase, .5),
|
||||
novelty: numberFrom(legacy.novelty, .5),
|
||||
repetitionPenalty: numberFrom(legacy.repetitionPenalty, 0),
|
||||
privacyCost: numberFrom(legacy.privacyCost, 0),
|
||||
utility: numberFrom(legacy.utility, .5),
|
||||
active: typeof legacy.active === "boolean" ? legacy.active : true,
|
||||
});
|
||||
}
|
||||
|
||||
export const questionOpportunitySchema = z.union([
|
||||
semanticQuestionOpportunitySchema,
|
||||
legacyQuestionOpportunitySchema,
|
||||
]).transform(normalizeQuestionOpportunity);
|
||||
export type QuestionOpportunity = z.output<typeof questionOpportunitySchema>;
|
||||
|
||||
export const eventDateSensitivitySchema = z.object({
|
||||
eventId: uuid,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import { z } from "zod";
|
||||
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import {
|
||||
eventKindSchema,
|
||||
eventSubjectSchema,
|
||||
evidenceDomainSchema,
|
||||
relatedPersonSchema,
|
||||
} from "../rectification-v4/contracts.ts";
|
||||
import {
|
||||
validatedModelAssistedEvidence,
|
||||
type ExtractedLifeEventEvidence,
|
||||
type ModelAssistedEventExtraction,
|
||||
} from "../conversational-rectification/evidence-extractor.ts";
|
||||
|
||||
export const modelAssistedEventExtractionSchema = z.object({
|
||||
sourceSpan: z.string().trim().min(1).max(4_000),
|
||||
summary: z.string().trim().min(1).max(1_000),
|
||||
domain: evidenceDomainSchema,
|
||||
eventKind: eventKindSchema,
|
||||
subject: eventSubjectSchema,
|
||||
relatedPerson: relatedPersonSchema.nullable(),
|
||||
dateText: z.string().trim().min(1).max(80).nullable(),
|
||||
}).strict();
|
||||
|
||||
export type EventExtractorGenerator = (prompt: string) => Promise<Readonly<{ object: unknown }>>;
|
||||
|
||||
export async function extractEventWithModel(input: Readonly<{
|
||||
rawText: string;
|
||||
sourceTurnId: string;
|
||||
asOfDate: string;
|
||||
modelId?: string | null;
|
||||
timeoutMs?: number;
|
||||
generateExtraction?: EventExtractorGenerator;
|
||||
}>): Promise<ExtractedLifeEventEvidence | null> {
|
||||
const model = (input.modelId ? resolveLanguageModel(input.modelId) : null) ?? defaultLanguageModel();
|
||||
if (!model && !input.generateExtraction) return null;
|
||||
const agent = model ? new Agent({
|
||||
id: `rectification-event-extractor-${model.id}`,
|
||||
name: "Restricted Rectification Event Extractor",
|
||||
model: model.model,
|
||||
instructions: "Extract at most one explicitly stated dated life event. sourceSpan and dateText must be exact continuous substrings of the user text. Never infer or invent a date, normalized range, candidate time, score, id, or profile value. Return strict JSON only.",
|
||||
}) : null;
|
||||
const generate = input.generateExtraction ?? (async (prompt: string) => {
|
||||
if (!agent) throw new Error("event_extractor_model_unavailable");
|
||||
return agent.generate(prompt, {
|
||||
abortSignal: AbortSignal.timeout(input.timeoutMs ?? 10_000),
|
||||
structuredOutput: { schema: modelAssistedEventExtractionSchema, jsonPromptInjection: "inline" },
|
||||
});
|
||||
});
|
||||
try {
|
||||
const result = await generate(JSON.stringify({
|
||||
task: "Extract one event that deterministic parsing could not classify. Use only literal text from userText.",
|
||||
userText: input.rawText,
|
||||
asOfDate: input.asOfDate,
|
||||
allowedOutput: ["sourceSpan", "summary", "domain", "eventKind", "subject", "relatedPerson", "dateText"],
|
||||
}));
|
||||
const extraction = modelAssistedEventExtractionSchema.parse(result.object) as ModelAssistedEventExtraction;
|
||||
return validatedModelAssistedEvidence({
|
||||
rawText: input.rawText,
|
||||
sourceTurnId: input.sourceTurnId,
|
||||
asOfDate: input.asOfDate,
|
||||
extraction,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,27 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts";
|
||||
import type { DiagnosticsSummary, QuestionOpportunity } from "./contracts.ts";
|
||||
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
|
||||
import type { DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts";
|
||||
|
||||
const domains: readonly EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure"];
|
||||
const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [
|
||||
"switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event",
|
||||
"invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace",
|
||||
];
|
||||
|
||||
const domainPolicy: Readonly<Record<Exclude<EvidenceDomain, "family" | "other">, Readonly<{
|
||||
goal: string;
|
||||
fallbackPrompt: string;
|
||||
keywords: RegExp;
|
||||
recallEase: number;
|
||||
privacyCost: number;
|
||||
}>>> = {
|
||||
education: { goal: "收集一件有大致日期的教育转折。", fallbackPrompt: "哪次入学、毕业或专业变化的时间你比较确定?", keywords: /大学|学校|入学|毕业|考试|专业|读书/, recallEase: .82, privacyCost: .03 },
|
||||
relocation: { goal: "收集一件有大致日期的迁居经历。", fallbackPrompt: "哪次搬家、离乡或长期迁居的时间你比较确定?", keywords: /搬家|迁居|离家|外地|城市|北京|上海|出国/, recallEase: .78, privacyCost: .04 },
|
||||
relationship: { goal: "在用户愿意的前提下收集一件有大致日期的关系转折。", fallbackPrompt: "如果方便,哪段关系开始、结束或进入婚姻的时间比较确定?", keywords: /恋爱|关系|结婚|离婚|分手|伴侣|对象/, recallEase: .62, privacyCost: .22 },
|
||||
career: { goal: "收集一件有大致日期的职业转折。", fallbackPrompt: "哪次入职、离职、转行、创业或职责变化的时间你比较确定?", keywords: /工作|实习|公司|研究院|职业|入职|离职|创业|负责/, recallEase: .85, privacyCost: .03 },
|
||||
finance: { goal: "在用户愿意的前提下收集一件有大致日期的财务转折。", fallbackPrompt: "如果方便,哪次收入、负债或资产明显变化的时间比较确定?", keywords: /收入|负债|投资|资产|财务|买房|卖房/, recallEase: .6, privacyCost: .18 },
|
||||
health_pressure: { goal: "在用户愿意的前提下收集一件本人有大致日期的健康转折。", fallbackPrompt: "如果方便,你本人哪次住院、手术、事故或健康转折的时间比较确定?", keywords: /住院|手术|事故|健康|生病|确诊|康复/, recallEase: .58, privacyCost: .28 },
|
||||
};
|
||||
|
||||
function stableUuid(value: string): string {
|
||||
const hex = createHash("sha256").update(value).digest("hex").slice(0, 32).split("");
|
||||
@@ -21,7 +40,9 @@ const routingValue: Record<QuestionOpportunity["kind"], number> = {
|
||||
ask_new_event: 0,
|
||||
};
|
||||
|
||||
function utility(value: Omit<QuestionOpportunity, "opportunityId" | "utility" | "active">): number {
|
||||
type OpportunityInput = Omit<SemanticQuestionOpportunity, "contractVersion" | "opportunityId" | "utility" | "active" | "forbiddenMoves">;
|
||||
|
||||
function utility(value: OpportunityInput): number {
|
||||
return Number((
|
||||
.35 * value.expectedInformationGain + .20 * value.dateSensitivity + .15 * value.candidateSplitRelevance
|
||||
+ .10 * value.domainCoverageGain + .10 * value.recallEase + .10 * value.novelty
|
||||
@@ -29,8 +50,34 @@ function utility(value: Omit<QuestionOpportunity, "opportunityId" | "utility" |
|
||||
).toFixed(6));
|
||||
}
|
||||
|
||||
function opportunity(caseId: string, input: Omit<QuestionOpportunity, "opportunityId" | "utility" | "active">): QuestionOpportunity {
|
||||
const result = { ...input, opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.prompt}`), utility: utility(input), active: true };
|
||||
function opportunity(caseId: string, input: OpportunityInput): QuestionOpportunity {
|
||||
return {
|
||||
contractVersion: "semantic-question-v2",
|
||||
...input,
|
||||
forbiddenMoves,
|
||||
opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.goal}:${input.fallbackPrompt}`),
|
||||
utility: utility(input),
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
function daysWide(event: LifeEventRevision): number {
|
||||
return Math.floor((Date.parse(`${event.dateRange.end}T00:00:00Z`) - Date.parse(`${event.dateRange.start}T00:00:00Z`)) / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
function anchorFor(event: LifeEventRevision): string {
|
||||
return event.summary.replace(/[“”"']/g, "").trim().slice(0, 80);
|
||||
}
|
||||
|
||||
function recentText(turns: readonly RectificationV4Turn[]): string {
|
||||
return turns.slice(-6).map((turn) => turn.answer).join(" ");
|
||||
}
|
||||
|
||||
function declinedSensitiveDomains(turns: readonly RectificationV4Turn[]): ReadonlySet<EvidenceDomain> {
|
||||
const result = new Set<EvidenceDomain>();
|
||||
for (const turn of turns) {
|
||||
if (turn.questionDomain && /不想说|不方便说|不想回答|跳过|这个不说|换个方向|不聊这个/.test(turn.answer)) result.add(turn.questionDomain);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -40,74 +87,125 @@ export function buildQuestionOpportunities(input: Readonly<{
|
||||
turns: readonly RectificationV4Turn[];
|
||||
snapshot: CandidateSnapshot | null;
|
||||
diagnostics: DiagnosticsSummary | null;
|
||||
targetDisposition?: TargetDisposition;
|
||||
retryTargetEventIds?: readonly string[];
|
||||
}>): readonly QuestionOpportunity[] {
|
||||
const attempted = new Set(input.turns.flatMap((turn) => turn.questionTargetEventId ? [turn.questionTargetEventId] : []));
|
||||
const targetAttempts = new Map<string, number>();
|
||||
for (const turn of input.turns) {
|
||||
if (turn.questionTargetEventId) targetAttempts.set(turn.questionTargetEventId, (targetAttempts.get(turn.questionTargetEventId) ?? 0) + 1);
|
||||
}
|
||||
const retryTargets = new Set(input.retryTargetEventIds ?? []);
|
||||
const scoreableDomains = new Set(input.events.filter((event) => event.scoreability === "scoreable").map((event) => event.domain));
|
||||
const refusedDomains = declinedSensitiveDomains(input.turns);
|
||||
const latestContext = recentText(input.turns);
|
||||
const opportunities: QuestionOpportunity[] = [];
|
||||
for (const eventId of retryTargets) {
|
||||
const event = input.events.find((value) => value.eventId === eventId);
|
||||
if (!event) continue;
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "resolve_event_conflict", domain: event.domain, targetEventId: event.eventId,
|
||||
prompt: `你刚才补充的新经历已经另行保存。关于“${event.summary}”的时间仍没有确定;如果记不清,可以直接说不知道。`,
|
||||
reason: "用户补充了另一件事,原事件的日期或主体仍待确认。",
|
||||
expectedInformationGain: .85, dateSensitivity: .75, candidateSplitRelevance: .6, domainCoverageGain: 0, recallEase: .8, novelty: .7, repetitionPenalty: .15, privacyCost: .05,
|
||||
}));
|
||||
}
|
||||
if (opportunities.length > 0) {
|
||||
return opportunities.sort((left, right) =>
|
||||
right.utility - left.utility
|
||||
|| left.opportunityId.localeCompare(right.opportunityId));
|
||||
|
||||
if (input.targetDisposition === "answered_other_event") {
|
||||
for (const eventId of retryTargets) {
|
||||
const event = input.events.find((value) => value.eventId === eventId);
|
||||
if (!event || (targetAttempts.get(eventId) ?? 0) > 1) continue;
|
||||
const anchor = anchorFor(event);
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "resolve_event_conflict", domain: event.domain, targetEventId: event.eventId,
|
||||
goal: `温和确认“${anchor}”尚缺的日期或主体;允许用户直接跳过。`,
|
||||
requestedFields: ["event_range"], anchors: [anchor], contextFacts: [`用户刚补充了另一件完整事件。`, `同一目标最多补问一次。`],
|
||||
fallbackPrompt: `关于“${anchor}”,如果还记得大概时间范围,可以补充一下吗?`,
|
||||
reason: "用户回答了另一件新事件,原目标只允许一次温和补问。",
|
||||
expectedInformationGain: .78, dateSensitivity: .7, candidateSplitRelevance: .55, domainCoverageGain: 0,
|
||||
recallEase: .72, novelty: .55, repetitionPenalty: .25, privacyCost: .05,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const targetClosed = input.targetDisposition === "unknown"
|
||||
|| input.targetDisposition === "declined"
|
||||
|| input.targetDisposition === "direction_change";
|
||||
for (const event of input.events) {
|
||||
if (retryTargets.has(event.eventId)) continue;
|
||||
if ((event.scoreability === "pending_review" || event.subject === "other") && !attempted.has(event.eventId)) {
|
||||
if (targetClosed && retryTargets.has(event.eventId)) continue;
|
||||
const attemptCount = targetAttempts.get(event.eventId) ?? 0;
|
||||
const anchor = anchorFor(event);
|
||||
if ((event.scoreability === "pending_review" || event.subject === "other") && attemptCount === 0) {
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "clarify_event_subject", domain: event.domain, targetEventId: event.eventId,
|
||||
prompt: `你刚才提到“${event.summary}”,这件事主要发生在你本人,还是家人或伴侣身上?`, reason: "事件主体决定是否允许进入个人分盘评分。",
|
||||
expectedInformationGain: .9, dateSensitivity: .2, candidateSplitRelevance: .3, domainCoverageGain: .2, recallEase: .95, novelty: .9, repetitionPenalty: 0, privacyCost: .05,
|
||||
}));
|
||||
}
|
||||
if (event.scoreability === "scoreable" && event.dateRange.precision !== "day" && !attempted.has(event.eventId)) {
|
||||
const sensitivity = input.diagnostics?.eventDateSensitivity.find((item) => item.eventId === event.eventId);
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "refine_event_date", domain: event.domain, targetEventId: event.eventId,
|
||||
prompt: `关于“${event.summary}”,你还记得更具体的月份或日期吗?不确定也可以只说大概范围。`, reason: "日期采样显示这件事的时间精度可能影响候选排序。",
|
||||
expectedInformationGain: sensitivity ? 1 - sensitivity.winnerRetentionRate : .72,
|
||||
dateSensitivity: sensitivity ? 1 - sensitivity.candidateClusterRetentionRate : .7,
|
||||
candidateSplitRelevance: .55, domainCoverageGain: 0, recallEase: .72, novelty: .8, repetitionPenalty: 0, privacyCost: .05,
|
||||
goal: `确认“${anchor}”发生在本人、家人还是伴侣。`, requestedFields: ["event_subject"],
|
||||
anchors: [anchor], contextFacts: [`当前主体为 ${event.subject}。`],
|
||||
fallbackPrompt: `“${anchor}”主要发生在你本人、家人还是伴侣身上?`,
|
||||
reason: "事件主体决定是否允许进入个人评分。",
|
||||
expectedInformationGain: .9, dateSensitivity: .2, candidateSplitRelevance: .3, domainCoverageGain: .2,
|
||||
recallEase: .95, novelty: .9, repetitionPenalty: 0, privacyCost: event.domain === "health_pressure" || event.domain === "family" ? .24 : .05,
|
||||
}));
|
||||
}
|
||||
if (event.scoreability !== "scoreable" || event.dateRange.precision === "day" || attemptCount > 0) continue;
|
||||
const sensitivity = input.diagnostics?.eventDateSensitivity.find((item) => item.eventId === event.eventId);
|
||||
const dateSensitive = Boolean(sensitivity && (sensitivity.winnerRetentionRate < .65 || sensitivity.candidateClusterRetentionRate < .65));
|
||||
const precision = event.dateRange.precision;
|
||||
const shouldRefine = precision === "quarter" || precision === "year" || (precision === "month" && dateSensitive)
|
||||
|| (precision === "range" && daysWide(event) > 120 && dateSensitive);
|
||||
if (!shouldRefine) continue;
|
||||
const requestedFields: SemanticQuestionOpportunity["requestedFields"] = precision === "year" || precision === "quarter"
|
||||
? ["event_month"]
|
||||
: precision === "range" ? ["event_range"] : ["event_day"];
|
||||
const fallbackPrompt = precision === "year" || precision === "quarter"
|
||||
? `“${anchor}”大概发生在哪个月,或一年中的哪个时间段?`
|
||||
: precision === "range"
|
||||
? `“${anchor}”的时间范围还能再缩小一些吗?`
|
||||
: `关于“${anchor}”,你还记得大概哪一天吗?`;
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "refine_event_date", domain: event.domain, targetEventId: event.eventId,
|
||||
goal: `仅在必要精度上细化“${anchor}”的日期。`, requestedFields, anchors: [anchor],
|
||||
contextFacts: [`现有精度为 ${precision}。`, ...(sensitivity ? [`候选保持率 ${sensitivity.candidateClusterRetentionRate}。`] : [])],
|
||||
fallbackPrompt, reason: dateSensitive ? "日期敏感性诊断显示该事件可能改变候选排序。" : "当前日期范围较宽。",
|
||||
expectedInformationGain: sensitivity ? 1 - sensitivity.winnerRetentionRate : .66,
|
||||
dateSensitivity: sensitivity ? 1 - sensitivity.candidateClusterRetentionRate : .55,
|
||||
candidateSplitRelevance: .55, domainCoverageGain: 0, recallEase: precision === "year" ? .8 : .62,
|
||||
novelty: .78, repetitionPenalty: 0, privacyCost: .05,
|
||||
}));
|
||||
}
|
||||
|
||||
const split = input.diagnostics?.candidateSplits[0];
|
||||
if (split) {
|
||||
const target = input.events.find((event) => split.eventIds.includes(event.eventId));
|
||||
const target = input.events.find((event) => split.eventIds.includes(event.eventId)
|
||||
&& (targetAttempts.get(event.eventId) ?? 0) === 0
|
||||
&& !(targetClosed && retryTargets.has(event.eventId)));
|
||||
const anchor = target ? anchorFor(target) : null;
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "disambiguate_candidate_split", domain: target?.domain ?? "other", targetEventId: target?.eventId ?? null,
|
||||
prompt: target ? `围绕“${target.summary}”,当时最明显的转折是事情开始、达到高峰,还是正式结束?` : "剩余候选在同一事件的阶段上有差异:你记得当时更接近开始、达到高峰,还是正式结束吗?",
|
||||
reason: `候选簇在 ${split.techniqueLayers.slice(0, 3).join("、") || "技术层"} 上出现可检验分歧。`,
|
||||
expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0, recallEase: .65, novelty: .9, repetitionPenalty: target && attempted.has(target.eventId) ? .35 : 0, privacyCost: .1,
|
||||
goal: target ? `确认“${anchor}”更接近开始、高峰还是正式结束。` : "确认一件现有事件的发生阶段。",
|
||||
requestedFields: ["event_stage"], anchors: anchor ? [anchor] : [],
|
||||
contextFacts: [`候选分歧涉及 ${split.techniqueLayers.length} 个已计算技术层。`],
|
||||
fallbackPrompt: target ? `“${anchor}”当时更接近事情开始、达到高峰,还是正式结束?` : "那件经历更接近开始、达到高峰,还是正式结束?",
|
||||
reason: "候选簇在现有诊断中出现可检验分歧。",
|
||||
expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0,
|
||||
recallEase: .65, novelty: .9, repetitionPenalty: 0, privacyCost: .1,
|
||||
}));
|
||||
}
|
||||
const missingDomain = domains.find((domain) => !scoreableDomains.has(domain));
|
||||
if (missingDomain) {
|
||||
const prompts: Record<EvidenceDomain, string> = {
|
||||
education: "你人生中有没有一次入学、毕业、考试或专业变化,时间大致在什么时候?",
|
||||
relocation: "你有没有一次印象深刻的搬家、离乡或长期迁居?大致在什么时候?",
|
||||
relationship: "你有没有一段关系正式开始、结束或进入婚姻的明确时间点?",
|
||||
career: "你有没有一次入职、离职、升职、转行或创业的明确时间点?",
|
||||
finance: "你有没有一次收入、投资、负债或资产状况明显改变的时间点?",
|
||||
health_pressure: "你本人有没有一次住院、手术、事故或明显健康转折?大致在什么时候?",
|
||||
family: "请补充一个家庭事件。", other: "请补充一个有明确时间的重要人生事件。",
|
||||
};
|
||||
|
||||
const scoreableCount = input.events.filter((event) => event.scoreability === "scoreable").length;
|
||||
for (const [domain, policy] of Object.entries(domainPolicy) as [Exclude<EvidenceDomain, "family" | "other">, (typeof domainPolicy)[Exclude<EvidenceDomain, "family" | "other">]][]) {
|
||||
if (refusedDomains.has(domain)) continue;
|
||||
const covered = scoreableDomains.has(domain);
|
||||
const themeBonus = policy.keywords.test(latestContext) ? .12 : 0;
|
||||
const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId);
|
||||
const latestEvent = input.events.at(-1);
|
||||
const latestAnchor = latestEvent ? anchorFor(latestEvent) : null;
|
||||
const prompt = latestAnchor
|
||||
? `承接“${latestAnchor}”,请再说一件时间相对明确的经历:${policy.fallbackPrompt}`
|
||||
: policy.fallbackPrompt;
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "ask_new_event", domain: missingDomain, targetEventId: null, prompt: prompts[missingDomain], reason: "当前证据领域覆盖不足。",
|
||||
expectedInformationGain: .7, dateSensitivity: .45, candidateSplitRelevance: .5, domainCoverageGain: 1, recallEase: .7, novelty: 1, repetitionPenalty: 0, privacyCost: missingDomain === "health_pressure" ? .2 : .08,
|
||||
kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal,
|
||||
requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [],
|
||||
contextFacts: [`已有 ${scoreableCount} 件可评分事件。`, `该领域${covered ? "已有覆盖" : "尚未覆盖"}。`],
|
||||
fallbackPrompt: prompt, reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
|
||||
expectedInformationGain: covered ? .54 + themeBonus : .7 + themeBonus,
|
||||
dateSensitivity: input.snapshot ? .5 : .35,
|
||||
candidateSplitRelevance: input.diagnostics?.candidateSplits.length ? .58 : .42,
|
||||
domainCoverageGain: covered ? 0 : 1,
|
||||
recallEase: policy.recallEase, novelty: alreadyAsked ? .35 : .9,
|
||||
repetitionPenalty: alreadyAsked ? .3 : 0, privacyCost: policy.privacyCost,
|
||||
}));
|
||||
}
|
||||
return opportunities.sort((left, right) =>
|
||||
right.utility - left.utility
|
||||
|| left.opportunityId.localeCompare(right.opportunityId));
|
||||
|
||||
return opportunities
|
||||
.sort((left, right) => right.utility - left.utility || left.opportunityId.localeCompare(right.opportunityId))
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.t
|
||||
import type { CandidateSnapshot, RectificationV4Question } from "../rectification-v4/contracts.ts";
|
||||
import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts";
|
||||
import { reconcileV4Evidence } from "../rectification-v4/extraction.ts";
|
||||
import { extractEventWithModel } from "./event-extractor-agent.ts";
|
||||
import { evidenceSetHash } from "../rectification-v4/fingerprints.ts";
|
||||
import { latestEventRevisions, scoreableEvents } from "../rectification-v4/evidence-ledger.ts";
|
||||
import { projectLegacyV4Turn } from "../rectification-v4/legacy-projector.ts";
|
||||
@@ -48,15 +49,38 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
}>> {
|
||||
const { claimed, now } = input;
|
||||
await input.onPhase?.("extracting_evidence");
|
||||
const reconciliation = claimed.turn.answer ? reconcileV4Evidence({
|
||||
const asOfDate = now.toISOString().slice(0, 10);
|
||||
let reconciliation = claimed.turn.answer ? reconcileV4Evidence({
|
||||
caseId: claimed.case.id,
|
||||
answer: claimed.turn.answer,
|
||||
sourceTurnId: claimed.turn.id,
|
||||
asOfDate: now.toISOString().slice(0, 10),
|
||||
asOfDate,
|
||||
existing: claimed.events,
|
||||
targetEventId: claimed.turn.questionTargetEventId,
|
||||
now,
|
||||
}) : { revisions: [], pending: [], unansweredTargetEventId: null };
|
||||
}) : { revisions: [], pending: [], unansweredTargetEventId: null, targetDisposition: "not_applicable" as const };
|
||||
const needsAssistance = claimed.case.deploymentMode !== "v4_legacy" && (
|
||||
reconciliation.pending.some((event) => event.reasonCode === "event_unparsed")
|
||||
|| reconciliation.revisions.some((event) => event.scoreability === "pending_review" || event.scoreability === "unsupported")
|
||||
);
|
||||
if (needsAssistance) {
|
||||
const assisted = await extractEventWithModel({
|
||||
rawText: claimed.turn.answer,
|
||||
sourceTurnId: claimed.turn.id,
|
||||
asOfDate,
|
||||
modelId: claimed.case.orchestrationModelId,
|
||||
});
|
||||
if (assisted) reconciliation = reconcileV4Evidence({
|
||||
caseId: claimed.case.id,
|
||||
answer: claimed.turn.answer,
|
||||
sourceTurnId: claimed.turn.id,
|
||||
asOfDate,
|
||||
existing: claimed.events,
|
||||
targetEventId: claimed.turn.questionTargetEventId,
|
||||
assistedEvidence: [assisted],
|
||||
now,
|
||||
});
|
||||
}
|
||||
const extracted = reconciliation.revisions;
|
||||
const events = latestEventRevisions([...claimed.events, ...extracted]);
|
||||
const scoreable = scoreableEvents(events);
|
||||
@@ -174,6 +198,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
turns: claimed.turns,
|
||||
snapshot,
|
||||
diagnostics,
|
||||
targetDisposition: reconciliation.targetDisposition,
|
||||
retryTargetEventIds: reconciliation.unansweredTargetEventId ? [reconciliation.unansweredTargetEventId] : [],
|
||||
});
|
||||
await input.onPhase?.("reasoning");
|
||||
@@ -182,6 +207,15 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
snapshot,
|
||||
diagnostics: safeDiagnostics,
|
||||
opportunities,
|
||||
recentTurns: claimed.turns,
|
||||
recentEvents: events,
|
||||
currentTarget: claimed.turn.questionTargetEventId
|
||||
? events.find((event) => event.eventId === claimed.turn.questionTargetEventId) ?? null
|
||||
: null,
|
||||
targetDisposition: reconciliation.targetDisposition,
|
||||
pendingEvidence: reconciliation.pending,
|
||||
candidateRangeChanged: claimed.case.latestSnapshot?.clusters[0]?.startTime !== snapshot?.clusters[0]?.startTime
|
||||
|| claimed.case.latestSnapshot?.clusters[0]?.endTime !== snapshot?.clusters[0]?.endTime,
|
||||
enabled: claimed.case.deploymentMode !== "v4_legacy",
|
||||
});
|
||||
const rawDecision = reasoned.decision;
|
||||
@@ -241,6 +275,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
acceptedEvents: extracted,
|
||||
pendingEvidence: reconciliation.pending,
|
||||
snapshot,
|
||||
previousSnapshot: claimed.case.latestSnapshot,
|
||||
validated: validatedDecision,
|
||||
})
|
||||
: legacyProjection.publicMessage;
|
||||
@@ -248,7 +283,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
id: randomUUID(),
|
||||
domain: selectedOpportunity.domain,
|
||||
targetEventId: selectedOpportunity.targetEventId,
|
||||
prompt: selectedOpportunity.prompt,
|
||||
prompt: publicMessage.question ?? selectedOpportunity.fallbackPrompt,
|
||||
recallCost: selectedOpportunity.privacyCost >= .2
|
||||
? "high" as const
|
||||
: selectedOpportunity.recallEase < .6
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Agent } from "@mastra/core/agent";
|
||||
import { createTool } from "@mastra/core/tools";
|
||||
import { z } from "zod";
|
||||
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import type { CandidateSnapshot, RectificationV4Case } from "../rectification-v4/contracts.ts";
|
||||
import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts";
|
||||
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
|
||||
import { deterministicDecision } from "./fallback-policy.ts";
|
||||
import { recordRectificationAgentTelemetry } from "./telemetry.ts";
|
||||
import {
|
||||
@@ -34,11 +35,53 @@ function diagnosticPayload(diagnostic: RectificationDiagnostic, summary: Diagnos
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReasonerState(input: Readonly<{
|
||||
snapshot: CandidateSnapshot | null;
|
||||
diagnostics: DiagnosticsSummary;
|
||||
opportunities: readonly QuestionOpportunity[];
|
||||
recentTurns?: readonly RectificationV4Turn[];
|
||||
recentEvents?: readonly LifeEventRevision[];
|
||||
currentTarget?: LifeEventRevision | null;
|
||||
targetDisposition?: TargetDisposition;
|
||||
pendingEvidence?: readonly PendingEvidence[];
|
||||
candidateRangeChanged?: boolean;
|
||||
}>) {
|
||||
return {
|
||||
task: "Choose the next bounded rectification action.",
|
||||
currentSnapshotId: input.snapshot?.id ?? null,
|
||||
canOfferCandidateRange: input.snapshot?.canAcceptRange ?? false,
|
||||
hasCandidateRange: Boolean(input.snapshot?.clusters[0]),
|
||||
candidateRangeChanged: input.candidateRangeChanged ?? false,
|
||||
latestAnswer: input.recentTurns?.at(-1)?.answer ?? "",
|
||||
recentTurns: (input.recentTurns ?? []).slice(-6).map((turn) => ({ question: turn.question, answer: turn.answer })),
|
||||
recentEvents: (input.recentEvents ?? []).slice(-5).map((event) => ({ summary: event.summary, date: event.dateRange.label, domain: event.domain, subject: event.subject })),
|
||||
currentTarget: input.currentTarget ? { summary: input.currentTarget.summary, date: input.currentTarget.dateRange.label, domain: input.currentTarget.domain } : null,
|
||||
targetDisposition: input.targetDisposition ?? "not_applicable",
|
||||
pendingEvidence: {
|
||||
count: input.pendingEvidence?.length ?? 0,
|
||||
reasons: [...new Set((input.pendingEvidence ?? []).map((item) => item.reasonCode))],
|
||||
},
|
||||
compactDiagnostics: {
|
||||
primaryClusterRetentionRate: input.diagnostics.primaryClusterRetentionRate,
|
||||
mostDiscriminatingLayers: input.diagnostics.mostDiscriminatingLayers,
|
||||
},
|
||||
opportunities: input.opportunities.map(({ opportunityId, kind, targetEventId, goal, requestedFields, anchors, utility, reason }) => ({
|
||||
opportunityId, kind, targetEventId, goal, requestedFields, anchors, utility, reason,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runBoundedReasoner(input: Readonly<{
|
||||
caseValue: RectificationV4Case;
|
||||
snapshot: CandidateSnapshot | null;
|
||||
diagnostics: DiagnosticsSummary;
|
||||
opportunities: readonly QuestionOpportunity[];
|
||||
recentTurns?: readonly RectificationV4Turn[];
|
||||
recentEvents?: readonly LifeEventRevision[];
|
||||
currentTarget?: LifeEventRevision | null;
|
||||
targetDisposition?: TargetDisposition;
|
||||
pendingEvidence?: readonly PendingEvidence[];
|
||||
candidateRangeChanged?: boolean;
|
||||
maxToolCalls?: number;
|
||||
timeoutMs?: number;
|
||||
enabled?: boolean;
|
||||
@@ -131,16 +174,7 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
outputTokenCount += Math.max(0, Math.trunc(usage.outputTokens ?? 0));
|
||||
usageObserved = true;
|
||||
};
|
||||
const baseState = {
|
||||
task: "Choose the next bounded rectification action.",
|
||||
currentSnapshotId: input.snapshot?.id ?? null,
|
||||
canOfferCandidateRange: input.snapshot?.canAcceptRange ?? false,
|
||||
compactDiagnostics: {
|
||||
primaryClusterRetentionRate: input.diagnostics.primaryClusterRetentionRate,
|
||||
mostDiscriminatingLayers: input.diagnostics.mostDiscriminatingLayers,
|
||||
},
|
||||
opportunities: input.opportunities.map(({ opportunityId, kind, targetEventId, utility, reason }) => ({ opportunityId, kind, targetEventId, utility, reason })),
|
||||
};
|
||||
const baseState = buildReasonerState(input);
|
||||
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "started", modelId, toolName: null, decisionAction: null, durationMs: null, errorCode: null, deploymentSha });
|
||||
try {
|
||||
|
||||
@@ -2,46 +2,142 @@ import path from "node:path";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts";
|
||||
import { publicMessageSchema, type PublicMessage, type ValidatedDecision } from "./contracts.ts";
|
||||
import { publicMessageSchema, type PublicMessage, type QuestionOpportunity, type ValidatedDecision } from "./contracts.ts";
|
||||
import { recordRectificationAgentTelemetry } from "./telemetry.ts";
|
||||
|
||||
const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification");
|
||||
const agents = new Map<string, Agent>();
|
||||
const bannedAcknowledgement = /(?:这个信息很有用|它不是单纯的|而是把|接下来最有价值的是|这样可以避免|已记录[::]?|我记下了)/;
|
||||
const overinterpretedAcknowledgement = /(?:职业方向正式落地|人生意义|意味着你|说明你(?:已经|开始|正式)|标志着你)/;
|
||||
const internalTerms = /(?:opportunityId|snapshotId|eventId|targetEventId|requestedFields|fallbackPrompt|tool\s*call|tool_call|score|评分|模型名|opportunity|snapshot|D\d{1,2}|KP\b|Vimshottari)/i;
|
||||
const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便|再告诉我)/;
|
||||
const birthMinute = /(?:出生|生时|几点).{0,12}(?:[01]\d|2[0-3]):[0-5]\d|(?:[01]\d|2[0-3]):[0-5]\d.{0,12}(?:出生|生时)/;
|
||||
|
||||
function agentFor(modelId: string | null): { id: string; agent: Agent } | null {
|
||||
const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel();
|
||||
if (!selected) return null;
|
||||
const cached = agents.get(selected.id);
|
||||
if (cached) return { id: selected.id, agent: cached };
|
||||
const agent = new Agent({
|
||||
id: `rectification-v5-renderer-${selected.id}`, name: "Birth Time Rectification Response Renderer", model: selected.model, skills: [skillPath],
|
||||
instructions: "Write concise natural Simplified Chinese. Acknowledge the latest experience, state uncertainty honestly, and never expose ids, scores, internal domains, representative minutes, model/tool details, or claim an exact birth minute. Return strict JSON only.",
|
||||
id: `rectification-v6-renderer-${selected.id}`,
|
||||
name: "Birth Time Rectification Response Renderer",
|
||||
model: selected.model,
|
||||
skills: [skillPath],
|
||||
instructions: "Write concise natural Simplified Chinese. Realize exactly one question from the supplied semantic opportunity. Do not invent events or dates, switch targets, interpret the life meaning of an experience, expose ids/scores/techniques, mention a representative minute, or claim an exact birth minute. Avoid canned acknowledgement. Return strict JSON only.",
|
||||
});
|
||||
agents.set(selected.id, agent);
|
||||
return { id: selected.id, agent };
|
||||
}
|
||||
|
||||
function deterministic(input: { latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision }): PublicMessage {
|
||||
const latest = input.acceptedEvents.at(-1);
|
||||
const acknowledgement = latest
|
||||
? `我记下了你提到的“${latest.summary}”,并保留了你给出的时间精度。`
|
||||
: input.pendingEvidence.length
|
||||
? "我保留了你刚才的原始描述;其中的日期或事件关系还不能安全进入评分。"
|
||||
: input.latestAnswer
|
||||
? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。"
|
||||
: "我会继续根据已确认的人生事件比较候选范围。";
|
||||
const primary = input.snapshot?.clusters[0];
|
||||
const candidateUpdate = primary ? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。` : null;
|
||||
const limitation = input.validated.decision.action === "stop_low_confidence" ? "现有证据不足以安全缩小范围,我不会把不稳定结果包装成确定时间。" : null;
|
||||
return { acknowledgement, candidateUpdate, limitation, question: input.validated.selectedOpportunity?.prompt ?? null };
|
||||
function normalized(value: string): string {
|
||||
return value.normalize("NFKC").replace(/[“”"'\s,,。.!!??::;;]/g, "");
|
||||
}
|
||||
|
||||
export function enforceServerQuestion(value: unknown, question: string | null): PublicMessage {
|
||||
return { ...publicMessageSchema.parse(value), question };
|
||||
export function validateQuestionRealization(question: unknown, opportunity: QuestionOpportunity): Readonly<{ valid: boolean; issues: readonly string[] }> {
|
||||
if (typeof question !== "string") return { valid: false, issues: ["question_missing"] };
|
||||
const value = question.trim();
|
||||
const issues: string[] = [];
|
||||
if (value.length < 8 || value.length > 180) issues.push("question_length_invalid");
|
||||
if ((value.match(/[??]/g) ?? []).length > 1) issues.push("multiple_question_marks");
|
||||
if ((value.match(/[。.!!??]/g) ?? []).length > 2) issues.push("too_many_sentences");
|
||||
if (/\n\s*(?:[-*•]|\d+[.)、])/.test(value)) issues.push("question_list_forbidden");
|
||||
if (internalTerms.test(value)) issues.push("internal_information_exposed");
|
||||
if (multiQuestionMoves.test(value)) issues.push("multiple_question_instruction");
|
||||
if (birthMinute.test(value)) issues.push("birth_minute_injected");
|
||||
if (opportunity.targetEventId) {
|
||||
const questionText = normalized(value);
|
||||
if (!opportunity.anchors.some((anchor) => questionText.includes(normalized(anchor)))) issues.push("target_anchor_missing");
|
||||
}
|
||||
for (const field of opportunity.requestedFields) {
|
||||
if (field === "event_subject" && !/(?:本人|你自己|家人|伴侣|配偶)/.test(value)) issues.push("event_subject_not_requested");
|
||||
if (field === "event_month" && !/(?:月份|哪个月|几月|大概月份|时间段)/.test(value)) issues.push("event_month_not_requested");
|
||||
if (field === "event_day" && !/(?:哪一天|几号|具体日期|大概日期)/.test(value)) issues.push("event_day_not_requested");
|
||||
if (field === "event_range" && !/(?:大概时间|时间范围|什么时候|哪个时间|哪一段时间)/.test(value)) issues.push("event_range_not_requested");
|
||||
if (field === "event_stage" && !/(?:开始|高峰|结束|正式发生)/.test(value)) issues.push("event_stage_not_requested");
|
||||
if (field === "new_dated_event" && !/(?:哪次|哪件|一件|经历)/.test(value)) issues.push("new_event_not_requested");
|
||||
if (field === "new_dated_event" && !/(?:时间|日期|什么时候|哪年|哪月|几月)/.test(value)) issues.push("new_event_date_not_requested");
|
||||
if (field === "event_year" && !/(?:哪年|年份|哪一年)/.test(value)) issues.push("event_year_not_requested");
|
||||
}
|
||||
return { valid: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
function primaryRange(snapshot: CandidateSnapshot | null): string | null {
|
||||
const primary = snapshot?.clusters[0];
|
||||
return primary ? `${primary.startTime}–${primary.endTime}` : null;
|
||||
}
|
||||
|
||||
export function candidateUpdateFor(input: Readonly<{
|
||||
snapshot: CandidateSnapshot | null;
|
||||
previousSnapshot: CandidateSnapshot | null;
|
||||
decisionAction: ValidatedDecision["decision"]["action"];
|
||||
}>): string | null {
|
||||
if (!input.snapshot?.canAcceptRange) return null;
|
||||
const current = primaryRange(input.snapshot);
|
||||
if (!current) return null;
|
||||
const previous = primaryRange(input.previousSnapshot);
|
||||
const firstStable = !input.previousSnapshot?.canAcceptRange;
|
||||
const changed = previous !== current;
|
||||
if (!firstStable && !changed) return null;
|
||||
return `目前通过稳定性门的候选范围是 ${current};它仍是待验证范围,不代表其中某一分钟已被确认。`;
|
||||
}
|
||||
|
||||
function naturalAcknowledgement(input: { latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; pendingEvidence: readonly PendingEvidence[] }): string {
|
||||
const latest = input.acceptedEvents.at(-1);
|
||||
if (latest) return `你提到的是 ${latest.dateRange.label} 的“${latest.summary}”。`;
|
||||
if (input.pendingEvidence.length) return "这段经历的事件或日期目前还不足以安全进入评分。";
|
||||
if (input.latestAnswer) return "我会保留你刚才的原始说法,不补写你没有确认的信息。";
|
||||
return "我们继续用时间相对明确的经历比较候选范围。";
|
||||
}
|
||||
|
||||
function deterministic(input: {
|
||||
latestAnswer: string;
|
||||
acceptedEvents: readonly LifeEventRevision[];
|
||||
pendingEvidence: readonly PendingEvidence[];
|
||||
snapshot: CandidateSnapshot | null;
|
||||
previousSnapshot: CandidateSnapshot | null;
|
||||
validated: ValidatedDecision;
|
||||
}): PublicMessage {
|
||||
return {
|
||||
acknowledgement: naturalAcknowledgement(input),
|
||||
candidateUpdate: candidateUpdateFor({ snapshot: input.snapshot, previousSnapshot: input.previousSnapshot, decisionAction: input.validated.decision.action }),
|
||||
limitation: input.validated.decision.action === "stop_low_confidence"
|
||||
? "现有证据不足以安全缩小范围,我会在这里停下,不把不稳定结果包装成确定时间。"
|
||||
: null,
|
||||
question: input.validated.selectedOpportunity?.fallbackPrompt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function realizePublicMessage(value: unknown, input: Parameters<typeof deterministic>[0]): PublicMessage {
|
||||
const parsed = publicMessageSchema.parse(value);
|
||||
const opportunity = input.validated.selectedOpportunity;
|
||||
const fallback = deterministic(input);
|
||||
const acknowledgement = bannedAcknowledgement.test(parsed.acknowledgement)
|
||||
|| overinterpretedAcknowledgement.test(parsed.acknowledgement)
|
||||
|| internalTerms.test(parsed.acknowledgement)
|
||||
|| (parsed.acknowledgement.match(/[。.!!??]/g) ?? []).length > 2
|
||||
|| (input.acceptedEvents.at(-1) && !normalized(parsed.acknowledgement).includes(normalized(input.acceptedEvents.at(-1)!.summary)))
|
||||
? fallback.acknowledgement
|
||||
: parsed.acknowledgement;
|
||||
const question = opportunity
|
||||
? validateQuestionRealization(parsed.question, opportunity).valid ? parsed.question : opportunity.fallbackPrompt
|
||||
: null;
|
||||
return {
|
||||
acknowledgement,
|
||||
candidateUpdate: fallback.candidateUpdate,
|
||||
limitation: fallback.limitation ?? (parsed.limitation && !internalTerms.test(parsed.limitation) ? parsed.limitation : null),
|
||||
question,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderPublicTurn(input: Readonly<{
|
||||
caseValue: RectificationV4Case; latestAnswer: string; acceptedEvents: readonly LifeEventRevision[];
|
||||
pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision; timeoutMs?: number;
|
||||
caseValue: RectificationV4Case;
|
||||
latestAnswer: string;
|
||||
acceptedEvents: readonly LifeEventRevision[];
|
||||
pendingEvidence: readonly PendingEvidence[];
|
||||
snapshot: CandidateSnapshot | null;
|
||||
previousSnapshot: CandidateSnapshot | null;
|
||||
validated: ValidatedDecision;
|
||||
timeoutMs?: number;
|
||||
}>): Promise<PublicMessage> {
|
||||
const started = Date.now();
|
||||
const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null;
|
||||
@@ -53,14 +149,23 @@ export async function renderPublicTurn(input: Readonly<{
|
||||
}
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "started", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: null, errorCode: null, deploymentSha });
|
||||
try {
|
||||
const opportunity = input.validated.selectedOpportunity;
|
||||
const result = await selected.agent.generate(JSON.stringify({
|
||||
task: "Render the public turn. The server-owned question must not be changed.", latestAnswer: input.latestAnswer,
|
||||
task: "Render one public turn and naturally realize the semantic question contract.",
|
||||
latestAnswer: input.latestAnswer,
|
||||
acceptedEvents: input.acceptedEvents.slice(-3).map((event) => ({ summary: event.summary, date: event.dateRange.label, subject: event.subject })),
|
||||
pendingEvidence: input.pendingEvidence.slice(-3).map((event) => ({ rawText: event.rawText, reasonCode: event.reasonCode })),
|
||||
candidateRange: input.snapshot?.clusters[0] ? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime } : null,
|
||||
action: input.validated.decision.action, exactQuestion: input.validated.selectedOpportunity?.prompt ?? null,
|
||||
pendingEvidence: input.pendingEvidence.slice(-3).map((event) => ({ reasonCode: event.reasonCode })),
|
||||
action: input.validated.decision.action,
|
||||
selectedOpportunity: opportunity ? {
|
||||
kind: opportunity.kind,
|
||||
goal: opportunity.goal,
|
||||
requestedFields: opportunity.requestedFields,
|
||||
anchors: opportunity.anchors,
|
||||
contextFacts: opportunity.contextFacts,
|
||||
forbiddenMoves: opportunity.forbiddenMoves,
|
||||
} : null,
|
||||
}), { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), structuredOutput: { schema: publicMessageSchema, jsonPromptInjection: "inline" } });
|
||||
const message = enforceServerQuestion(result.object, input.validated.selectedOpportunity?.prompt ?? null);
|
||||
const message = realizePublicMessage(result.object, input);
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: null, deploymentSha });
|
||||
return message;
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user