merge origin/staging into report chart render branch
Keep BUG-607 after 602–606 and retain both 09-09 changelog entries. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,7 +28,47 @@ export type CollectDomain =
|
||||
| "health_pressure"
|
||||
| "other";
|
||||
|
||||
export const GENERIC_COLLECT_QUESTION = "从你最容易想起来的一件事开始就好——比如哪年上的大学、哪年换的工作、哪年搬的家,记得大概年份就行。";
|
||||
export const GENERIC_COLLECT_QUESTION = "先说你最容易想起的一两件,年月大概就行。";
|
||||
|
||||
/** One-time suffix on the first dated-collect stem. Not a chase-more turn. */
|
||||
export const FIRST_DATED_COLLECT_INVITE = "想到别的也可以一起说。";
|
||||
|
||||
export function withFirstDatedCollectInvite(stem: string): string {
|
||||
const spoken = stem.trim();
|
||||
if (!spoken || spoken.includes(FIRST_DATED_COLLECT_INVITE)) return spoken;
|
||||
return `${spoken}${FIRST_DATED_COLLECT_INVITE}`;
|
||||
}
|
||||
|
||||
/** Six everyday domains named in the opening body. Not years. */
|
||||
export const OPENING_COLLECT_DOMAINS = [
|
||||
"升学",
|
||||
"第一份工作",
|
||||
"搬家",
|
||||
"恋爱结婚",
|
||||
"家里的大事",
|
||||
"生病受伤",
|
||||
] as const;
|
||||
|
||||
export function openingSpokenBody(range: readonly [string, string] | null): string {
|
||||
const window = formatClockRange(range) ?? "当前这段";
|
||||
return [
|
||||
`眼下按 ${window} 来核对,用你记得的经历对照几种分盘和大运。`,
|
||||
"最后给区间和代表分钟,不给精确到秒。",
|
||||
`想到几件说几件,有大概年月就行——比如${OPENING_COLLECT_DOMAINS.join("、")}。`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
const OPENING_YEAR = /(?:19|20)\d{2}/;
|
||||
const OPENING_SENTENCE = /(?<=[。!?])/;
|
||||
|
||||
export function isAcceptableOpeningBody(body: string): boolean {
|
||||
const spoken = body.trim();
|
||||
if (!spoken || OPENING_YEAR.test(spoken)) return false;
|
||||
const sentences = spoken.split(OPENING_SENTENCE).map((part) => part.trim()).filter(Boolean);
|
||||
if (sentences.length === 0 || sentences.length > 4) return false;
|
||||
const hits = OPENING_COLLECT_DOMAINS.filter((domain) => spoken.includes(domain)).length;
|
||||
return hits >= 5;
|
||||
}
|
||||
|
||||
/** Everyday words a collect spoken prompt must mention for its server domain. */
|
||||
export const COLLECT_DOMAIN_KEYWORDS: Readonly<Record<string, readonly string[]>> = {
|
||||
@@ -76,6 +116,8 @@ export const RECTIFICATION_USER_COPY = {
|
||||
hostNarrationFallback: "我按现有材料继续往下收。",
|
||||
collectHandoff: "接下来我们继续。",
|
||||
collectDeclinedAck: "记下了,这方面先跳过。",
|
||||
collectSkippedAck: "记下了,这题先放着。",
|
||||
firstDatedCollectInvite: FIRST_DATED_COLLECT_INVITE,
|
||||
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
|
||||
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
|
||||
probePoolExhaustedStop: "能分开候选的问题已经问完,先按现有材料给你结果。",
|
||||
@@ -395,6 +437,10 @@ export function listUserVisibleCopy(): string[] {
|
||||
RECTIFICATION_USER_COPY.hostNarrationFallback,
|
||||
RECTIFICATION_USER_COPY.collectHandoff,
|
||||
RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
RECTIFICATION_USER_COPY.collectSkippedAck,
|
||||
FIRST_DATED_COLLECT_INVITE,
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
openingSpokenBody(["04:45", "05:15"]),
|
||||
RECTIFICATION_USER_COPY.uncertaintyStop,
|
||||
RECTIFICATION_USER_COPY.tiedFirstStop,
|
||||
RECTIFICATION_USER_COPY.probePoolExhaustedStop,
|
||||
|
||||
@@ -35,8 +35,15 @@ import { decideFromDossier } from "./decision-from-dossier";
|
||||
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
|
||||
import { alreadyDelivered } from "./delivery-turn-guard";
|
||||
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
|
||||
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice, withRangeChangedAfterEvidence } from "../user-copy";
|
||||
import { stripQuestionSentences } from "./collect-prompt";
|
||||
import {
|
||||
RECTIFICATION_USER_COPY,
|
||||
isAcceptableOpeningBody,
|
||||
openingRangeFromCandidateRange,
|
||||
openingSpokenBody,
|
||||
withCompareFailedRetryNotice,
|
||||
withRangeChangedAfterEvidence,
|
||||
} from "../user-copy";
|
||||
import { stripQuestionSentences, trimEvidenceTurnBody } from "./collect-prompt";
|
||||
import { focusSpokenPrompt } from "./turn-question";
|
||||
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
|
||||
import {
|
||||
@@ -221,13 +228,20 @@ function shouldAutoRetry(errorCode: string, signal?: AbortSignal): boolean {
|
||||
return !signal?.aborted && isRetryableError(errorCode);
|
||||
}
|
||||
|
||||
function openingBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): string {
|
||||
function clockWindow(range: { start_time?: string | null; end_time?: string | null } | null | undefined): string | null {
|
||||
const start = range?.start_time?.trim().slice(0, 5) || "";
|
||||
const end = range?.end_time?.trim().slice(0, 5) || "";
|
||||
return start && end ? `${start}–${end}` : null;
|
||||
}
|
||||
|
||||
export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): string {
|
||||
const confirmed = dossier.evidence.filter((item) => item.status === "confirmed");
|
||||
const pending = dossier.evidence.filter((item) => item.status === "draft" || item.status === "pending_confirmation");
|
||||
const domains = [...new Set(confirmed.map((item) => item.domain))].slice(0, 6);
|
||||
const range = dossier.case.candidateRange;
|
||||
const uncertaintyType = range && typeof range === "object"
|
||||
? "用户的出生时间存在一个服务端保存的不确定范围"
|
||||
const window = clockWindow(range);
|
||||
const uncertaintyType = window
|
||||
? `当前搜索窗口 ${window},来自用户在资料里声明的不确定档`
|
||||
: "用户的出生时间精度仍需通过经历证据核对";
|
||||
const clue = typeof birthTimeClue === "string" && birthTimeClue.trim()
|
||||
? birthTimeClue.trim()
|
||||
@@ -235,12 +249,13 @@ function openingBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): st
|
||||
return [
|
||||
"【服务端 opening brief】",
|
||||
`Case 状态:${dossier.case.status}。`,
|
||||
`当前搜索窗口:${window ?? "尚未锁定"}。来源:intake 声明的不确定档。`,
|
||||
`出生时间不确定类型:${uncertaintyType}。`,
|
||||
`已有证据摘要:已确认 ${confirmed.length} 条,待澄清或待确认 ${pending.length} 条${domains.length ? `;已覆盖 ${domains.join("、")}` : ""}。`,
|
||||
...(clue
|
||||
? [`家人或本人关于出生时段的线索(仅旁白建议,不得改搜索窗口):${clue}`]
|
||||
: []),
|
||||
"正文只打招呼,说明可以慢慢说、记得大概年份即可,不要要求一次说完。不要提问,不要举大学、工作、搬家的例子。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题。",
|
||||
"做法要点:一句当前窗口与核对做法;一句「最后给区间和代表分钟,不给精确到秒」;一句「想到几件说几件,有大概年月就行」并点出升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤。一条消息可以报多件,想到几件说几件。不得写具体年份,不得要求先准备材料。不要提问。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,题干写成「先说你最容易想起的一两件,年月大概就行」。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -994,6 +1009,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (next !== answerText) answerText = next;
|
||||
}
|
||||
}
|
||||
if (action === "opening") {
|
||||
const openingRange = openingRangeFromCandidateRange(latestDossier.case.candidateRange);
|
||||
if (!isAcceptableOpeningBody(answerText)) {
|
||||
answerText = openingSpokenBody(openingRange);
|
||||
}
|
||||
} else if (action === "evidence") {
|
||||
answerText = trimEvidenceTurnBody(answerText);
|
||||
}
|
||||
const rangeAfterEvidence = previousInferenceFromReceipt(
|
||||
latestDossier.latestResult?.decisionReceipt ?? null,
|
||||
)?.credible_range ?? null;
|
||||
@@ -1098,7 +1121,7 @@ function buildAgentMessages(
|
||||
if (options.action === "opening") {
|
||||
return [bootstrap, {
|
||||
role: "user",
|
||||
content: [timeContext, caseContext, openingBrief(dossier, birthTimeClue)].join("\n"),
|
||||
content: [timeContext, caseContext, buildOpeningBrief(dossier, birthTimeClue)].join("\n"),
|
||||
}];
|
||||
}
|
||||
return [bootstrap, {
|
||||
|
||||
@@ -19,4 +19,6 @@ export const MACHINE_VOICE_LEXICON = [
|
||||
"有没有记得住时间的收入变化、大笔支出或欠债",
|
||||
"继续收窄",
|
||||
"范围还在",
|
||||
"领先",
|
||||
"落后",
|
||||
] as const;
|
||||
|
||||
@@ -977,13 +977,20 @@ export async function applyCollectFocusDenial(
|
||||
focusId: string;
|
||||
deferFollowup?: boolean;
|
||||
narrateAdopt?: AdoptNarrationWriter;
|
||||
status?: "declined" | "skipped";
|
||||
},
|
||||
): Promise<{
|
||||
narration: string;
|
||||
ack: string;
|
||||
closeStatus: "declined" | "skipped";
|
||||
nextInterviewPersisted: boolean;
|
||||
nextChoiceReady: boolean;
|
||||
focus: ConversationFocus | null;
|
||||
}> {
|
||||
const closeStatus = input.status === "skipped" ? "skipped" : "declined";
|
||||
const ack = closeStatus === "skipped"
|
||||
? RECTIFICATION_USER_COPY.collectSkippedAck
|
||||
: RECTIFICATION_USER_COPY.collectDeclinedAck;
|
||||
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
|
||||
const focus = dossier.conversationSummary.activeFocus;
|
||||
if (!focus || focus.id !== input.focusId) {
|
||||
@@ -991,12 +998,14 @@ export async function applyCollectFocusDenial(
|
||||
}
|
||||
await resolveV10ConversationFocus(accounting, input.userId, input.caseId, {
|
||||
focusId: input.focusId,
|
||||
status: "declined",
|
||||
status: closeStatus,
|
||||
evidenceId: null,
|
||||
});
|
||||
if (input.deferFollowup === true) {
|
||||
return {
|
||||
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
narration: ack,
|
||||
ack,
|
||||
closeStatus,
|
||||
nextInterviewPersisted: false,
|
||||
nextChoiceReady: false,
|
||||
focus: null,
|
||||
@@ -1024,7 +1033,7 @@ export async function applyCollectFocusDenial(
|
||||
targetDomain: focus.targetDomain,
|
||||
},
|
||||
},
|
||||
}, "declined");
|
||||
}, closeStatus);
|
||||
const decision = decideFromDossier(withDeclined, { birthDate });
|
||||
const nextAction = publicNextAction(decision);
|
||||
const nextInterview = await persistNextInterviewAfterChoice({
|
||||
@@ -1040,6 +1049,8 @@ export async function applyCollectFocusDenial(
|
||||
});
|
||||
return {
|
||||
narration: nextInterview.hostNarration,
|
||||
ack,
|
||||
closeStatus,
|
||||
nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady,
|
||||
nextChoiceReady: nextInterview.choiceReady,
|
||||
focus: nextInterview.focus ?? null,
|
||||
@@ -1061,15 +1072,16 @@ export async function persistCollectDenialTurn(input: {
|
||||
userMessage: string | null;
|
||||
applied: CollectDenialApplied;
|
||||
}): Promise<{ streamText: string; turnId: string }> {
|
||||
const ack = input.applied.ack ?? RECTIFICATION_USER_COPY.collectDeclinedAck;
|
||||
const hasNextStem = Boolean(
|
||||
input.applied.focus
|
||||
&& (input.applied.nextInterviewPersisted || input.applied.nextChoiceReady)
|
||||
&& input.applied.narration.trim(),
|
||||
);
|
||||
const stored = hasNextStem
|
||||
? composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, input.applied.narration)
|
||||
? composeCollectSpokenAssistantText(ack, input.applied.narration)
|
||||
: input.applied.narration;
|
||||
const streamText = hasNextStem ? RECTIFICATION_USER_COPY.collectDeclinedAck : input.applied.narration;
|
||||
const streamText = hasNextStem ? ack : input.applied.narration;
|
||||
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: input.requestId,
|
||||
userMessage: input.userMessage,
|
||||
|
||||
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
|
||||
export const MAX_RESUMABLE_CASES_PER_USER = 1;
|
||||
|
||||
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
|
||||
export const RECTIFICATION_SKILL_VERSION = "10.0.18";
|
||||
export const RECTIFICATION_SKILL_VERSION = "10.0.19";
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "./choice-card";
|
||||
import {
|
||||
explainRangeChange,
|
||||
explainScoreMovement,
|
||||
type ClusterScoreDelta,
|
||||
} from "./probe-explain.ts";
|
||||
|
||||
@@ -118,12 +117,8 @@ export function composeChoiceNarration(input: {
|
||||
return "已记录你的选择。这是盘外核对,不会改候选分数。";
|
||||
}
|
||||
if (input.appliedInference) {
|
||||
const movement = explainScoreMovement(input.deltasByCluster ?? []);
|
||||
const range = explainRangeChange(input.credibleBefore, input.credibleAfter);
|
||||
if (movement || range) {
|
||||
return ["已记录你的选择", movement, range].filter(Boolean).join("。") + "。";
|
||||
}
|
||||
return "已记录你的选择,并更新了候选比较。";
|
||||
return `已记录,${range || "范围没变"}。`;
|
||||
}
|
||||
return "已记录你的选择。";
|
||||
}
|
||||
|
||||
@@ -7,6 +7,30 @@
|
||||
const SENTENCE_SPLIT = /(?<=[。!??\n])/;
|
||||
const NARRATIVE_SENTENCE = /范围|记下|对照|\d{1,2}:\d{2}/;
|
||||
|
||||
export const EVIDENCE_VALUE_JUDGMENT_PHRASES = [
|
||||
"很有帮助",
|
||||
"很有价值",
|
||||
"很有分量",
|
||||
"特别有用",
|
||||
] as const;
|
||||
|
||||
function spokenSentences(body: string): string[] {
|
||||
return body.split(SENTENCE_SPLIT).map((part) => part.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function trimEvidenceTurnBody(body: string): string {
|
||||
const spoken = body.trim();
|
||||
if (!spoken) return "";
|
||||
const sentences = spokenSentences(spoken);
|
||||
let text = sentences.length > 2
|
||||
? (/[。!??]$/.test(sentences[0] ?? "") ? sentences[0] ?? "" : `${sentences[0] ?? ""}。`)
|
||||
: spoken;
|
||||
for (const phrase of EVIDENCE_VALUE_JUDGMENT_PHRASES) {
|
||||
text = text.replaceAll(phrase, "");
|
||||
}
|
||||
return text.replace(/[,、]{2,}/g, ",").replace(/[ \t]+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isQuestionSentence(text: string, stem: string): boolean {
|
||||
if (!text) return false;
|
||||
if (stem && text === stem) return true;
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
USER_COLLECT_QUESTION,
|
||||
USER_COLLECT_QUESTION_RETRY,
|
||||
withFirstDatedCollectInvite,
|
||||
} from "../user-copy.ts";
|
||||
|
||||
export { GENERIC_COLLECT_QUESTION };
|
||||
@@ -194,6 +195,7 @@ export type MethodFollowup = Readonly<{
|
||||
selection_score?: number;
|
||||
probe_id?: string;
|
||||
collect_retry?: boolean;
|
||||
invite_more_once?: boolean;
|
||||
block_periods?: Readonly<Record<"A" | "B" | "C", BlockScanBlock>>;
|
||||
widen_windows?: Readonly<Record<"A" | "B", import("./window-widen.ts").WidenWindowOption>>;
|
||||
date_reliability_evidence_id?: string;
|
||||
@@ -501,9 +503,15 @@ function topicIntent(topic: Readonly<Record<string, unknown>>): string {
|
||||
return typeof topic.intent === "string" ? topic.intent : "";
|
||||
}
|
||||
|
||||
function topicStatus(topic: Readonly<Record<string, unknown>>): string {
|
||||
return typeof topic.status === "string" ? topic.status : "";
|
||||
}
|
||||
|
||||
function declinedDomains(
|
||||
topics: readonly Readonly<Record<string, unknown>>[],
|
||||
options: { includeSkipped?: boolean } = {},
|
||||
): Set<string> {
|
||||
const includeSkipped = options.includeSkipped !== false;
|
||||
const domains = new Set<string>();
|
||||
for (const topic of topics) {
|
||||
if (isOpeningOtherCollectTopic(topic)) continue;
|
||||
@@ -512,6 +520,7 @@ function declinedDomains(
|
||||
// Missing intent stays collect for legacy declined_skipped rows.
|
||||
if (intent === "distinguish_candidates") continue;
|
||||
if (intent && intent !== "collect_method_evidence") continue;
|
||||
if (!includeSkipped && topicStatus(topic) === "skipped") continue;
|
||||
const domain = topicDomain(topic);
|
||||
if (domain) domains.add(domain);
|
||||
}
|
||||
@@ -739,7 +748,7 @@ export function reverseVerifyRemainingForAdopt(input: {
|
||||
return remainingReverseVerifyProbes(
|
||||
input.eventProbes,
|
||||
input.evidence,
|
||||
declinedDomains(input.declinedTopics ?? []),
|
||||
declinedDomains(input.declinedTopics ?? [], { includeSkipped: false }),
|
||||
new Set(input.askedProbeKeys ?? []),
|
||||
input.birthDate,
|
||||
);
|
||||
@@ -1204,7 +1213,12 @@ export function spokenFollowupForUser(
|
||||
const mapped = followup.collect_retry === true
|
||||
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain])
|
||||
: USER_COLLECT_QUESTION[domain];
|
||||
return mapped ?? null;
|
||||
if (!mapped) return null;
|
||||
const dated = (DATED_COLLECT_ORDER as readonly string[]).includes(domain);
|
||||
if (followup.invite_more_once && dated && followup.collect_retry !== true) {
|
||||
return withFirstDatedCollectInvite(mapped);
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export const DATED_COLLECT_ORDER = [
|
||||
@@ -1253,15 +1267,29 @@ function datedCollectDomainBlocked(
|
||||
|| answeredYes.has(domain);
|
||||
}
|
||||
|
||||
export function datedCollectAlreadyAsked(
|
||||
declined: ReadonlySet<string>,
|
||||
topics: readonly Readonly<Record<string, unknown>>[] = [],
|
||||
): boolean {
|
||||
if (DATED_COLLECT_ORDER.some((domain) => declined.has(domain))) return true;
|
||||
return DATED_COLLECT_ORDER.some((domain) => domainCollectFocusAsked(topics, domain));
|
||||
}
|
||||
|
||||
export function nextDatedCollectFollowup(
|
||||
evidence: readonly MethodFollowupEvidence[],
|
||||
declined: ReadonlySet<string>,
|
||||
answeredYes: ReadonlySet<string> = new Set(),
|
||||
options?: { askedDatedCollect?: boolean },
|
||||
): MethodFollowup | null {
|
||||
for (const domain of DATED_COLLECT_ORDER) {
|
||||
if (datedCollectDomainBlocked(domain, evidence, declined, answeredYes)) continue;
|
||||
const next = datedCollectFollowup(domain, evidence);
|
||||
if (next) return next;
|
||||
if (next) {
|
||||
return {
|
||||
...next,
|
||||
invite_more_once: options?.askedDatedCollect !== true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1350,7 +1378,9 @@ export function exhaustionSpokenCollectFollowup(input: {
|
||||
}): MethodFollowup | null {
|
||||
const declined = declinedDomains(input.declinedTopics ?? []);
|
||||
const answeredYes = domainsAnsweredYes(input.answeredProbes, input.eventProbes);
|
||||
const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes);
|
||||
const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
|
||||
askedDatedCollect: datedCollectAlreadyAsked(declined, input.declinedTopics ?? []),
|
||||
});
|
||||
if (dated) return dated;
|
||||
if (
|
||||
!declined.has("occupation")
|
||||
@@ -1876,9 +1906,15 @@ export function buildMethodFollowupPlan(input: {
|
||||
const collectRetry = base.intent === "collect_method_evidence"
|
||||
&& typeof base.domain === "string"
|
||||
&& domainCollectFocusAsked(askedRows, base.domain);
|
||||
const inviteMoreOnce = base.intent === "collect_method_evidence"
|
||||
&& typeof base.domain === "string"
|
||||
&& (DATED_COLLECT_ORDER as readonly string[]).includes(base.domain)
|
||||
&& !collectRetry
|
||||
&& !datedCollectAlreadyAsked(declinedDomains(input.declinedTopics ?? []), askedRows);
|
||||
return {
|
||||
...base,
|
||||
...(collectRetry ? { collect_retry: true } : {}),
|
||||
...(inviteMoreOnce ? { invite_more_once: true } : { invite_more_once: false }),
|
||||
choice_frame: attach
|
||||
? buildChoiceFrame(base, {
|
||||
observations: input.observations,
|
||||
@@ -1901,6 +1937,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
const ask = (why: string, varga: string, extra = "") =>
|
||||
agentHint(why, varga, extra, input.evidence);
|
||||
const declined = declinedDomains(input.declinedTopics ?? []);
|
||||
const reverseVerifyDeclined = declinedDomains(input.declinedTopics ?? [], { includeSkipped: false });
|
||||
const answeredYes = domainsAnsweredYes(input.answeredProbes, [
|
||||
...(input.eventProbes ?? []),
|
||||
...(input.eventClarificationProbes ?? []),
|
||||
@@ -2129,7 +2166,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
const probe = remainingReverseVerifyProbes(
|
||||
input.eventProbes,
|
||||
input.evidence,
|
||||
declined,
|
||||
reverseVerifyDeclined,
|
||||
askedKeys,
|
||||
input.birthDate,
|
||||
)[0] ?? null;
|
||||
@@ -2504,7 +2541,12 @@ export function buildMethodFollowupPlan(input: {
|
||||
&& precisionCard?.choice_frame
|
||||
) {
|
||||
next = precisionCard;
|
||||
} else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined, answeredYes))) {
|
||||
} else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
|
||||
askedDatedCollect: datedCollectAlreadyAsked(declined, [
|
||||
...(input.closedCollectFocuses ?? []),
|
||||
...(input.declinedTopics ?? []),
|
||||
]),
|
||||
}))) {
|
||||
next = makeFollowup(datedCollect);
|
||||
} else if (!occupationCovered) {
|
||||
if (meetsAcceptanceEventQuality(input.evidence)) {
|
||||
@@ -2680,7 +2722,12 @@ export function buildMethodFollowupPlan(input: {
|
||||
next = null;
|
||||
}
|
||||
if (!next) {
|
||||
const leftoverDated = nextDatedCollectFollowup(input.evidence, declined, answeredYes);
|
||||
const leftoverDated = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
|
||||
askedDatedCollect: datedCollectAlreadyAsked(declined, [
|
||||
...(input.closedCollectFocuses ?? []),
|
||||
...(input.declinedTopics ?? []),
|
||||
]),
|
||||
});
|
||||
if (leftoverDated) {
|
||||
next = makeFollowup(leftoverDated);
|
||||
} else if (!occupationCovered) {
|
||||
|
||||
@@ -223,6 +223,20 @@ export function explainScoreMovement(deltas: readonly ClusterScoreDelta[]): stri
|
||||
return "";
|
||||
}
|
||||
|
||||
function clockMinutes(value: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
function rangeWidthMinutes(start: string, end: string): number | null {
|
||||
const from = clockMinutes(start);
|
||||
const to = clockMinutes(end);
|
||||
if (from == null || to == null) return null;
|
||||
const width = to >= from ? to - from : to + 24 * 60 - from;
|
||||
return width > 0 ? width : null;
|
||||
}
|
||||
|
||||
export function explainRangeChange(
|
||||
before: readonly [string, string] | null | undefined,
|
||||
after: readonly [string, string] | null | undefined,
|
||||
@@ -233,5 +247,11 @@ export function explainRangeChange(
|
||||
const endAfter = clockTime(after?.[1]);
|
||||
if (!startBefore || !endBefore || !startAfter || !endAfter) return "";
|
||||
if (startBefore === startAfter && endBefore === endAfter) return "范围没变";
|
||||
return `范围从 ${startBefore}–${endBefore} 收到 ${startAfter}–${endAfter}`;
|
||||
const afterLabel = `${startAfter}–${endAfter}`;
|
||||
const widthBefore = rangeWidthMinutes(startBefore, endBefore);
|
||||
const widthAfter = rangeWidthMinutes(startAfter, endAfter);
|
||||
if (widthAfter != null && widthBefore != null && widthAfter > widthBefore) {
|
||||
return `范围变为 ${afterLabel}`;
|
||||
}
|
||||
return `范围收到 ${afterLabel}`;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,14 @@ export function shouldDeclineCollectFocus(
|
||||
return classified?.intent === "answer_current_focus" && classified.answer_class === "no";
|
||||
}
|
||||
|
||||
export function isCollectDeclineUtterance(message: string): boolean {
|
||||
return message.trim().replace(/[。!?.!?]+$/u, "") === "没有";
|
||||
}
|
||||
|
||||
export function isCollectSkipUtterance(message: string): boolean {
|
||||
return message.trim().replace(/[。!?.!?]+$/u, "") === "记不清";
|
||||
}
|
||||
|
||||
export function shouldContinueAgentForDatedEvent(
|
||||
classified: RectificationTurnIntent | null,
|
||||
): boolean {
|
||||
|
||||
@@ -66,6 +66,11 @@ export type RectificationNatalRecast = Readonly<{
|
||||
confirmation_allowed: false;
|
||||
}>;
|
||||
|
||||
export type RectificationInferenceMark = Readonly<{
|
||||
time: string;
|
||||
eliminated: boolean;
|
||||
}>;
|
||||
|
||||
export type RectificationCandidateResult = Readonly<{
|
||||
resultId: string;
|
||||
candidates: readonly RectificationCandidate[];
|
||||
@@ -95,6 +100,8 @@ export type RectificationCandidateResult = Readonly<{
|
||||
credibleRange: readonly [string, string] | null;
|
||||
rangeDelivery: RangeDeliveryProjection | null;
|
||||
verificationReportMarkdown: string | null;
|
||||
/** Inference-layer minutes including eliminated ones. Empty when no inference_state. */
|
||||
inferenceMarks?: readonly RectificationInferenceMark[];
|
||||
}>;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
@@ -284,6 +291,24 @@ function parseCredibleRange(value: unknown): readonly [string, string] | null {
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
function parseInferenceMarks(receipt: Record<string, unknown> | null): readonly RectificationInferenceMark[] {
|
||||
const state = record(receipt?.inference_state);
|
||||
if (!state || !Array.isArray(state.candidates)) return [];
|
||||
const marks: RectificationInferenceMark[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of state.candidates) {
|
||||
const row = record(value);
|
||||
const clock = time(row?.time);
|
||||
if (!clock || seen.has(clock)) continue;
|
||||
seen.add(clock);
|
||||
marks.push({
|
||||
time: clock,
|
||||
eliminated: row?.status === "eliminated",
|
||||
});
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
export function parseRectificationCandidateResult(value: unknown): RectificationCandidateResult | null {
|
||||
const snapshot = record(value);
|
||||
if (!snapshot || typeof snapshot.resultId !== "string") return null;
|
||||
@@ -372,6 +397,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
|
||||
?? verificationMarkdownFromUnknown(
|
||||
(snapshot.rangeDelivery ?? snapshot.range_delivery) as { verification_markdown?: unknown } | undefined,
|
||||
),
|
||||
inferenceMarks: parseInferenceMarks(receipt),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export type RectificationTimelineView = Readonly<{
|
||||
ticks: readonly TimelineTick[];
|
||||
/** `05:07–05:09` */
|
||||
rangeLabel: string;
|
||||
/** `2 分钟` */
|
||||
/** `3 分钟` */
|
||||
widthLabel: string;
|
||||
}>;
|
||||
|
||||
@@ -131,12 +131,23 @@ function buildTicks(axisStart: number, axisEnd: number): TimelineTick[] {
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export type TimelineInferenceMark = Readonly<{
|
||||
time: string;
|
||||
eliminated: boolean;
|
||||
}>;
|
||||
|
||||
export type RectificationTimelineInput = Readonly<{
|
||||
/** `candidate_range`: the case's current search window, and the axis. */
|
||||
searchWindow: readonly [string, string] | null;
|
||||
/** `credible_range`; absent during block scan, where the band is the window. */
|
||||
credibleRange: readonly [string, string] | null;
|
||||
candidateTimes: readonly string[];
|
||||
/**
|
||||
* Inference-layer minutes with elimination status. When present and non-empty,
|
||||
* these are the marks; `eliminated` is the only in/out judge. When absent,
|
||||
* engine `candidateTimes` are drawn solid.
|
||||
*/
|
||||
inferenceMarks?: readonly TimelineInferenceMark[];
|
||||
stage: RectificationTimelineStage | null;
|
||||
}>;
|
||||
|
||||
@@ -177,9 +188,12 @@ export function buildRectificationTimeline(
|
||||
// boundaries do not reach the client as structured data (see PROGRESS).
|
||||
const marks: TimelineMark[] = [];
|
||||
if (input.stage === "minute") {
|
||||
const sourced = input.inferenceMarks && input.inferenceMarks.length > 0
|
||||
? input.inferenceMarks.map((mark) => ({ time: mark.time, eliminated: mark.eliminated }))
|
||||
: input.candidateTimes.map((time) => ({ time, eliminated: false }));
|
||||
const seen = new Set<number>();
|
||||
for (const raw of input.candidateTimes) {
|
||||
const parsed = parseClockMinutes(raw);
|
||||
for (const item of sourced) {
|
||||
const parsed = parseClockMinutes(item.time);
|
||||
if (parsed === null) continue;
|
||||
const at = alignToAxis(parsed, axisStart, axisEnd);
|
||||
if (at < axisStart || at > axisEnd) continue;
|
||||
@@ -188,8 +202,8 @@ export function buildRectificationTimeline(
|
||||
marks.push({
|
||||
key: `m${at}`,
|
||||
percent: timelinePercent(at, axisStart, axisEnd),
|
||||
// Closed interval: a candidate sitting on a boundary is still in range.
|
||||
state: at >= bandStart && at <= bandEnd ? "in" : "out",
|
||||
// Status from the inference layer, never band position (BUG-603).
|
||||
state: item.eliminated ? "out" : "in",
|
||||
});
|
||||
}
|
||||
marks.sort((left, right) => left.percent - right.percent);
|
||||
@@ -203,6 +217,6 @@ export function buildRectificationTimeline(
|
||||
marks,
|
||||
ticks: buildTicks(axisStart, axisEnd),
|
||||
rangeLabel: `${formatClockMinutes(bandStart)}–${formatClockMinutes(bandEnd)}`,
|
||||
widthLabel: timelineDurationLabel(bandEnd - bandStart),
|
||||
widthLabel: timelineDurationLabel(bandEnd - bandStart + 1),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user