fix(rectification): generate opening with agent

This commit is contained in:
Jesse_Chen
2026-07-31 09:17:49 +08:00
parent bab5647afc
commit fdf791953e
9 changed files with 197 additions and 38 deletions
@@ -26,6 +26,10 @@ const newEventDomainTerms: Readonly<Partial<Record<QuestionOpportunity["domain"]
health_pressure: /(?:住院|手术|事故|健康|生病|确诊|康复)/,
};
const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict();
const openingMessageSchema = z.object({ message: z.string().trim().min(1).max(1_000) }).strict();
const domainChecklistTerms = /(?:学业|教育|搬家|迁居|感情|婚姻|工作|职业|财务|健康)/g;
export type OpeningQuestionGenerator = (prompt: string, phase: "generate" | "repair") => Promise<Readonly<{ object: unknown }>>;
function agentFor(modelId: string | null): { id: string; agent: Agent } | null {
const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel();
@@ -37,12 +41,74 @@ function agentFor(modelId: string | null): { id: string; agent: Agent } | null {
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. For a new event, use the supplied recall cues as optional examples, ask whether one such event happened instead of assuming it did, and preserve the user's ability to say no, forget, decline, or change direction. Do not invent events, ages, date windows 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.",
instructions: "Write concise natural Simplified Chinese for the supplied task. For an opening message, state that the supplied candidate window is only being checked and is not a confirmed birth minute, then invite one clearly remembered experience or connected sequence without a fixed-domain checklist. For a semantic opportunity, realize exactly one question; for a new event, use recall cues only as optional examples and ask whether it happened instead of assuming it did. Preserve the user's ability to be unsure, skip, decline, or change direction. Do not invent events, ages, date windows or dates, switch targets, interpret life meaning, 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 validateOpeningMessage(value: unknown, range: Readonly<{ start: string; end: string }>) {
const parsed = openingMessageSchema.safeParse(value);
if (!parsed.success) return { message: null, issues: ["opening_schema_invalid"] };
const message = parsed.data.message;
const issues: string[] = [];
if (!message.includes(range.start) || !message.includes(range.end)) issues.push("candidate_range_missing");
if (!/(?:不是|并非|尚未|还未|不能).{0,16}(?:确认|确定)|待(?:核对|验证)/.test(message)) issues.push("unconfirmed_range_missing");
if ((message.match(/[?]/g) ?? []).length > 1) issues.push("multiple_questions");
if (!/(?:经历|事情|事件|变化|转折|记得|想得起来)/.test(message) || !/(?:说|讲|分享|回忆|开始)/.test(message)) issues.push("experience_invitation_missing");
if (internalTerms.test(message)) issues.push("private_detail_exposed");
const positiveClaims = message.split(/[。;;!??!]/).filter((sentence) => !/(?:不是|并非|尚未|还未|不能)/.test(sentence)).join(" ");
if (exactMinuteClaim.test(positiveClaims)) issues.push("exact_minute_claimed");
if ((message.match(domainChecklistTerms) ?? []).length >= 3) issues.push("fixed_domain_checklist");
return { message: issues.length ? null : message, issues };
}
export async function generateOpeningQuestion(input: Readonly<{
caseId: string;
candidateRange: Readonly<{ start: string; end: string }>;
modelId: string | null;
timeoutMs?: number;
generate?: OpeningQuestionGenerator;
}>): Promise<string> {
const selected = input.generate ? null : agentFor(input.modelId);
const modelId = selected?.id ?? input.modelId;
const started = Date.now();
const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || process.env.VERCEL_GIT_COMMIT_SHA?.trim() || null;
const generate = input.generate ?? (async (prompt: string) => {
if (!selected) throw new Error("opening_model_unavailable");
return selected.agent.generate(prompt, {
abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000),
structuredOutput: { schema: openingMessageSchema, jsonPromptInjection: "inline" },
});
});
const context = {
task: "Write the opening message for this new rectification case.",
candidateRange: input.candidateRange,
requirements: [
"State that this candidate range is unconfirmed and only being checked.",
"Invite one clearly remembered experience or a connected sequence.",
"Use at most one natural question and no fixed-domain checklist.",
"Allow the user to be unsure, skip, or change direction.",
],
};
recordRectificationAgentTelemetry({ caseId: input.caseId, phase: "renderer", outcome: "started", modelId, toolName: null, decisionAction: "opening_question", durationMs: null, errorCode: null, deploymentSha });
try {
let result = validateOpeningMessage((await generate(JSON.stringify(context), "generate")).object, input.candidateRange);
if (!result.message) {
recordRectificationAgentTelemetry({ caseId: input.caseId, phase: "renderer", outcome: "rejected", modelId, toolName: null, decisionAction: "opening_question", durationMs: Date.now() - started, errorCode: result.issues[0] ?? "opening_rejected", deploymentSha });
result = validateOpeningMessage((await generate(JSON.stringify({ ...context, task: "Repair the rejected opening message once.", validationIssues: result.issues }), "repair")).object, input.candidateRange);
}
if (!result.message) throw new Error(`opening_rejected:${result.issues.join(",")}`);
recordRectificationAgentTelemetry({ caseId: input.caseId, phase: "renderer", outcome: "succeeded", modelId, toolName: null, decisionAction: "opening_question", durationMs: Date.now() - started, errorCode: null, deploymentSha });
return result.message;
} catch (error) {
recordRectificationAgentTelemetry({ caseId: input.caseId, phase: "renderer", outcome: "failed", modelId, toolName: null, decisionAction: "opening_question", durationMs: Date.now() - started, errorCode: error instanceof Error ? error.message.slice(0, 120) : "opening_failed", deploymentSha });
throw error;
}
}
function normalized(value: string): string {
return value.normalize("NFKC").replace(/[“”"'\s,。.!?::;;]/g, "");
}
@@ -9,7 +9,7 @@ import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectific
import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts";
import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts";
import { regenerateDirectorQuestion } from "../rectification-agent/director-agent.ts";
import { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts";
import { generateOpeningQuestion, regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts";
import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
import { hasPolicyInvalidScoreableEvents } from "./evidence-ledger.ts";
import { openingQuestion } from "./opening-question.ts";
@@ -23,11 +23,13 @@ export function createRectificationV4CaseService(
readonly now?: () => Date;
readonly regenerateQuestion?: typeof regenerateQuestionRealization;
readonly regenerateDirectorQuestion?: typeof regenerateDirectorQuestion;
readonly generateOpeningQuestion?: typeof generateOpeningQuestion;
} = {},
) {
const now = options.now ?? (() => new Date());
const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization;
const redirectQuestion = options.regenerateDirectorQuestion ?? regenerateDirectorQuestion;
const generateOpening = options.generateOpeningQuestion ?? generateOpeningQuestion;
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
const [events, turns, analysis, job] = await Promise.all([
@@ -51,22 +53,39 @@ export function createRectificationV4CaseService(
return {
async createCase(input: { readonly userId: string; readonly actionId: string; readonly calculationSpec: CalculationSpec }) {
const replay = await store.loadActionCase(input.userId, input.actionId);
if (replay) return response(input.userId, replay);
const specHash = calculationSpecHash(input.calculationSpec);
const active = await store.findActiveCase(input.userId);
if (active?.calculationSpecHash === specHash) {
return response(input.userId, await store.createCase({ case: active, actionId: input.actionId }));
}
const timestamp = now().toISOString();
const deploymentMode = selectRectificationDeploymentMode(input.userId);
const caseId = randomUUID();
const orchestrationModelId = process.env.RECTIFICATION_ORCHESTRATION_MODEL_ID?.trim() || null;
const narrationModelId = process.env.RECTIFICATION_NARRATION_MODEL_ID?.trim() || null;
const initialQuestion = openingQuestion(await generateOpening({
caseId,
candidateRange: input.calculationSpec.candidateRange,
modelId: narrationModelId,
}));
const caseValue: RectificationV4Case = {
id: randomUUID(),
id: caseId,
userId: input.userId,
protocol: deploymentMode === "v4_legacy" ? rectificationV4Protocol : rectificationAgentV5Protocol,
version: 0,
status: "awaiting_answer",
phase: "collecting_evidence",
calculationSpec: input.calculationSpec,
calculationSpecHash: calculationSpecHash(input.calculationSpec),
calculationSpecHash: specHash,
evidenceSetHash: evidenceSetHash([]),
currentQuestion: openingQuestion(input.calculationSpec.candidateRange),
currentQuestion: initialQuestion,
latestSnapshot: null,
orchestrationModelId: process.env.RECTIFICATION_ORCHESTRATION_MODEL_ID?.trim() || null,
narrationModelId: process.env.RECTIFICATION_NARRATION_MODEL_ID?.trim() || null,
orchestrationModelId,
narrationModelId,
skillVersion: CURRENT_RECTIFICATION_SKILL_VERSION,
promptVersion: CURRENT_RECTIFICATION_PROMPT_VERSION,
algorithmVersion: rectificationV4AlgorithmVersion,
@@ -26,9 +26,7 @@ export function projectLegacyV4Question(input: Readonly<{
id: randomUUID(),
domain: "other",
targetEventId: null,
prompt: input.latestAnswer
? "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。"
: "请从你自己最确定、时间也比较清楚的一段人生经历开始说。你可以一次讲几件连续发生的事,不需要按固定领域回答。",
prompt: "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。",
recallCost: "low",
reason: "V4 legacy projector:保持开放叙述。",
};
@@ -1,16 +1,13 @@
import { randomUUID } from "node:crypto";
import type { RectificationV4Question } from "./contracts.ts";
export function openingQuestion(
candidateRange: Readonly<{ start: string; end: string }>,
id?: string,
): RectificationV4Question {
export function openingQuestion(prompt: string, id?: string): RectificationV4Question {
return {
id: id ?? randomUUID(),
domain: "other",
targetEventId: null,
prompt: `我会先在 ${candidateRange.start}${candidateRange.end} 这个范围内核对,它还不是已确认的出生分钟。请从你自己最确定、时间也比较清楚的一段人生经历开始说;可以一次讲几件连续发生的事,不需要按固定领域回答。`,
prompt,
recallCost: "low",
reason: "首轮允许开放叙述,由后续系统根据真实经历选择高信息量问题。",
reason: "首轮由 Agent 根据候选范围生成自然引导。",
};
}