feat(rectification): 删掉年月录入卡,年月阶段打字回答且不被追问覆盖
Independent Staging Quality Gate / validate (push) Successful in 10m1s
Independent Staging Quality Gate / publish (push) Successful in 36m54s

This commit is contained in:
jesse-ux
2026-09-16 23:22:32 +08:00
parent abcfdac523
commit 6aabbe386f
49 changed files with 1035 additions and 431 deletions
@@ -21,6 +21,7 @@ import {
loadV9CaseSkillIdentity,
loadV9CaseCompute,
RectificationToolServiceError,
persistV9DeterministicTurn,
resolveV10ConversationFocus,
setV9EvidenceDateReliability,
type RectificationRpcClient,
@@ -31,6 +32,7 @@ import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt";
import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "../../agent-generation-settings.ts";
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts";
import { shouldHostReaskYearEntry } from "./year-entry-host.ts";
import { decideFromDossier } from "./decision-from-dossier";
import { ensureNonTerminalTurnExit, persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { alreadyDelivered } from "./delivery-turn-guard";
@@ -336,6 +338,28 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
// Date reliability (above) runs first. Year-stage typed collect with a
// yearless short reply does not enter the model (BUG-910).
const yearReask = shouldHostReaskYearEntry(dossier.conversationSummary.activeFocus, message);
if (yearReask && message) {
const persisted = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId: options.requestId,
userMessage: message,
assistantMessage: yearReask,
});
await emit({ type: "answer.delta", text: yearReask, replace: true });
return {
ok: true,
turnId: persisted.turnId,
turnStatus: "completed",
skillLoaded: true,
answerText: yearReask,
phases: ["answer.host_year_entry"],
toolsUsed: [],
errorCode: null,
previousFocusId,
};
}
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
@@ -89,5 +89,5 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
export const RECTIFICATION_SKILL_VERSION = "10.0.28";
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
export const RECTIFICATION_SKILL_VERSION = "10.0.29";
@@ -27,15 +27,15 @@ export const CHOICE_STOP_MESSAGE = "先这样";
export const CHOICE_SKIP_QUESTION_LABEL = "这题跳过";
export const CHOICE_SKIP_QUESTION_MESSAGE = "这题跳过";
export const HOLDOUT_MESSAGE_PREFIX = "盘外核对(不计分)";
export const TARGETED_COLLECT_OPTION_A = "有,我来填时间";
export const TARGETED_COLLECT_OPTION_A = "有,我来说";
export const TARGETED_COLLECT_OPTION_B = "这类事都没有过";
export const TARGETED_COLLECT_OPTION_C = "记不太清楚";
export const TARGETED_COLLECT_OPTION_D = "这条先跳过";
export const GUIDED_WINDOW_OPTION_A = "有,我来填时间";
export const GUIDED_WINDOW_OPTION_A = "有,我来说";
export const GUIDED_WINDOW_OPTION_B = "这段没有";
export const GUIDED_WINDOW_OPTION_C = "记不清";
export const GUIDED_WINDOW_OPTION_D = "这条先跳过";
export const TARGETED_COLLECT_WHY_USER = "答有的话再用选择器填年月,没有或记不清就问下一条。";
export const TARGETED_COLLECT_WHY_USER = "答有的话直接打字说大概年月,没有或记不清就问下一条。";
export const TARGETED_COLLECT_KEEP_HINT = "照发服务端卡片,不要自己写题干。";
export const FORBIDDEN_CHOICE_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/;
export const FOCUS_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -680,18 +680,10 @@ export function guidedWindowQuestionId(
return stage === "year" ? `${base}:year` : base;
}
export function parseYearEntryQuestionId(
questionId: string | null | undefined,
): Readonly<{ domain: GuidedWindowDomain; year: number | null; month: number | null }> | null {
const guided = parseGuidedWindowQuestionId(questionId);
if (guided?.stage === "year") {
return { domain: guided.domain, year: guided.year, month: guided.monthLo };
}
const targeted = parseTargetedCollectQuestionId(questionId);
if (targeted?.stage === "year") {
return { domain: targeted.domain, year: null, month: null };
}
return null;
/** Year-stage collect questions: typed answers, no entry card (T0 / BUG-908). */
export function isYearStageQuestionId(questionId: string | null | undefined): boolean {
return parseTargetedCollectQuestionId(questionId)?.stage === "year"
|| parseGuidedWindowQuestionId(questionId)?.stage === "year";
}
/** Open-window examples: the first three still-open domains, table driven. */
@@ -897,8 +889,9 @@ export function pendingTargetedYearDomain(
existenceResolved.add(targeted.domain);
}
}
for (const domain of covered) yearClosed.add(domain);
for (const domain of existenceResolved) {
if (covered.has(domain) || yearClosed.has(domain)) continue;
if (yearClosed.has(domain)) continue;
return domain;
}
return null;
@@ -1528,7 +1528,7 @@ export function targetedCollectYearFollowup(domain: CollectKind): MethodFollowup
ask_theme: theme,
domain,
kind_hint: `targeted:${domain}:year`,
user_prompt_hint: TARGETED_YEAR_PROMPT,
user_prompt_hint: `${TARGETED_YEAR_PROMPT} 用户给了年月就走 batch 写入并结束本题;不追问主体、原因、细节;定向健康题主体默认本人。`,
must_not_label: false,
choice_frame: null,
source: "method_coverage",
@@ -2490,7 +2490,7 @@ export function buildMethodFollowupPlan(input: {
kind_hint: collectKindFromFocus(focus),
user_prompt_hint: keepChoice
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入并结束本题;不追问主体、原因、细节;定向健康题主体默认本人。否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
source: "active_focus",
...(liveProbe && focus.intent === "distinguish_candidates"
? {
@@ -23,11 +23,13 @@ import { refinementFromDecisionReceipt } from "./refinement-packet";
import {
setV10ConversationFocus,
resolveV10ConversationFocus,
loadV9CaseDossier,
RectificationToolServiceError,
safeToolErrorCode,
type AccountingClient,
type ConversationFocus,
} from "./tool-service";
import { isYearStageQuestionId } from "./collection-question-pool";
export { parsePersistedFollowupQuestionId, parseCollectFocusQuestionId } from "./method-followup";
@@ -342,14 +344,6 @@ export function collectFocusSchema(followup: MethodFollowup): Record<string, unk
schema.date_reliability = true;
schema.target_evidence_id = followup.date_reliability_evidence_id;
}
const yearEntry = (followup.kind_hint ?? "").endsWith(":year")
|| (followup.collection_key ?? "").endsWith(":year");
if (yearEntry) {
schema.event_date_entry = true;
if (followup.domain) schema.default_domain = followup.domain;
if (typeof followup.probe_year === "number") schema.default_year = followup.probe_year;
if (typeof followup.probe_month === "number") schema.default_month = followup.probe_month;
}
return schema;
}
@@ -381,6 +375,31 @@ export function isCollectFocusSchema(schema: Readonly<Record<string, unknown>> |
return schema?.[COLLECT_FOCUS_SCHEMA_KEY] === true && typeof schema.prompt === "string";
}
export function shouldResolveCollectFocusAfterEvidence(
focus: Pick<ConversationFocus, "intent" | "questionId" | "expectedAnswerSchema"> | null | undefined,
): boolean {
if (!focus || focus.intent !== "collect_method_evidence") return false;
if (isCollectFocusSchema(focus.expectedAnswerSchema)) return true;
return isYearStageQuestionId(focus.questionId);
}
export async function resolveActiveCollectFocusAfterEvidence(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
evidenceId: string | null;
}): Promise<void> {
if (!input.evidenceId) return;
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const active = dossier.conversationSummary.activeFocus;
if (!active || !shouldResolveCollectFocusAfterEvidence(active)) return;
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
focusId: active.id,
status: "resolved",
evidenceId: input.evidenceId,
});
}
async function persistCollectFocus(input: {
accounting: AccountingClient;
userId: string;
@@ -2,6 +2,7 @@ import { parseAgentChoiceCopy } from "./choice-card";
import { persistableFocusDomain, stableFollowupQuestionId } from "./server-focus";
import { MACHINE_VOICE_LEXICON } from "./agent-voice-lexicon";
import { COLLECT_DOMAIN_KEYWORDS } from "../user-copy";
import { isYearStageQuestionId } from "./collection-question-pool";
import type { MethodFollowup } from "./method-followup";
export const SPOKEN_PROMPT_MIN = 8;
@@ -99,8 +100,9 @@ export function validateSpokenPrompt(input: {
export function withSpokenPrompt(
schema: Readonly<Record<string, unknown>>,
spokenPrompt: string,
questionId?: string | null,
): Record<string, unknown> {
if (schema.targeted_collect === true) {
if (schema.targeted_collect === true || isYearStageQuestionId(questionId)) {
return { ...schema, spoken_prompt: spokenPrompt };
}
const next: Record<string, unknown> = { ...schema, prompt: spokenPrompt };
@@ -14,7 +14,7 @@ import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract";
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
import { parseAgentChoiceCopy, serverOwnedChoiceCopy } from "./choice-card";
import { isCollectFocusSchema } from "./server-focus";
import { isTargetedCollectExistenceFocus } from "./collection-question-pool";
import { isTargetedCollectExistenceFocus, isYearStageQuestionId } from "./collection-question-pool";
import { rebuildTargetedCollectExistenceFrame } from "./method-followup";
export const TURN_DECISION_MAX_BYTES = 6 * 1024;
@@ -83,6 +83,17 @@ export function projectCurrentQuestion(
if (!focus) return null;
const schema = focus.expectedAnswerSchema;
const spoken = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
if (isYearStageQuestionId(focus.questionId) && spoken) {
return {
question_id: focus.questionId ?? null,
focus_id: focus.id ?? null,
probe_id: typeof schema?.probe_id === "string" ? schema.probe_id : null,
prompt: spoken,
kind: "collect_spoken",
intent: focus.intent,
domain: focus.targetDomain ?? null,
};
}
const copy = parseAgentChoiceCopy(schema);
const probeId = typeof schema?.probe_id === "string" ? schema.probe_id : null;
if (copy) {
@@ -0,0 +1,27 @@
/**
* Year-stage collect is a typed question. A yearless short reply must not
* enter the model (BUG-910). Date-reliability classification runs first.
*/
import { TARGETED_YEAR_PROMPT, isYearStageQuestionId } from "./collection-question-pool";
import type { ConversationFocus } from "./tool-service";
export function userMessageHasYear(message: string): boolean {
return /(?:19|20)\d{2}|\d{1,4}\s*年/.test(message);
}
export function yearEntryServerPrompt(
schema: Readonly<Record<string, unknown>> | null | undefined,
): string {
const prompt = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
return prompt || TARGETED_YEAR_PROMPT;
}
export function shouldHostReaskYearEntry(
focus: Pick<ConversationFocus, "questionId" | "expectedAnswerSchema"> | null | undefined,
message: string | null | undefined,
): string | null {
if (!message?.trim()) return null;
if (!isYearStageQuestionId(focus?.questionId ?? null)) return null;
if (userMessageHasYear(message)) return null;
return yearEntryServerPrompt(focus?.expectedAnswerSchema);
}