merge origin/staging into report chart render branch
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

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:
Jesse_Chen
2026-09-09 15:13:37 +08:00
co-authored by Cursor
67 changed files with 1826 additions and 172 deletions
+3 -3
View File
@@ -252,7 +252,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring.
- **Candidate result:** keep the reported range, candidate interval, and active-time status visually separate. Delivery shows one range card: title (range + event count), up to three compare columns (time, relative likelihood, D9/D10/nakshatra traits, event-fit counts, next-12-month windows, “更像这个”), and the representative-minute boundary. An eight-method report sits in a closed `<details>` fold. Support numbers stay on the house board. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels a column as the true birth time.
- **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year. Stop on spoken collect is not a composer button: `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) stays on choice cards, and generating turns keep “停止回答”. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The composer has no `rectification-step-state` status sentence and no `rectification-composer-meta`.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year. Spoken collect, when the focus is live and the turn is not busy, shows two 44px secondary buttons above the input, class `rectification-collect-reply` (not `rectification-step-state`): 「没有」 posts the same declined path as typing 没有; 「记不清」 posts skipped. Stop on spoken collect is not a composer button: `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) stays on choice cards, and generating turns keep “停止回答”. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The method sentence (`vargaSentence`) lives in the expanded activity timeline, not in the spoken bubble. The composer has no `rectification-step-state` status sentence and no `rectification-composer-meta`.
- **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey.
- **Scoring and retry:** `score_pending` is a quiet progress surface with cancellable bounded polling and no manual compare control. `retry_scoring` preserves the confirmed evidence and exposes one explicit retry action. Refresh and device changes resume from the persisted action rather than inferring progress from copy.
- **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute.
@@ -514,7 +514,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
**二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px56px 等于既有的 `--rectification-jump-clearance`44px 触控尺寸 + `space-3`),不新造常数。
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与区间读数(`05:0705:09 · 2 分钟`)。以下一律不得加回来——每一条都是被明确砍掉的:
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与区间读数(`05:0705:09 · 3 分钟`)。以下一律不得加回来——每一条都是被明确砍掉的:
| 不得出现 | 理由 |
| --- | --- |
@@ -529,7 +529,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
- **轴 = 当前搜索窗口(`candidate_range`),不是开场窗口。** 开场窗口不是定值(±15 / ±30 / ±60 / ±120 / 整天),且放宽会顶穿它(BUG-572:±15 → ±30 → ±60 → ±120)。窗口变化时轴重新对到新窗口,区间带与标记用 CSS transition 过渡到新位置(`prefers-reduced-motion` 由全局 `*` 规则收成一帧)。窗口不变时轴稳定,收窄照样读得出来。
- **候选标记一律等大,只分在范围内 / 已排除。** 不得按 `relativeSupport` / `probability_percent` 分级(尺寸、深浅、粗细都不行):BUG-560 状态是 blocked,候选间相对支持度 7–9 分(满分 100),校准显示分钟级几乎无区分力,分级显示等于用视觉编码放大统计上不显著的差异。被排除的点留在原地变空心、不消失——用户由此看见自己刚才那一答排掉了哪几分钟。
- **时段阶段(`block_scan`)不画分钟点**,区间带覆盖整个窗口。那一刻确实什么都还没排除,写「23 小时 59 分」是事实而非占位。
- **时段阶段(`block_scan`)不画分钟点**,区间带覆盖整个窗口。那一刻确实什么都还没排除,写「24 小时」是事实而非占位。
- 跨午夜的窗口(`late_night` 是 23:00–03:59,末端早于始端)按次日展开,轴保持单调。
- 条上不得出现 `rectification-step-state` 类名(BUG-575 防复发)。
+5 -4
View File
@@ -22,7 +22,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 坏 | 好 | 为什么 |
|---|---|---|
| 请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病;只记得年份也可以。 | 从你最容易想起来的开始就好——比如哪年上的大学、哪年换的工作,记得大概年份就行。 | 公文枚举改成一件容易开口的事。 |
| 请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病;只记得年份也可以。 | 眼下按 04:45–05:15 来核对,用你记得的经历对照几种分盘和大运。最后给区间和代表分钟,不给精确到秒。想到几件说几件,有大概年月就行——比如升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤。 | 开场三句讲做法并点领域,不写具体年份。 |
| (服务端探针 2023 感情)2023 年前后,你有没有一段认真开始或结束的关系?逐字复读模板。 | 你刚才说到工作这块已经比较清楚了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读模板。 |
| (服务端探针 2016 升学)2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 |
| (服务端探针 2019 入职)2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 题干不写选项字面;选项由服务端画在同一条消息里。 |
@@ -38,15 +38,16 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 采用状态在该列按钮上。`postAdoptVerifyDone` 在没有核对题时改短句。 |
| (采用后只念代表分钟)代表分钟是 05:12。 | 这 27 分钟里,事业方向、性格底色的判断是稳定的;婚恋(D9)会随分钟变,看盘时按范围读。这不是已确认的唯一出生分钟。 | 采用后先讲区间里什么稳、什么随分钟变;两句来自服务端,不是模型编的。 |
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
| 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 |
| 好的,记下了。 | 记下了:2016 年 9 月入学、2020 年 6 月毕业。 | 只复述年-月和事件短语,不评价价值。 |
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 |
| 选一个最贴近你实际情况的就行,答不上来也可以选「一时说不好」。 | 2024 年那件事记下了。接下来我们继续。 | 正文先承接用户刚说的年份和事件;不要预告选项。 |
| ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 |
| 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 |
| 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 |
| 也可以再说一件你记得大概时间的事。 | 能分开候选的问题已经问完,先按现有材料给你结果。 / 还差带月份的经历,领域不限。 | 采集问完后交付或说清门槛,不再开放采集。 |
| 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。输入框上方不再挂先这样。范围小字是状态句,不可点。 | 停止入口只留选择题卡与生成中停止。 |
| 选择题只有题干,不知道为什么问、答了会怎样 | 题干下可展开「为什么问这题」;选项旁有一段「会让哪段领先 / 落后」。 | 解释由服务端生成,不让模型编分数。 |
| 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。口述采集输入框上方是「没有」「记不清」两个 44px 次要按钮,不是先这样。范围小字是状态句,不可点。 | 没有=拒答,记不清=本会话先放着。 |
| 只报一件后追问「还有吗,还能想起别的吗」 | 下一问仍是既有逐领域采集。第一道题干末尾由服务端加一次「想到别的也可以一起说。」 | 邀请不是要求;不插追问轮,也不重复开场。 |
| 点选后旁白写「04:3104:39 这段领先,05:0005:07 这段落后」 | 已记录,范围收到 04:31–04:39。 | 旁白只说范围变没变。领先/落后留在卡内「答了会怎样」,不进气泡。 |
| 每轮只有技法清单,不知道现在第几步 | 输入框上方有「第 N 步 · 原因 · 下一步」。 | 用户始终看得见该做什么。 |
| 完全不清楚出生时间后劝退「生时校正以后需要时再做」 | 可以直接开始生时校正:先从你记得的经历比出大致时段。 | 不知道钟点时先比时段,不劝退、不给分钟卡。 |
| 问「偏移 / 误差有多少分钟」 | 你对这个时间有多确定? | 不确定度只在资料里问一次,校正不再追问。 |
@@ -36,6 +36,8 @@ import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inf
import { CHOICE_STOP_LABEL, parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card";
import {
classifyRectificationTurnIntent,
isCollectDeclineUtterance,
isCollectSkipUtterance,
optionIdForAnswerClass,
shouldDeclineCollectFocus,
shouldContinueAgentForDatedEvent,
@@ -493,11 +495,30 @@ export async function POST(request: Request) {
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
} else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema)) {
const userMessage = parsed.data.message ?? "";
if (isCollectDeclineUtterance(userMessage) || isCollectSkipUtterance(userMessage)) {
const applied = await applyCollectFocusDenial(accounting, {
userId,
caseId,
focusId: focus.id,
status: isCollectSkipUtterance(userMessage) ? "skipped" : "declined",
narrateAdopt,
});
const finished = await persistCollectDenialTurn({
accounting,
userId,
caseId,
requestId,
userMessage,
applied,
});
return completedMessageResponse(finished.streamText, requestId, caseId, finished.turnId);
}
let classified = null;
try {
classified = await classifyRectificationTurnIntent(selectedModel, {
focus,
userMessage: parsed.data.message ?? "",
userMessage,
caseStatus,
signal: request.signal,
});
+12
View File
@@ -1518,6 +1518,18 @@ button:disabled { cursor: default; opacity: .45; }
.composer-queue__recall:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
.composer-wrap { position: sticky; bottom: 0; z-index: 2; min-width: 0; border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: var(--space-3) var(--space-6) var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); }
.rectification-collect-replies {
width: min(760px, 100%);
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0 auto var(--space-2);
}
.rectification-collect-reply {
min-height: 44px;
min-width: 44px;
padding: 0 var(--space-4);
}
/* Jump to latest: shared by both chat surfaces. Hangs off the composer wrap's top
edge so it never reflows the transcript or the composer. */
.jump-to-latest {
@@ -85,12 +85,10 @@ export function StreamingMarkdown({
export function ChatMessageContent({
text,
auditRows,
vargaSentence,
streaming = false,
}: {
text: string;
auditRows?: readonly TechniqueAuditRow[];
vargaSentence?: string | null;
streaming?: boolean;
}) {
const renderMarkdown = useMarkdownRenderer();
@@ -112,7 +110,6 @@ export function ChatMessageContent({
))}
</div>
) : null}
{vargaSentence ? <p className="message-varga-sentence">{vargaSentence}</p> : null}
{showFoldedAudit ? <TechniqueAuditDisclosure rows={rows} /> : null}
</div>
);
+1 -1
View File
@@ -116,7 +116,6 @@ export function ChatMessageRow({
<ChatMessageContent
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={showThinkingPanel ? null : vargaSentence}
streaming={message.state !== "settled"}
/>
)
@@ -137,6 +136,7 @@ export function ChatMessageRow({
<ConsultationRunTimeline
rows={consultTimeline}
live={showActivity && message.state !== "settled"}
vargaSentence={vargaSentence}
/>
{hasAnswer ? (
<section className="consultation-report-analysis" aria-label="回复">
@@ -57,14 +57,17 @@ export function timelineSummaryLabel(rows: readonly ConsultationTimelineRow[], l
export function ConsultationRunTimeline({
rows,
live = false,
vargaSentence,
}: Readonly<{
rows: readonly ConsultationTimelineRow[];
live?: boolean;
vargaSentence?: string | null;
}>) {
const bodyId = useId();
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const visibleRows = visibleTimelineRows(rows, live);
if (visibleRows.length === 0) return null;
const varga = vargaSentence?.trim() ?? "";
if (visibleRows.length === 0 && !varga) return null;
const open = userOpen ?? live;
const summary = timelineSummaryLabel(rows, live);
@@ -94,6 +97,14 @@ export function ConsultationRunTimeline({
{visibleRows.map((row) => (
<TimelineRow key={row.id} row={row} />
))}
{varga ? (
<li className="agent-thinking-step is-done consultation-run-timeline__row">
<span className="agent-thinking-marker" aria-hidden="true">
<Check />
</span>
<span>{varga}</span>
</li>
) : null}
</ol>
</div>
</div>
@@ -1421,6 +1421,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
searchWindow,
credibleRange: candidateResult?.credibleRange ?? null,
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
inferenceMarks: candidateResult?.inferenceMarks ?? [],
stage: caseStage,
});
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
@@ -1732,6 +1733,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{!conversationAnchor.anchored && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
{collectSpokenPrompt && !busy && !readonly && regeneratingMessageKey === null ? (
<div className="rectification-collect-replies" role="group" aria-label="采集题快捷回答">
<Button
type="button"
variant="outline"
className="rectification-collect-reply"
onClick={() => void send("message", "没有")}
>
</Button>
<Button
type="button"
variant="outline"
className="rectification-collect-reply"
onClick={() => void send("message", "记不清")}
>
</Button>
</div>
) : null}
<ChatComposer
inputRef={composer}
value={draft}
@@ -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:0705: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),
};
}
+2 -2
View File
@@ -63,10 +63,10 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
1. rectification-read-case
2. display_date_label
3. rectification-record-evidence-batch
4. rectification-set-focus spokenPrompt /2-4 set-focus spokenPrompt next_followup=null 2-4 +case.accepted_time choice collect_spoken
4. rectification-set-focus spokenPrompt / set-focus spokenPrompt next_followup=null / / / +case.accepted_time choice collect_spoken
5. confirmation_allowed false 5 skill_verification_report80%/60%
6. Skill
2016 9
2016 9 2020 6
1 `;
export function getRectificationV9Agent(
@@ -4,13 +4,16 @@ import test from "node:test";
import {
GENERIC_COLLECT_QUESTION,
OPENING_COLLECT_DOMAINS,
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
accidentCaseHardcodedTurns,
containsBoundarySemantics,
engineMeaningToDisplayCopy,
isAcceptableOpeningBody,
listUserVisibleCopy,
nonConvergingRangeNarration,
openingSpokenBody,
stopReasonPrefix,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
@@ -114,6 +117,7 @@ test("delivery range narration puts the stop reason before the progress clause",
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectDeclinedAck));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectSkippedAck));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.probePoolExhaustedStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.insufficientEventsGate));
assert.equal(stopReasonPrefix("probe_pool_exhausted"), RECTIFICATION_USER_COPY.probePoolExhaustedStop);
@@ -127,10 +131,15 @@ test("question stem ownership stays on set-focus spokenPrompt, not a slot or a s
assert.match(agent, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(agent, /题干会作为同一条消息的下一段自动出现/);
assert.match(agent, /开场轮:先 set-focus 写采集题的 spokenPrompt/);
assert.match(agent, /不要举大学、工作、搬家的例子/);
assert.match(agent, /每轮正文 2-4 句/);
// 旧:不要举大学、工作、搬家的例子 / 每轮正文 2-4 句 / 接下来我们继续
// 新:开场点六类、证据轮一句复述
// 原因:BUG-604 / BUG-606
assert.match(agent, /升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤/);
assert.match(agent, /证据轮正文只写一句复述/);
assert.match(agent, /记下了:2016 年 9 月入学、2020 年 6 月毕业/);
assert.match(agent, /正文不得断言界面当前状态/);
assert.match(agent, /接下来我们继续/);
assert.doesNotMatch(agent, /每轮正文 2-4 句/);
assert.doesNotMatch(agent, /不要举大学、工作、搬家的例子/);
assert.doesNotMatch(agent, /题干与选项完全由结构化槽位和 UI 承担/);
assert.doesNotMatch(agent, /collect_spoken 题干由服务器接在同一条正文末尾/);
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
@@ -256,3 +265,18 @@ test("skill forbids computing D9/D10 signs from transition clocks", () => {
assert.match(skill, new RegExp(sentence.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(comparison, new RegExp(sentence.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
test("opening body lists domains without years and the stem no longer lists year examples", () => {
const body = openingSpokenBody(["04:45", "05:15"]);
assert.equal(isAcceptableOpeningBody(body), true);
assert.ok(OPENING_COLLECT_DOMAINS.filter((domain) => body.includes(domain)).length >= 5);
assert.doesNotMatch(body, /(?:19|20)\d{2}/);
assert.match(body, /最后给区间和代表分钟,不给精确到秒/);
assert.equal(GENERIC_COLLECT_QUESTION, "先说你最容易想起的一两件,年月大概就行。");
assert.doesNotMatch(GENERIC_COLLECT_QUESTION, /比如/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectSkippedAck));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.firstDatedCollectInvite));
assert.doesNotMatch(listUserVisibleCopy().join("\n"), /还有吗|还能想起别的吗/);
assert.ok(MACHINE_VOICE_LEXICON.includes("领先"));
assert.ok(MACHINE_VOICE_LEXICON.includes("落后"));
});
+5 -1
View File
@@ -74,7 +74,11 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(messageRowSource, /stackedThinkingAndAnswer/);
assert.match(messageRowSource, /aria-label="回复"/);
assert.match(messageRowSource, /className="message-stage-and-answer"/);
assert.match(messageRowSource, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
// 旧:vargaSentence 在气泡正文,折叠思考时才藏
// 新:正文不再渲染方法句;展开的活动时间线保留
// 原因:BUG-606 决策 3(a)
assert.doesNotMatch(messageRowSource, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
assert.match(messageRowSource, /<ConsultationRunTimeline[\s\S]*vargaSentence=\{vargaSentence\}/);
assert.match(globalStyles, /\.message-stage-and-answer/);
assert.match(globalStyles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
@@ -706,7 +706,10 @@ test("rectification Agent output stays natural and keeps tool execution silent",
assert.match(strategy, /(?:无需|不要求)结束、暂停或保存进度/);
assert.match(strategy, /不要一进场就出 A\/B\/C\/D/);
assert.match(agent, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(agent, /正文只做承接(2-4 句),不提问、不复述题干、不预告选项/);
// 旧:正文只做承接(2-4 句)→ 新:正文只做承接,证据轮一句复述
// 原因:BUG-606 每轮只留一句话,不再给 2-4 句额度
assert.match(agent, /正文只做承接,不提问、不复述题干、不预告选项/);
assert.match(agent, /证据轮正文只写一句复述/);
});
test("clear current-turn events go through the batch evidence service", () => {
@@ -30,7 +30,7 @@ import {
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { RECTIFICATION_TERMINATION_COPY, ADOPT_OUTCOMES } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import {
evidenceLedgerFingerprint,
parseV9CaseDossier,
@@ -729,7 +729,7 @@ test("clicking A applies the choice without invoking a language model", async ()
start: "2015-01-01",
end: "2015-12-31",
});
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /已记录/);
// 原值: 旁白含 05:0005:10 或「眼下更像 05:00」,且不再问家人
// 新值: 下一问是家人采集
// 原因: BUG-559 无同域带年月锚点的 varga_style 卡被丢掉;账本只有事业,下一问按方法轮转到家人
@@ -809,7 +809,7 @@ test("answering a discriminator persists the next dated card so GET still has a
const persistedPrompt = schema.choice?.prompt;
assert.ok(persistedPrompt);
assert.notEqual(applied.narration, persistedPrompt);
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /已记录/);
assert.doesNotMatch(applied.narration, /2014/);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_assistant_message, applied.narration);
@@ -978,18 +978,23 @@ test("answering the last discriminator persists a year-locked family collect foc
const schema = setFocus.args.p_expected_answer_schema as { choice?: unknown; prompt?: string; collect?: boolean };
assert.equal(schema.choice, undefined);
assert.equal(schema.collect, true);
// 旧:prompt 含 2021 → 新:口语等于 USER_COLLECT_QUESTION.family → 决策 7 采集题不带年份前缀
assert.equal(schema.prompt, RECTIFICATION_USER_COPY.collectQuestionByDomain.family);
// 旧:prompt 等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604;年份仍不进采集题干
assert.equal(
schema.prompt,
withFirstDatedCollectInvite(RECTIFICATION_USER_COPY.collectQuestionByDomain.family),
);
assert.match(schema.prompt ?? "", /家人|结婚|添丁|住院/);
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, false);
assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false);
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /已记录/);
assert.doesNotMatch(applied.narration, /2021/);
assert.doesNotMatch(applied.narration, /点选/);
assert.doesNotMatch(applied.narration, /D24/);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.match(String(turn?.args.p_assistant_message ?? ""), /已记录你的选择/);
assert.match(String(turn?.args.p_assistant_message ?? ""), /已记录/);
assert.doesNotMatch(String(turn?.args.p_assistant_message ?? ""), /2021/);
assert.equal(
accounting.calls.some((call) => (
@@ -1047,7 +1052,7 @@ test("keeps the applied answer when narration persistence fails", async () => {
assert.equal(applied.applied, true);
assert.equal(applied.status, "applied");
assert.equal(applied.narrationPersisted, false);
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /已记录/);
});
test("stop_and_review does not write an inference transition", async () => {
@@ -1301,9 +1306,12 @@ test("a scoring choice narrates cluster movement and range change", () => {
credibleBefore: ["04:31", "05:07"],
credibleAfter: ["04:31", "04:39"],
});
assert.match(narrowed, /领先/);
assert.match(narrowed, /落后/);
assert.match(narrowed, /范围从 04:3105:07 收到 04:3104:39/);
// 旧:领先/落后 +「范围从 04:3105:07 收到 04:3104:39」
// 新:已记录,范围收到 A–B。不变则「范围没变」。变宽写「变为」。
// 原因:BUG-606 决策 3(b),旁白不再讲哪段领先落后
assert.equal(narrowed, "已记录,范围收到 04:3104:39。");
assert.doesNotMatch(narrowed, /领先|落后/);
assert.doesNotMatch(narrowed, /范围从 /);
const unchanged = composeChoiceNarration({
optionId: "A",
scoring: true,
@@ -1313,6 +1321,14 @@ test("a scoring choice narrates cluster movement and range change", () => {
credibleAfter: ["04:31", "04:39"],
});
assert.match(unchanged, /范围没变/);
const widened = composeChoiceNarration({
optionId: "A",
scoring: true,
appliedInference: true,
credibleBefore: ["04:45", "04:50"],
credibleAfter: ["04:40", "05:00"],
});
assert.equal(widened, "已记录,范围变为 04:4005:00。");
const unsure = composeChoiceNarration({
optionId: "D",
scoring: true,
@@ -1353,8 +1369,12 @@ test("answering A narrates the credible range even when the search window stays
optionId: "A",
expectedRevision: previous.revision,
});
assert.match(applied.narration, /范围从 04:3105:07 收到 04:3104:31/);
// 旧:整段旁白等于「已记录,范围收到 04:3104:31。」
// 新:旁白以该句开头;若下一问未能单独落库,服务端仍可能把采集题干接在后面
// 原因:BUG-606 决策 3(b) 锁的是点选旁白,不是下一问是否挂上同一条消息
assert.match(applied.narration, /^已记录,范围收到 04:3104:31。/);
assert.doesNotMatch(applied.narration, /范围没变/);
assert.doesNotMatch(applied.narration, /领先|落后/);
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
assert.match(source, /credibleBefore:\s*previous\.credible_range/);
assert.match(source, /credibleAfter:\s*applied\.state\.credible_range/);
@@ -307,4 +307,40 @@ test("session_outcome gates adopt cards independently of leaked can_adopt", () =
assert.equal(canShowRectificationReadonlyRange(adopted), false);
});
test("parses inference marks from decisionReceipt.inference_state.candidates", () => {
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const result = parseRectificationCandidateResult({
...camelCaseSnapshot,
candidates: [
{ candidateId: CANDIDATE_ID, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 },
{ candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 },
],
credible_range: ["04:51", "04:53"],
decisionReceipt: {
...camelCaseSnapshot.decisionReceipt,
inference_state: {
candidates: times.map((clock) => ({
time: clock,
status: clock === "04:51" || clock === "04:53" ? "active" : "eliminated",
})),
},
},
});
assert.ok(result);
assert.ok(result.inferenceMarks);
assert.equal(result.candidates.length, 2);
assert.deepEqual(result.inferenceMarks.map((mark) => mark.time), [...times]);
assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7);
assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:51")?.eliminated, false);
assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:45")?.eliminated, true);
});
test("inference marks are empty when inference_state is absent", () => {
const result = parseRectificationCandidateResult(camelCaseSnapshot);
assert.ok(result);
assert.deepEqual(result.inferenceMarks, []);
});
@@ -28,7 +28,7 @@ import {
parseV9CaseDossier,
RectificationToolServiceError,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
@@ -186,7 +186,10 @@ test("family collect spoken stem has no year prefix while probe_year stays dated
});
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 只报一件也走既有采集,不插「还有吗」追问轮
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
});
test("four scoreable events skip holdout for domains already in the ledger", () => {
@@ -582,13 +585,13 @@ test("career discriminator yes covers career collect without ledger career evide
});
assert.equal(unanswered.next_followup?.domain, "career");
assert.equal(unanswered.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(unanswered.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(unanswered.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
const yes = careerAnswerPlan("yes");
assert.notEqual(yes.next_followup?.domain, "career");
assert.equal(yes.next_followup?.domain, "education");
assert.equal(yes.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(yes.next_followup), USER_COLLECT_QUESTION.education);
assert.equal(spokenFollowupForUser(yes.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.education));
assert.equal(
yes.methods.find((item) => item.method_id === "d10_career")?.status,
"covered",
@@ -600,17 +603,17 @@ test("career discriminator yes covers career collect without ledger career evide
const no = careerAnswerPlan("no");
assert.equal(no.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(no.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(no.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
const unsure = careerAnswerPlan("unsure");
assert.equal(unsure.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(unsure.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(unsure.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
});
test("career discriminator yes does not invent coverage from a semantic_key prefix", () => {
const plan = careerAnswerPlan("yes", { eventProbes: [] });
assert.equal(plan.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
});
test("ledger-classified career probe answers do not cover career collect", () => {
@@ -623,7 +626,7 @@ test("ledger-classified career probe answers do not cover career collect", () =>
}],
});
assert.equal(plan.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
});
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences, trimEvidenceTurnBody } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
@@ -121,3 +121,13 @@ test("attachQuestionsToTurns leaves body unchanged when the turn has no focus",
assert.equal(turns[0]?.question, null);
});
test("trimEvidenceTurnBody keeps the first sentence and strips value judgments", () => {
const three = trimEvidenceTurnBody("记下了:2016 年 9 月入学。这对校正很有帮助。接下来我们继续。");
assert.equal(three, "记下了:2016 年 9 月入学。");
assert.doesNotMatch(three, /很有帮助|很有价值|很有分量|特别有用/);
const judged = trimEvidenceTurnBody("记下了:2020 年 6 月毕业,特别有用。");
assert.doesNotMatch(judged, /特别有用/);
const two = trimEvidenceTurnBody("记下了:2016 年 9 月入学。\n\n范围收到 04:5005:10。");
assert.equal(two, "记下了:2016 年 9 月入学。\n\n范围收到 04:5005:10。");
});
@@ -33,6 +33,7 @@ import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9
import {
RECTIFICATION_USER_COPY,
USER_COLLECT_QUESTION,
withFirstDatedCollectInvite,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
ATTEMPT_ID,
@@ -462,8 +463,8 @@ function rpcDossier(decision: DecisionDossier, activeFocus?: Record<string, unkn
});
}
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => {
@@ -486,7 +487,10 @@ test("revision 5 with uncovered relatives asks the dated family collect, not a y
// 旧:year_label 含 2021 → 新:采集口语不带年份前缀,year_label 不进 followup
// 原因:决策 7probe_year 仍锁定 dated 家庭采集,不出无年份 D12 卡
assert.equal(plan.next_followup?.year_label, undefined);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 单事件开场不插追问轮
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
assert.equal(plan.dropped_probes.some((item) => (
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
)), true);
@@ -1295,9 +1299,12 @@ test("has_new_dated_event continues into the agent after applying the answer", (
answerChoice.indexOf("export async function persistCollectDenialTurn"),
answerChoice.indexOf("export function isStalePreAdoptFocus"),
);
assert.match(persistDenial, /composeCollectSpokenAssistantText\(RECTIFICATION_USER_COPY\.collectDeclinedAck/);
assert.match(persistDenial, /const ack = input\.applied\.ack/);
assert.match(persistDenial, /composeCollectSpokenAssistantText\(ack,/);
assert.match(persistDenial, /linkFocusAskedTurn/);
assert.match(persistDenial, /streamText = hasNextStem \? RECTIFICATION_USER_COPY\.collectDeclinedAck/);
// 旧:streamText 写死 collectDeclinedAck → 新:用 applied.ack,跳过走 collectSkippedAck
// 原因:BUG-605 「记不清」回执与「没有」不同
assert.match(persistDenial, /streamText = hasNextStem \? ack/);
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
assert.match(persistApplied, /command\.deferFollowup !== true/);
assert.equal(shouldContinueAgentForDatedEvent({
@@ -359,7 +359,7 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
assert.match(agentSource, /不得宣称唯一出生分钟/);
assert.doesNotMatch(agentSource, /±2 分钟/);
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
const accounting = fakeAccounting({
...receiptHandlers,
@@ -673,7 +673,10 @@ test("nonterminal invariant 3: answer_choice response waits until incident fallb
// 保留语义:answer_choice 出口仍必须先把家人采集题干写入 focus/turn,再返回 HTTP。
assert.equal(decision.canAdopt, true);
assert.ok(currentQuestion?.prompt);
assert.equal(currentQuestion.prompt, "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。");
// 旧:家人采集题干原句
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 单事件开场不插追问轮
assert.equal(currentQuestion.prompt, "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。想到别的也可以一起说。");
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.equal(setFocus.args.p_intent, "collect_method_evidence");
@@ -199,12 +199,12 @@ test("delivery report gives 04:53 D10 as Cancer instead of letting the model inf
assert.match(report.markdown, /04:53 \| .*巨蟹座/);
});
test("skill 10.0.18 forbids computing varga signs from transition times", () => {
test("skill 10.0.19 forbids computing varga signs from transition times", () => {
const skillDir = fileURLToPath(new URL("../../skills/jyotish-birth-time-rectification", import.meta.url));
const skill = readFileSync(`${skillDir}/SKILL.md`, "utf8");
const comparison = readFileSync(`${skillDir}/references/candidate-comparison.md`, "utf8");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.match(skill, /^version: 10\.0\.18$/m);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.match(skill, /^version: 10\.0\.19$/m);
assert.match(skill, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(comparison, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
@@ -13,7 +13,7 @@ import {
parseWindowScan,
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import { WINDOW_SCAN_DISPLAY_LAYER_ORDER } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
@@ -1454,9 +1454,9 @@ test("rescore failure does not fail the evidence write", async () => {
assert.ok(result.rescore.error_code);
});
test("public tool surface stays at 14 and new cases bind 10.0.18", () => {
test("public tool surface stays at 14 and new cases bind 10.0.19", () => {
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
const deprecated = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.2",
@@ -2381,7 +2381,10 @@ test("family collect attaches the collection-probe year instead of a yearless D2
// 原因:决策 7,年份只用于计分与去重,不进采集题干
assert.equal(plan.next_followup?.year_label, undefined);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 单事件开场不插追问轮
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
});
test("already-open yearless family card is abandoned instead of kept as a scoring frame", () => {
@@ -576,8 +576,8 @@ function warnLines(run: () => Promise<unknown> | unknown) {
}).then((result) => ({ result, lines }));
}
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("USER_COLLECT_QUESTION no longer has an other fallback", () => {
@@ -335,7 +335,7 @@ test("narrator failure still leaves the choice applied and does not ask the user
scoring: true,
appliedInference: true,
});
assert.match(applied, /已记录你的选择/);
assert.match(applied, /已记录,范围没变/);
assert.doesNotMatch(applied, /请再选一次|重新回答/);
const stopped = composeChoiceNarration({
optionId: "stop",
@@ -213,9 +213,9 @@ test("read-case evidence context keeps day labels and confirm does not rewrite d
assert.equal("p_occurred_from" in confirmCall.args, false);
});
test("new-case skill identity is 10.0.18 and the prompt prefers batch ingest", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.match(skill, /^version: 10\.0\.18$/m);
test("new-case skill identity is 10.0.19 and the prompt prefers batch ingest", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.match(skill, /^version: 10\.0\.19$/m);
assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/);
assert.match(agentSource, /新事件走 rectification-record-evidence-batch/);
assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
@@ -184,8 +184,8 @@ const CANDIDATE_IDS = [
"88888888-8888-4888-8888-888888888882",
] as const;
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("nineteen-row ledger opens the training gate with four scoreable domains", () => {
@@ -11,7 +11,10 @@ import {
buildNextUserAction,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
USER_COLLECT_QUESTION,
withFirstDatedCollectInvite,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationQuestionGapState,
@@ -204,6 +207,9 @@ test("family collect spoken stem equals USER_COLLECT_QUESTION.family while probe
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(plan.next_followup?.choice_frame, null);
const spoken = spokenFollowupForUser(plan.next_followup);
assert.equal(spoken, USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 只报一件也走既有采集,不插「还有吗」追问轮
assert.equal(spoken, withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
assert.doesNotMatch(spoken ?? "", /^\d{4} 年前后,/);
});
@@ -296,6 +296,8 @@ test("collect denial persists ack+stem then binds asked_turn_id; stream is ack o
userMessage: "没有",
applied: {
narration: prompt,
ack: RECTIFICATION_USER_COPY.collectDeclinedAck,
closeStatus: "declined",
nextInterviewPersisted: true,
nextChoiceReady: false,
focus: {
@@ -341,6 +343,8 @@ test("collect denial without a next stem still writes a turn and does not invent
userMessage: "没有",
applied: {
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
ack: RECTIFICATION_USER_COPY.collectDeclinedAck,
closeStatus: "declined",
nextInterviewPersisted: false,
nextChoiceReady: false,
focus: null,
@@ -426,8 +426,8 @@ function rpcDossier(decision: DecisionDossier) {
});
}
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
@@ -517,10 +517,11 @@ test("zero-evidence opening persists the server-owned first question", async ()
// 新值: collect:other:collect_method_evidence
// 原因: 开场仍用 domain=otherGENERIC 题干;稳定 id 不得含 unknown
assert.equal(stableFollowupQuestionId(followup), "collect:other:collect_method_evidence");
// 旧:开场 domain=other 后查表落到补问兜底句 → 新:method_coverage + other 且非 retry 用 GENERIC_COLLECT_QUESTION → 保留 开场题由服务端出、带具体例子
// 旧:开场 GENERIC 带「比如哪年上的大学…」→ 新:题干只邀请一两件,领域清单在开场正文
// 原因:BUG-604
assert.equal(spokenFollowupForUser(followup), GENERIC_COLLECT_QUESTION);
assert.doesNotMatch(spokenFollowupForUser(followup) ?? "", /^也可以再/);
assert.match(spokenFollowupForUser(followup) ?? "", /比如/);
assert.match(spokenFollowupForUser(followup) ?? "", /先说你最容易想起的一两件/);
const expected = GENERIC_COLLECT_QUESTION;
const accounting = fakeAccounting({
@@ -606,15 +607,15 @@ test("exhaustion other fallback and other retry keep the after-evidence copy", (
assert.equal(spokenFollowupForUser(retry), null);
});
test("GENERIC_COLLECT_QUESTION keeps concrete opening examples", () => {
test("GENERIC_COLLECT_QUESTION invites one or two memories without listing years", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8");
assert.match(GENERIC_COLLECT_QUESTION, /比如/);
assert.ok(
[...GENERIC_COLLECT_QUESTION.matchAll(/上的大学|换的工作|搬的家/g)].length >= 2,
GENERIC_COLLECT_QUESTION,
);
// 旧:「…开始就好——比如哪年上的大学、哪年换的工作…」→ 新:加「哪年搬的家」→ 保留 开场题必须带具体例子、由服务端出
assert.match(source, /GENERIC_COLLECT_QUESTION = "你最容易想起的一件事开始就好/);
// 旧:从你最容易想起来的一件事开始就好——比如哪年上的大学、哪年换的工作、哪年搬的家
// 新:先说你最容易想起的一两件,年月大概就行。领域清单在开场正文。
// 原因:BUG-604 决策 1,题干不再列年份例子
assert.equal(GENERIC_COLLECT_QUESTION, "先说你最容易想起的一两件,年月大概就行。");
assert.doesNotMatch(GENERIC_COLLECT_QUESTION, /比如/);
assert.doesNotMatch(GENERIC_COLLECT_QUESTION, /(?:19|20)\d{2}/);
assert.match(source, /GENERIC_COLLECT_QUESTION = "先说你最容易想起的一两件,年月大概就行。"/);
});
test("degraded spoken collect does not keep discriminator identity or block a later card", async () => {
@@ -10,8 +10,15 @@ import {
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { spokenCollectFallbackFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
declinedCollectDomains,
nextDatedCollectFollowup,
reverseVerifyRemainingForAdopt,
spokenFollowupForUser,
spokenCollectFallbackFollowup,
type MethodFollowup,
type MethodFollowupEvidence,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_ID,
@@ -28,6 +35,8 @@ import {
import { parseV9CaseDossier as parseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { isCollectDeclineUtterance, isCollectSkipUtterance } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
const RELATIONSHIP_PROMPT = USER_COLLECT_QUESTION.relationship;
@@ -85,8 +94,8 @@ function collectPersistResult(overrides: {
};
}
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("cases current_question remains the submit contract, not a visual slot", () => {
@@ -107,6 +116,19 @@ test("cases current_question remains the submit contract, not a visual slot", ()
const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\""));
assert.doesNotMatch(wrap, /rectification-collect-stop/);
assert.doesNotMatch(wrap, /CHOICE_STOP_LABEL/);
// 旧:输入框上方无按钮(先这样已删)
// 新:collect_spoken 且未 busy 时两个 44px 次要按钮「没有」「记不清」
// 原因:BUG-605;不得复用 rectification-step-state
assert.match(wrap, /rectification-collect-replies/);
assert.match(wrap, /rectification-collect-reply/);
assert.match(wrap, /send\("message", "没有"\)/);
assert.match(wrap, /send\("message", "记不清"\)/);
assert.match(wrap, />[\s\n]*没有[\s\n]*</);
assert.match(wrap, />[\s\n]*记不清[\s\n]*</);
assert.doesNotMatch(wrap, /rectification-step-state/);
assert.match(chat, /collectSpokenPrompt && !busy && !readonly && regeneratingMessageKey === null/);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(styles, /\.rectification-collect-reply \{[\s\S]*min-height:\s*44px/);
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
});
@@ -403,3 +425,103 @@ test("agent route keeps question ownership in the server Case projection", () =>
assert.match(agentRun, /askedTurnId: turnId/);
assert.doesNotMatch(agentRun, /当前可询问范围/);
});
test("没有 and 记不清 short-circuit collect_spoken without a model run", () => {
assert.equal(isCollectDeclineUtterance("没有"), true);
assert.equal(isCollectDeclineUtterance("没有。"), true);
assert.equal(isCollectSkipUtterance("记不清"), true);
assert.equal(isCollectSkipUtterance("记不清!"), true);
assert.equal(isCollectDeclineUtterance("记不清"), false);
assert.equal(isCollectSkipUtterance("没有"), false);
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const collectStart = route.lastIndexOf("} else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema))");
const collectBranch = route.slice(collectStart);
const collectFocus = collectBranch.slice(0, collectBranch.indexOf("classifyRectificationTurnIntent"));
assert.match(collectFocus, /isCollectDeclineUtterance/);
assert.match(collectFocus, /isCollectSkipUtterance/);
assert.match(collectFocus, /status: isCollectSkipUtterance\(userMessage\) \? "skipped" : "declined"/);
assert.match(collectFocus, /persistCollectDenialTurn/);
assert.doesNotMatch(collectFocus, /runV9AgentTurn/);
});
test("skipped collect domains are not re-asked this session; reverse-verify may still touch them", () => {
const skippedFamily = [{
target_domain: "family",
status: "skipped",
intent: "collect_method_evidence",
}];
const declined = declinedCollectDomains(skippedFamily);
assert.equal(declined.has("family"), true);
const next = nextDatedCollectFollowup([], declined);
assert.notEqual(next?.domain, "family");
const familyProbe: DiscriminatingEventProbe = {
year: 2018,
year_label: "2018 年前后",
domain: "family",
event_family: "家里的大事",
source: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2018 年。",
role: "distinguish",
information_gain: 1.1,
semantic_key: "family.2018.dasha_boundary",
candidate_split_hash: "family.2018.dasha_boundary",
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
choice_kind: "existence",
};
const remaining = reverseVerifyRemainingForAdopt({
eventProbes: [familyProbe],
evidence: [],
declinedTopics: skippedFamily,
});
assert.equal(remaining.some((probe) => probe.domain === "family"), true);
});
test("single confirmed domain opening goes to dated collect without a chase-more turn", () => {
const evidence: MethodFollowupEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
];
const next = nextDatedCollectFollowup(evidence, new Set());
assert.ok(next);
assert.notEqual(next.domain, "education");
assert.equal(next.domain, "family");
const spoken = spokenFollowupForUser(next);
assert.ok(spoken);
assert.match(spoken, /家里/);
assert.match(spoken, /想到别的也可以一起说/);
assert.doesNotMatch(spoken, /还有吗|别的吗|还能想起别的吗/);
assert.doesNotMatch(spoken, /先说你最容易想起的一两件/);
const later = nextDatedCollectFollowup(evidence, new Set(), new Set(), { askedDatedCollect: true });
assert.equal(later?.domain, "family");
assert.doesNotMatch(spokenFollowupForUser(later) ?? "", /想到别的也可以一起说/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /recordV10EvidenceBatch/);
assert.match(tools, /result\.acceptedCount > 0[\s\S]*autoRescoreAfterEvidenceChange/);
const copy = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8");
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.doesNotMatch(copy, /还有吗|还能想起别的吗/);
assert.doesNotMatch(agent, /还有吗|还能想起别的吗/);
assert.doesNotMatch(agentRun, /还有吗|还能想起别的吗/);
});
test("three confirmed domains from one message are not re-asked by dated collect", () => {
const evidence: MethodFollowupEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null },
{ status: "confirmed", domain: "relocation", datePrecision: "year", occurredFrom: "2023-01-01", occurredTo: null },
];
const next = nextDatedCollectFollowup(evidence, new Set());
assert.notEqual(next?.domain, "education");
assert.notEqual(next?.domain, "career");
assert.notEqual(next?.domain, "relocation");
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /recordV10EvidenceBatch/);
assert.match(tools, /result\.acceptedCount > 0[\s\S]*autoRescoreAfterEvidenceChange/);
});
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import {
buildRectificationTimeline,
formatClockMinutes,
@@ -86,7 +87,8 @@ test("the band is placed against the axis and never escapes it", () => {
assert.equal(Math.round(view.bandWidthPercent), 7);
assert.ok(view.bandStartPercent + view.bandWidthPercent <= 100);
assert.equal(view.rangeLabel, "05:0705:09");
assert.equal(view.widthLabel, "2 分钟");
// Inclusive of both ends: 05:07, 05:08, 05:09. Exclusive difference was "2 分钟".
assert.equal(view.widthLabel, "3 分钟");
// A range reported wider than the window is clamped rather than overflowing.
const overflowing = buildRectificationTimeline({
@@ -100,7 +102,33 @@ test("the band is placed against the axis and never escapes it", () => {
assert.equal(overflowing.bandWidthPercent, 100);
});
test("candidate marks are binary and the range boundary counts as inside", () => {
test("candidate marks follow inference status, not band position", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:07", "05:08", "05:09"],
inferenceMarks: [
{ time: "05:06", eliminated: true },
{ time: "05:07", eliminated: false },
{ time: "05:08", eliminated: true },
{ time: "05:09", eliminated: false },
{ time: "05:10", eliminated: true },
],
stage: "minute",
});
assert.ok(view);
// 05:08 sits inside the band but is eliminated → hollow. Position vs band is not the judge.
assert.deepEqual(view.marks.map((mark) => [mark.key, mark.state]), [
["m306", "out"],
["m307", "in"],
["m308", "out"],
["m309", "in"],
["m310", "out"],
]);
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
});
test("without inference marks every engine candidate is solid", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
@@ -108,12 +136,7 @@ test("candidate marks are binary and the range boundary counts as inside", () =>
stage: "minute",
});
assert.ok(view);
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "in", "in", "out"]);
// Closed interval: both ends of the range are in, not on the fence.
assert.equal(view.marks[1]?.state, "in");
assert.equal(view.marks[3]?.state, "in");
// No third state exists to grade confidence with.
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in", "in", "in"]);
});
test("marks outside the window are dropped and duplicates collapse", () => {
@@ -135,11 +158,11 @@ test("block scan draws the window as the range and no minute marks", () => {
stage: "block_scan",
});
assert.ok(view);
// Saying "23 小时 59 分" here is the truth: nothing has been ruled out yet.
// Inclusive 00:0023:59 is a full day. Exclusive difference was "23 小时 59 分".
assert.equal(view.bandStartPercent, 0);
assert.equal(view.bandWidthPercent, 100);
assert.equal(view.rangeLabel, "00:0023:59");
assert.equal(view.widthLabel, "23 小时 59 分");
assert.equal(view.widthLabel, "24 小时");
assert.deepEqual(view.marks, []);
});
@@ -176,7 +199,8 @@ test("widening the window rescales the axis and keeps the band inside it", () =>
// The widened range still fits: an axis pinned to the opening window would not hold it.
assert.ok(after.bandStartPercent >= 0);
assert.ok(after.bandStartPercent + after.bandWidthPercent <= 100);
assert.equal(after.widthLabel, "24 分钟");
// 04:5605:20 inclusive is 25 minutes. Exclusive difference was "24 分钟".
assert.equal(after.widthLabel, "25 分钟");
});
test("a window that crosses midnight stays monotonic", () => {
@@ -191,11 +215,126 @@ test("a window that crosses midnight stays monotonic", () => {
assert.equal(view.axisStartLabel, "23:00");
assert.equal(view.axisEndLabel, "03:59");
assert.equal(view.rangeLabel, "00:3001:30");
assert.equal(view.widthLabel, "1 小时");
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "out"]);
// Inclusive 00:3001:30 is 61 minutes. Exclusive difference was "1 小时".
assert.equal(view.widthLabel, "1 小时 1 分");
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in"]);
assert.ok(view.marks.every((mark) => mark.percent >= 0 && mark.percent <= 100));
});
test("widthLabel counts both clock ends, matching the delivery-report inclusive minute shape", () => {
const label = (
searchWindow: readonly [string, string],
credibleRange: readonly [string, string] | null,
) => buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: [],
stage: credibleRange ? "minute" : "block_scan",
})?.widthLabel;
assert.equal(label(["04:45", "05:15"], ["04:51", "04:59"]), "9 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:09"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["04:51", "04:53"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:07"]), "1 分钟");
assert.equal(label(["00:00", "23:59"], null), "24 小时");
});
test("eliminated inference minutes stay as hollow dots and keep stable keys", () => {
const searchWindow = ["04:45", "05:15"] as const;
const credibleRange = ["04:51", "04:53"] as const;
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const marksFor = (active: ReadonlySet<string>) => times.map((time) => ({
time,
eliminated: !active.has(time),
}));
const first = buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: ["04:51", "04:53"],
inferenceMarks: marksFor(new Set(["04:51", "04:53"])),
stage: "minute",
});
assert.ok(first);
assert.equal(first.marks.length, 9);
assert.equal(first.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(first.marks.filter((mark) => mark.state === "in").length, 2);
const firstKeys = first.marks.map((mark) => mark.key);
const second = buildRectificationTimeline({
searchWindow,
credibleRange: ["04:53", "04:53"],
candidateTimes: ["04:53"],
inferenceMarks: marksFor(new Set(["04:53"])),
stage: "minute",
});
assert.ok(second);
assert.equal(second.marks.length, 9);
assert.equal(second.marks.filter((mark) => mark.state === "out").length, 8);
assert.equal(second.marks.filter((mark) => mark.state === "in").length, 1);
assert.deepEqual(second.marks.map((mark) => mark.key), firstKeys);
});
test("timeline chat wiring reads parsed inferenceMarks and never the raw receipt", () => {
const timelineBlock = chat.slice(
chat.indexOf("const timelineView = buildRectificationTimeline"),
chat.indexOf("const persistedOfferKey"),
);
assert.match(timelineBlock, /inferenceMarks:\s*candidateResult\?\.inferenceMarks/);
assert.doesNotMatch(timelineBlock, /decisionReceipt|inference_state/);
});
test("parsed inference_state marks reach the timeline even when engine candidates are only the active minutes", () => {
const activeId = "88888888-8888-4888-8888-888888888881";
const secondId = "88888888-8888-4888-8888-888888888882";
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const result = parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: activeId, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 },
{ candidateId: secondId, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 },
],
overallConfidence: "low",
selectionAllowed: false,
canAdopt: false,
confirmationAllowed: false,
representativeTime: "04:53",
selectedTime: null,
selectionKind: null,
credibleRange: ["04:51", "04:53"],
decisionReceipt: {
inference_state: {
candidates: times.map((time) => ({
time,
status: time === "04:51" || time === "04:53" ? "active" : "eliminated",
})),
},
},
});
assert.ok(result);
assert.ok(result.inferenceMarks);
assert.equal(result.candidates.length, 2);
assert.equal(result.inferenceMarks.length, 9);
assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7);
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: result.credibleRange,
candidateTimes: result.candidates.map((candidate) => candidate.time),
inferenceMarks: result.inferenceMarks,
stage: "minute",
});
assert.ok(view);
assert.equal(view.marks.length, 9);
assert.equal(view.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(view.marks.filter((mark) => mark.state === "in").length, 2);
assert.equal(view.widthLabel, "3 分钟");
});
test("duration reads as clock language, not raw minutes", () => {
assert.equal(timelineDurationLabel(2), "2 分钟");
assert.equal(timelineDurationLabel(59), "59 分钟");
+75 -9
View File
@@ -19,11 +19,17 @@ import {
createRectificationV9ReadOnlyTools,
createRectificationV9Tools,
} from "../src/mastra/rectification-v9-tools.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { buildOpeningBrief, runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { parseV9CaseDossier, persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
GENERIC_COLLECT_QUESTION,
OPENING_COLLECT_DOMAINS,
RECTIFICATION_USER_COPY,
isAcceptableOpeningBody,
openingSpokenBody,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
@@ -68,7 +74,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /思考用简体中文写在思维链/);
assert.match(prompt, /对用户说的话必须自己写在正文里/);
assert.match(prompt, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(prompt, /每轮正文 2-4 句/);
// 旧:每轮正文 2-4 句 → 新:证据轮正文只写一句复述 → BUG-606 决策 3
assert.match(prompt, /证据轮正文只写一句复述/);
assert.match(prompt, /「先这样」由服务器/);
assert.match(prompt, /collection_progress/);
assert.match(prompt, /不得写「范围在收窄」/);
@@ -88,11 +95,11 @@ test("system prompt carries only high-priority boundaries, never the method copy
test("agent pins the dedicated rectification skill and its fixed version", () => {
assert.equal(RECTIFICATION_V9_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(basename(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_SKILL_NAME);
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.18"));
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.19"));
assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("step budgets are bounded per action with a hard ceiling", () => {
@@ -239,7 +246,13 @@ test("agent receives the exact server-owned case id for tool calls", async () =>
const openingPrompt = JSON.stringify(observedMessages);
assert.match(openingPrompt, new RegExp(CASE_ID));
assert.match(openingPrompt, /服务端 opening brief/);
assert.match(openingPrompt, /不要要求一次说完/);
// 旧:不要要求一次说完、不要举大学/工作/搬家的例子
// 新:窗口 + 六类领域 + 一条消息可以报多件
// 原因:BUG-604 决策 1,重算按轮计费
assert.match(openingPrompt, /一条消息可以报多件/);
assert.match(openingPrompt, /升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤/);
assert.match(openingPrompt, /04:5005:10|当前搜索窗口/);
assert.match(openingPrompt, /intake 声明的不确定档/);
assert.match(openingPrompt, /spokenPrompt 写出当前采集题/);
assert.match(openingPrompt, /不要提问/);
assert.doesNotMatch(openingPrompt, /接在正文末尾并写入聊天历史/);
@@ -392,13 +405,17 @@ test("collect_spoken stem is persisted on the same turn via asked_turn_id, not a
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, greeting);
// 旧:模型一句招呼原样落库 → 新:开场正文必须过六类领域验收,不合格则换成模板
// 原因:BUG-604 开场 ≤4 句、≥5 类、不含年份
const spoken = openingSpokenBody(["04:50", "05:10"]);
assert.equal(isAcceptableOpeningBody(result.answerText), true);
assert.equal(result.answerText, spoken);
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
assert.notEqual(replaced?.text, composeCollectSpokenAssistantText(greeting, GENERIC_COLLECT_QUESTION));
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(finalize?.args.p_assistant_message, greeting);
assert.equal(finalize?.args.p_assistant_message, spoken);
assert.equal(
accounting.calls.some((call) => (
call.fn === "set_agentic_rectification_conversation_focus"
@@ -785,3 +802,52 @@ test("agent-run uses collectHandoff when the owned-focus body is only a question
assert.equal(result.ok, true);
assert.equal(result.answerText, RECTIFICATION_USER_COPY.collectHandoff);
});
test("buildOpeningBrief names the search window, intake source, and six domains", () => {
const dossier = parseV9CaseDossier(dossierFixture({
candidateRange: { start_time: "04:45", end_time: "05:15" },
evidence: [],
evidenceCount: 0,
}));
assert.ok(dossier);
const brief = buildOpeningBrief(dossier);
assert.match(brief, /当前搜索窗口:04:4505:15/);
assert.match(brief, /intake 声明的不确定档/);
for (const domain of OPENING_COLLECT_DOMAINS) {
assert.match(brief, new RegExp(domain));
}
assert.match(brief, /先说你最容易想起的一两件,年月大概就行/);
assert.doesNotMatch(brief, /不要要求一次说完/);
assert.doesNotMatch(brief, /不要举大学、工作、搬家的例子/);
assert.doesNotMatch(brief, /(?:19|20)\d{2}/);
});
test("evidence persist keeps the first sentence when the model writes three", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = runOptions({
action: "evidence",
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", {
text: "记下了:2016 年 9 月入学、2020 年 6 月毕业。这对校正很有帮助。接下来我们继续。",
}),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.match(result.answerText, /^记下了:2016 年 9 月入学、2020 年 6 月毕业。/);
assert.doesNotMatch(result.answerText, /很有帮助|很有价值|很有分量|特别有用/);
assert.doesNotMatch(result.answerText, /接下来我们继续/);
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.match(String(finalize?.args.p_assistant_message ?? ""), /^记下了:2016 年 9 月入学、2020 年 6 月毕业。/);
assert.doesNotMatch(String(finalize?.args.p_assistant_message ?? ""), /很有帮助|特别有用/);
});
@@ -95,9 +95,9 @@ test("terminal transitions are one-way and evidence writes stop at terminal", ()
test("the active rectification skill pins the v10 identity and lives in the right directory", () => {
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
assert.match(skill, /^version: 10\.0\.18$/m);
assert.match(skill, /^version: 10\.0\.19$/m);
assert.match(skill, /至多一个主问题且唯一来源:[\s\S]*不得自行提出、复述、改写或预告问题/);
for (const reference of references) {
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
@@ -209,7 +209,7 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
session_id: SESSION_ID,
status: "draft",
should_start_opening: true,
skill_version: "10.0.18",
skill_version: "10.0.19",
};
}
return null;
@@ -247,11 +247,11 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
});
assert.equal(response.disposition, "created");
assert.equal(response.shouldStartOpening, true);
assert.equal(response.skillVersion, "10.0.18");
assert.equal(response.skillVersion, "10.0.19");
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2");
assert.ok(openCall);
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
assert.equal(openCall.args.p_skill_version, "10.0.18");
assert.equal(openCall.args.p_skill_version, "10.0.19");
assert.equal(openCall.args.p_user_id, "user-1");
// The server derives the baseline; the request never carries it from the browser.
assert.equal("birth_date" in openCall.args, false);
+15 -10
View File
@@ -1404,11 +1404,14 @@ test("Chinese interview planning stays on reasoning-delta; the spoken answer is
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, spoken);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: spoken }],
);
// 旧:模型正文含追问原样落库 → 新:证据轮超过两句只留首句
// 原因:BUG-606 服务端裁剪,不靠提示词
const recap = "职业类型已经记下。";
assert.equal(result.answerText, recap);
const spokenDeltas = emitted.filter((event) => event.type === "answer.delta");
assert.equal(spokenDeltas[0]?.text, spoken);
assert.equal(spokenDeltas.at(-1)?.text, recap);
assert.equal((spokenDeltas.at(-1) as { replace?: boolean } | undefined)?.replace, true);
const publicText = JSON.stringify(emitted);
assert.doesNotMatch(publicText, /occupation_note/);
assert.doesNotMatch(publicText, /让我继续访谈/);
@@ -1575,11 +1578,13 @@ test("model terminal text-delta is the reply even when Case evidence is availabl
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, spoken);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: spoken }],
);
// 旧:模型正文含入职追问原样落库 → 新:证据轮超过两句只留首句
// 原因:BUG-606 服务端裁剪
const recap = "职业已经记下。";
assert.equal(result.answerText, recap);
const spokenDeltas = emitted.filter((event) => event.type === "answer.delta");
assert.equal(spokenDeltas[0]?.text, spoken);
assert.equal(spokenDeltas.at(-1)?.text, recap);
assert.doesNotMatch(result.answerText, /已经记下:/);
assert.doesNotMatch(result.answerText, /2016年9月离家去北京开始工作/);
});
@@ -379,3 +379,21 @@ test("choice option focus ring stays inside the button and does not stack on the
assert.doesNotMatch(card, /firstChoiceRef/);
assert.doesNotMatch(card, /\.focus\(/);
});
test("vargaSentence stays in expanded activity rows, not the spoken bubble", () => {
const content = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8");
const activity = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
const timeline = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8");
const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
// 旧:ChatMessageContent 渲染 message-varga-sentence
// 新:气泡正文不再带方法句;活动记录展开区保留
// 原因:BUG-606 决策 3(a)
assert.doesNotMatch(content, /vargaSentence/);
assert.doesNotMatch(content, /message-varga-sentence/);
assert.match(activity, /vargaSentence/);
assert.match(activity, /VargaTraceStep/);
assert.match(timeline, /vargaSentence/);
assert.match(timeline, /inert=\{open \? undefined : true\}/);
assert.match(row, /<ConsultationRunTimeline[\s\S]*vargaSentence=\{vargaSentence\}/);
assert.doesNotMatch(row, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
});
@@ -417,7 +417,9 @@ test("agent instructions do not assert that the next question is already on scre
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(agent, /正文不得断言界面当前状态/);
assert.match(agent, /接下来我们继续/);
// 旧:用「接下来我们继续」垫句 → 新:题干作为同一条消息下一段出现,不再垫过渡句
// 原因:BUG-606 每轮一句,开场/证据轮不再写「接下来我们继续」
assert.match(agent, /题干会作为同一条消息的下一段自动出现/);
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
assert.match(chat, /await loadCaseSnapshot\(\)/);
assert.match(chat, /mergeTurnQuestions/);
+2 -2
View File
@@ -85,8 +85,8 @@ test("checked-in registry verifies hashed product packages and leaves consult on
[
{
name: "jyotish-birth-time-rectification",
version: "10.0.18",
sha256: "ad209a7824d7eb65f35efd7d6245e9a3752a8b32972de0fbafb751691e86a1ad",
version: "10.0.19",
sha256: "a68885d3cf2110ee456bff65210c60e6bef4da4ea45e2a790a5462fd6d7307c8",
},
{
name: "jyotish-personal-report",