fix(web): hold reverse-inference cards until acceptance event quality
Independent Staging Quality Gate / validate (push) Successful in 22m10s
Independent Staging Quality Gate / publish (push) Failing after 7m28s

Conflict probes were jumping after one dated event, so the interview asked
another domain before method collection. Spoken replies now follow the
stamped choice prompt instead of a topic denylist.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-25 23:53:42 +08:00
parent 37c5c8d356
commit 97b02aef5d
10 changed files with 346 additions and 59 deletions
+23 -7
View File
@@ -5318,8 +5318,8 @@
- 根因:第一件带日期事件之后下一问固定走感情 → 事业 → 家人 → 职业。精度阶段/分盘差异问句不挡出牌,`session_outcome=adopt_representative` 时把它们放进 `deferred_followup`。探针年份锁在 choice_frame 里,但问句轮不到。
- 修复:已有带日期事件且仍有大运冲突探针时,`source=event_probe` 先问该年前事并挡住出牌。A 写入并重算以筛窗。年龄带探针不插队。空账本仍只自然语言收集。不打开唯一分钟确认门。折进未提交的 Skill 10.0.11。
- 验证:`frontend/tests/rectification-eight-method.test.ts` 锁定教育事件 + 事业激活探针先问 2018 年入职且 `session_outcome` 保持收集;年龄带探针不插在感情收集前。
- 防复发:大运冲突探针在采用前必须挡住出牌。不得在空账本挂 `choice_frame`。不得把 `confirmation_allowed` 改成 true。
- 相关记录:BUG-348、BUG-350
- 防复发:大运冲突探针在采用前必须挡住出牌。不得在空账本挂 `choice_frame`。不得把 `confirmation_allowed` 改成 true。不得在采用门事件/领域未齐时插队(见 BUG-386)。
- 相关记录:BUG-348、BUG-350、BUG-386
- 复发自:BUG-348(收集后再区分;区分问句被方法轮询和出牌 defer 排到采用之后)
- 修复版本:9873ba42
@@ -5600,13 +5600,13 @@
- 状态:resolved
- 首次发现:2026-08-25
- 最近更新:2026-08-25
- 影响面:`rectification-record-evidence-batch``projectTurnDecision``persistServerOwnedFocus`生时纠正系统提示
- 影响面:`rectification-record-evidence-batch``projectTurnDecision``persistServerOwnedFocus``bindSpokenToOpenQuestion`、生时纠正运行器
- 用户现象:刚记下大学毕业后,气泡问「2016 年前后入学考试有没有发挥失常」,点选卡却是「2023 年前后有没有入职或职责加重」。题干和选项是服务器模板,没有跟口语对齐。
- 触发条件:账本已有带日期学业事件;引擎发出 dasha 冲突探针并盖戳点选卡;本轮只调了 `record-evidence-batch`,没有 `compare-candidates`
- 根因:(1) 证据写入后的自动重算会 `persistServerOwnedFocus`,但 batch 工具结果没有把 `open_question.prompt` 回给模型。(2) `turn_decision.current_question` 只有 id,没有题干。模型按上一轮学业对话另起了考试质量问,界面 GET 却展示已盖戳的事业卡。(3) 选题必须用刚算完的 decision receipt;若只信 persist RPC 回包,探针可能被丢掉。点选卡仍由服务器出,保证点选直接改后验,这不是本次缺陷
- 修复:batch / confirm 重算后返回 `open_question.prompt`;选题用刚写入的 receipt(含探针)。`current_question` 带上同一题干。系统提示要求正文只问这一句,不得另起高考发挥。不改 Skill `10.0.11`
- 验证:`frontend/tests/rectification-eight-method.test.ts``frontend/tests/rectification-answer-choice.test.ts``frontend/tests/rectification-v9-agent.test.ts`
- 防复发:有点选卡时口语必须是 `open_question.prompt` / `current_question.prompt`,不得另问一层。证据写入后的工具结果必须带回已持久化题干。不得改已哈希 Skill `10.0.11`
- 根因:(1) 证据写入后的自动重算会 `persistServerOwnedFocus`,但 batch 工具结果没有把 `open_question.prompt` 回给模型。(2) `turn_decision.current_question` 只有 id,没有题干。模型按上一轮学业对话另起了考试质量问,界面 GET 却展示已盖戳的事业卡。(3) 选题必须用刚算完的 decision receipt;若只信 persist RPC 回包,探针可能被丢掉。(4) 用系统提示逐条禁止高考发挥/入学年份堵不住用户回答的多种写法。点选卡仍由服务器出,保证点选直接改后验。
- 修复:batch / confirm 重算后返回 `open_question.prompt`;选题用刚写入的 receipt。有已盖戳题干时,运行器丢掉口语里的其它问句并接到这句题干,不在提示词里枚举禁止主题。不改 Skill `10.0.11`
- 验证:`frontend/tests/rectification-eight-method.test.ts``frontend/tests/rectification-answer-choice.test.ts``frontend/tests/rectification-v9-agent.test.ts``frontend/tests/rectification-v9-stream.test.ts`
- 防复发:有点选卡时口语由运行器接到 `open_question.prompt` / `current_question.prompt`,不得靠提示词枚举禁止追问。证据写入后的工具结果必须带回已持久化题干。不得改已哈希 Skill `10.0.11`
- 相关记录:BUG-375、BUG-379、BUG-382
- 复发自:BUG-375(点选卡已由服务器盖戳,但模型仍按探针列表另写一问)
- 修复版本:待发布
@@ -5627,6 +5627,22 @@
- 复发自:BUG-381(避让点选卡时把居中改成了靠右)
- 修复版本:待发布
## BUG-386 | 采用门未齐就按冲突时间出反推前事点选卡
- 状态:resolved
- 首次发现:2026-08-25
- 最近更新:2026-08-25
- 影响面:`method_followup_plan``meetsAcceptanceEventQuality`、生时纠正系统提示
- 用户现象:刚记下学业等一两件带日期经历后,点选卡就按 dasha 冲突年反问另一领域「某年前后有没有入职」。方法层感情/事业还没问完,采用门也还没到 3 件可评分事件、2 个领域。
- 触发条件:账本已有至少一件可评分事件,但不足 3 件或不足 2 个领域;decision receipt 含 `dasha_activation` / `dasha_boundary` 探针。
- 根因:BUG-351 在第一件带日期事件之后就让冲突探针插队并盖戳点选卡。引擎采用门仍要 3 件/2 领域,访谈却提前进入反推前事。
- 修复:冲突探针插队改到与采用门相同的事件质量门槛:至少 3 件主评分事件、至少 2 个领域。`occupation_note` 与外貌/疤痕/占问不计。未齐时继续感情→事业→家人→职业自然语言收集,不盖冲突卡。齐了之后仍按 BUG-351 插队并挡出牌。不改 Skill `10.0.11`,不打开 `confirmation_allowed`
- 验证:`frontend/tests/rectification-eight-method.test.ts``frontend/tests/rectification-v9-agent.test.ts``frontend/tests/rectification-agentic-entry.test.ts`
- 防复发:不得在采用门事件/领域未齐时把 dasha 冲突探针写成 `source=event_probe` 或挂 `choice_frame`。不得把 `occupation_note` 算进 3 件。不得改已哈希 Skill `10.0.11`
- 相关记录:BUG-351、BUG-361、BUG-379
- 复发自:BUG-351(一件带日期事件后冲突探针插队,早于采用门)
- 修复版本:待发布
## BUG-379 | 生时纠正已记入学后仍编造高考年并再问入学
- 状态:resolved
@@ -44,7 +44,12 @@ import {
createStepAnswerState,
flushStepAnswerOnStreamFinish,
} from "./step-answer";
import { composeRectificationTurnNarration, publicNarrationDtoFromDossier } from "./turn-narration";
import {
bindSpokenToOpenQuestion,
composeRectificationTurnNarration,
openQuestionPromptFromToolResult,
publicNarrationDtoFromDossier,
} from "./turn-narration";
import {
defaultMessageOrigin,
isRectificationMessageOrigin,
@@ -630,11 +635,16 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
const stepAnswer = createStepAnswerState();
let persistedPrompt: string | null = null;
const heldSpoken: string[] = [];
const publishSpokenStep = async (pieces: readonly string[]) => {
// The model's terminal text-delta is the user-visible reply. Do not
// regex-split it, and do not replace it with Case narration.
const spoken = pieces.join("").trim();
if (!spoken || !caseLoaded) return;
if (persistedPrompt) {
heldSpoken.push(spoken);
return;
}
answerText += spoken;
answerDeltas.push(spoken);
await emit({ type: "answer.delta", text: spoken });
@@ -663,6 +673,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
const stampedPrompt = openQuestionPromptFromToolResult(chunk);
if (stampedPrompt) persistedPrompt = stampedPrompt;
const stepEffect = applyStepAnswerChunk(
stepAnswer,
chunk,
@@ -770,7 +783,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (mapped === "max_steps" || mapped === "provider_error") {
return failedAttempt(attemptId, mapped);
}
if (!answerText.trim()) {
if (!persistedPrompt) {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
persistedPrompt = publicNarrationDtoFromDossier(latest).nextQuestion;
} catch {
// Keep whatever prompt the tool result already stamped.
}
}
if (persistedPrompt) {
const bound = bindSpokenToOpenQuestion(heldSpoken.join("") || answerText, persistedPrompt);
if (bound.trim()) {
answerText = bound;
answerDeltas.push(bound);
await emit({ type: "answer.delta", text: bound });
}
} else if (!answerText.trim()) {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
const narration = composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
@@ -204,10 +204,70 @@ export const BACKGROUND_ONLY_KINDS: ReadonlySet<EvidenceKind> = new Set([
"horary_query",
]);
/** Notes that may cover a method layer but do not count as primary scoring events. */
export const AUXILIARY_EVIDENCE_KINDS: ReadonlySet<EvidenceKind> = new Set([
"appearance_note",
"birthmark_or_scar",
"occupation_note",
]);
export const NON_PRIMARY_SCORING_DOMAINS: ReadonlySet<string> = new Set([
"appearance",
"marks",
"occupation",
"horary",
"other",
]);
/** Same floors as `scripts/rectification/decision_policy.py`. */
export const MIN_ACCEPTANCE_EVENTS = 3;
export const MIN_ACCEPTANCE_DOMAINS = 2;
export function isBackgroundEvidenceKind(kind: EvidenceKind): boolean {
return BACKGROUND_ONLY_KINDS.has(kind);
}
export function isPrimaryScoreableEvidence(item: {
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
}): boolean {
if (item.status !== "confirmed") return false;
if (item.datePrecision === "unknown") return false;
if (!item.occurredFrom && !item.occurredTo) return false;
if (NON_PRIMARY_SCORING_DOMAINS.has(item.domain)) return false;
const kind = item.eventKind;
if (
kind === "other"
|| kind === "horary_query"
|| kind === "appearance_note"
|| kind === "birthmark_or_scar"
|| kind === "occupation_note"
) {
return false;
}
return true;
}
/** Reverse-inference / conflict probes wait until the engine could accept. */
export function meetsAcceptanceEventQuality(
evidence: readonly Readonly<{
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
}>[],
): boolean {
const scoreable = evidence.filter(isPrimaryScoreableEvidence);
const domains = new Set(scoreable.map((item) => item.domain));
return scoreable.length >= MIN_ACCEPTANCE_EVENTS && domains.size >= MIN_ACCEPTANCE_DOMAINS;
}
/** Ledger/engine subject follows the domain. Family events must not stay on the tool default `self`. */
export function evidenceSubjectForDomain(
domain: string,
@@ -24,9 +24,10 @@
* Appearance and marks are skipped_by_policy. Horary does not block offering
* time cards. Occupation does block cards until a note exists.
* Method coverage asks for dated events in natural language.
* After the first dated event, remaining dasha conflict probes
* (year/activation differences) are asked before more method rotation
* and they block offering time cards so the window can be filtered.
* Dasha conflict probes wait until acceptance event quality
* (3 primary scoreable events in 2 domains), then jump ahead of
* remaining method rotation and block offering time cards so the
* window can be filtered.
* Once blocking methods are covered, move into candidate discrimination.
* Coverage complete never means adopt. Horary does not block cards.
* A/B/C/D choice frames attach only when candidates already diverge
@@ -52,6 +53,7 @@ import {
type CandidateDiscriminatorProbe,
} from "../core/candidate-contrast-packet.ts";
import type { SessionOutcomeKind } from "./confirmation-gate.ts";
import { meetsAcceptanceEventQuality } from "./evidence-model";
import type {
DiscriminatingEventProbe,
NakshatraBoundary,
@@ -764,7 +766,7 @@ export function buildMethodFollowupPlan(input: {
...(input.askedProbeKeys ?? []),
...askedKeysFromLedgerEvidence(input.evidence),
]);
const conflictProbe = dashaCovered
const conflictProbe = dashaCovered && meetsAcceptanceEventQuality(input.evidence)
? remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys)[0] ?? null
: null;
if (!dashaCovered) {
@@ -30,3 +30,47 @@ export function composeRectificationTurnNarration(dto: RectificationNarrationDto
}
return parts.join("");
}
function promptFromQuestionField(value: unknown): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const prompt = (value as { prompt?: unknown }).prompt;
if (typeof prompt !== "string") return null;
const text = prompt.trim();
return text.length > 0 ? text : null;
}
export function readOpenQuestionPrompt(value: unknown): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
return promptFromQuestionField(row.open_question)
?? promptFromQuestionField(row.current_question);
}
export function openQuestionPromptFromToolResult(chunk: {
type?: string;
payload?: { result?: unknown; output?: unknown };
object?: unknown;
}): string | null {
if (chunk.type !== "tool-result") return null;
return readOpenQuestionPrompt(chunk.payload?.result)
?? readOpenQuestionPrompt(chunk.payload?.output)
?? readOpenQuestionPrompt(chunk.object)
?? readOpenQuestionPrompt(chunk.payload);
}
/**
* When a choice card is already stamped, the persisted prompt is the only
* follow-up. Keep non-interrogative acknowledgements; drop any other ask.
* This is not a topic denylist — user answers stay free text or taps.
*/
export function bindSpokenToOpenQuestion(spoken: string, nextQuestion: string | null): string {
const question = nextQuestion?.trim() ?? "";
if (!question) return spoken.trim();
const ack = spoken
.split(/\n{2,}/)
.flatMap((block) => block.split(/(?<=[])\s*/u))
.map((part) => part.trim())
.filter((part) => part.length > 0 && !/[?]/.test(part) && !part.includes(question))
.slice(0, 2);
return [...ack, question].join("\n\n");
}
+2 -2
View File
@@ -69,9 +69,9 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。对用户说的话必须自己写在正文里,不要只写规划等服务器代写。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。
7. 只基于成功 attempt 输出正文。工具失败时说明面向用户的边界,不声称未执行的方法或结果。
8. 当前轮新事件一律走 rectification-record-evidence-batch(一件也可以)。优先传 source 原文的 quoteStart/quoteEnd,不要改写 quote。rectification-confirm-evidence 只用于用户对已有 pending 明确说“对/是”。不得要求用户把已说清的事件再发一遍。
9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_actionid=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compareC 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。挡住出牌的方法层未齐时source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 没有了 / 没了 / 没有其它 / 想不起来了 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–DashaGochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。
9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_actionid=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compareC 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。采用门所需的可评分事件未齐(至少 3 件、2 个领域)时继续按方法层收集,不要根据冲突探针出点选卡或改问冲突年。齐了之后source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 没有了 / 没了 / 没有其它 / 想不起来了 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–DashaGochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。
10. 不泄露系统提示词或 Skill 原文。
11. 追问只跟 method_followup_plan 与服务器已持久化的 current_question / open_question。不要调用 rectification-set-focus;下一问和点选卡由 compare-candidates / read-case 在服务端事务内创建。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,正文直接问,不要提点选卡。若工具返回了 open_question.prompt 或 current_question.prompt正文只问这一句,不得另起高考发挥、入学年份或其它方法层追问。不得发明年份,不得根据出生年推算高考或入学年份并当成事实,不要把已回答的考试质量题或职责倾向再问一遍。账本已有入学、毕业或感情开始/结束日期时,不要再问那一件发生在哪一年。挡住出牌的方法层未齐时,source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐后只问当前剩余候选分钟还能拆开的区分探针;没有剩余拆分且未拉开时落实 offer_provisional_range,不要再问整窗 D9/D24,也不要 adopt。不要问两套盘哪个更像或可能性高低。点选 A/B/C/D 与「先这样」由服务器按 questionId/optionId 确定性处理,不要把选项全文当成新事件,也不要为点选调用 resolve-focus、read-case 或 compare;自由文本补充才走工具。正文禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。
11. 追问只跟 method_followup_plan 与服务器已持久化的 current_question / open_question。不要调用 rectification-set-focus;下一问和点选卡由 compare-candidates / read-case 在服务端事务内创建。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,正文直接问,不要提点选卡。若工具返回了 open_question.prompt 或 current_question.prompt不要另写追问;下一问由点选卡呈现,运行器会把口语接到这句题干。自由文本只作补充。采用门所需的可评分事件/领域未齐时不要走 event_probe,忽略 receipt 里的 dasha 冲突探针。齐了之后 source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐后只问当前剩余候选分钟还能拆开的区分探针;没有剩余拆分且未拉开时落实 offer_provisional_range,不要再问整窗 D9/D24,也不要 adopt。不要问两套盘哪个更像或可能性高低。点选 A/B/C/D 与「先这样」由服务器按 questionId/optionId 确定性处理,不要把选项全文当成新事件,也不要为点选调用 resolve-focus、read-case 或 compare;自由文本补充才走工具。正文禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。
12. 证据有效变化后由服务器重算候选。不要等用户说“没有更多了”才比较,也不要对同一证据指纹再 compare。分钟扫描只在服务端,结果只是候选或平台,不得宣布确认。
13. 落实 start_consultation:前事核对结束或用户先这样后,请用户用当前采用时间看盘;对不上同时请改选其他候选。解释事件–Dasha 账本、双轨是否一致、换升时刻、精度阶段、D9/D10 类型对照和相对支持时,仍必须说候选范围不是出生时间真值。`;
@@ -243,6 +243,8 @@ test("usage completes or releases without hiding settlement failures", () => {
assert.match(run, /thinkingTokens: 8_192/);
assert.match(run, /activity.changed/);
assert.match(run, /if \(!answerText\.trim\(\)\) \{[\s\S]*composeRectificationTurnNarration/);
assert.match(run, /bindSpokenToOpenQuestion/);
assert.match(run, /openQuestionPromptFromToolResult/);
assert.doesNotMatch(run, /splitRectificationSpokenAndThinking/);
assert.match(run, /emit\(event: PublicStreamEvent\): Promise<void> \| void;/);
assert.match(run, /agentGenerationSettings\(options\.generationModel, \{[\s\S]*thinking: "enabled"/);
@@ -637,6 +639,8 @@ test("the Agent prompt cannot offer candidates while asking for more evidence",
assert.match(agent, /id 不是 adopt_representative 时不得调用 rectification-offer-candidates/);
assert.match(agent, /verify_adopted_time/);
assert.match(agent, /event_probe/);
assert.match(agent, /至少 3 件/);
assert.match(agent, /2 个领域/);
assert.match(agent, /selection_allowed 只表示可以采用代表性时间/);
const tools = readFileSync(
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
+126 -38
View File
@@ -37,6 +37,8 @@ import {
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
const EDUCATION_ID = "44444444-4444-4444-8444-444444444441";
const RELATIONSHIP_ID = "44444444-4444-4444-8444-444444444442";
const FAMILY_ID = "44444444-4444-4444-8444-444444444443";
const UNIQUE_MINUTE_COPY = /±5 分钟确定性/;
const CLASSIC_COVERAGE = [
@@ -50,6 +52,39 @@ const CLASSIC_COVERAGE = [
{ status: "confirmed", domain: "horary", datePrecision: "day" as const, occurredFrom: "2024-01-01", occurredTo: null },
];
function datedEvidence(
domain: string,
year: string,
extra: {
eventKind?: string | null;
summary?: string | null;
datePrecision?: "year" | "month" | "day";
} = {},
) {
return {
status: "confirmed" as const,
domain,
datePrecision: extra.datePrecision ?? ("year" as const),
occurredFrom: `${year}-01-01`,
occurredTo: null,
...(extra.eventKind !== undefined ? { eventKind: extra.eventKind } : {}),
...(extra.summary !== undefined ? { summary: extra.summary } : {}),
};
}
const CAREER_CONFLICT_PROBE = {
year: 2018,
year_label: "2018 年前后",
domain: "career" as const,
event_family: "入职、升职或职责明显加重",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: "年份锁定 2018 年前后。请写成一句自然语言,问是否入职或职责加重。",
role: "reverse_verify" as const,
};
const ENGINE_SCORE = {
success: true,
endpoint: "rectification_v5_score",
@@ -113,6 +148,36 @@ const educationEvidence = {
created_at: "2026-08-12T10:00:06.000Z",
};
const relationshipEvidence = {
id: RELATIONSHIP_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relationship_start",
domain: "relationship",
occurred_from: "2018-01-01",
occurred_to: null,
date_precision: "year",
summary: "2018年一段感情开始",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:07.000Z",
};
const familyEvidence = {
id: FAMILY_ID,
source_turn_id: TURN_ID,
subject: "family",
event_kind: "family_event",
domain: "family",
occurred_from: "2020-01-01",
occurred_to: null,
date_precision: "year",
summary: "2020年家人相关变化",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:08.000Z",
};
function stubEngine(response: unknown) {
const previous = globalThis.fetch;
globalThis.fetch = (async () => ({
@@ -142,27 +207,25 @@ test("eight-method routing asks relationship after dated education, not relocati
assert.equal(plan.methods.find((item) => item.method_id === "appearance")?.status, "skipped_by_policy");
});
test("dated evidence plus dasha conflict probe asks that event before method rotation and blocks offer", () => {
test("dasha conflict probe does not jump ahead of method rotation before acceptance event quality", () => {
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
eventProbes: [{
year: 2018,
year_label: "2018 年前后",
domain: "career",
event_family: "入职、升职或职责明显加重",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。请写成一句自然语言,问是否入职或职责加重。",
role: "reverse_verify",
}],
evidence: [datedEvidence("education", "2016")],
eventProbes: [CAREER_CONFLICT_PROBE],
});
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
assert.notEqual(plan.next_followup?.source, "event_probe");
});
test("dasha conflict probe jumps after three scoreable events in two domains and blocks offer", () => {
const plan = buildMethodFollowupPlan({
evidence: [
datedEvidence("education", "2016"),
datedEvidence("education", "2020"),
datedEvidence("relationship", "2018"),
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
assert.equal(plan.next_followup?.source, "event_probe");
assert.equal(plan.next_followup?.intent, "distinguish_candidates");
@@ -181,6 +244,41 @@ test("dated evidence plus dasha conflict probe asks that event before method rot
}), "collect_evidence");
});
test("three scoreable events in one domain still rotate methods instead of reverse-inferring", () => {
const plan = buildMethodFollowupPlan({
evidence: [
datedEvidence("education", "2012"),
datedEvidence("education", "2016"),
datedEvidence("education", "2020"),
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
});
test("occupation_note does not count toward reverse-inference event quality", () => {
const plan = buildMethodFollowupPlan({
evidence: [
datedEvidence("education", "2016"),
datedEvidence("education", "2020"),
{
status: "confirmed",
domain: "occupation",
datePrecision: "unknown",
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(plan.next_followup?.choice_frame, null);
});
test("age-band probe does not jump ahead of uncovered relationship", () => {
const plan = buildMethodFollowupPlan({
evidence: [{
@@ -689,7 +787,8 @@ test("evidence batch returns the persisted choice prompt as open_question", asyn
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [educationEvidence],
evidence: [educationEvidence, relationshipEvidence, familyEvidence],
evidenceCount: 3,
latestResult: null,
}),
get_agentic_rectification_case_compute: () => computeFixture(),
@@ -1500,24 +1599,13 @@ test("没有了 is a user stop", () => {
test("same domain different year still asks a conflict probe", () => {
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2015-01-01",
occurredTo: null,
}],
evidence: [
datedEvidence("education", "2016"),
datedEvidence("relationship", "2018"),
datedEvidence("career", "2015"),
],
eventProbes: [{
year: 2018,
year_label: "2018 年前后",
domain: "career",
event_family: "入职、升职或职责明显加重",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。请写成一句自然语言,问是否入职或职责加重。",
role: "reverse_verify",
...CAREER_CONFLICT_PROBE,
information_gain: 0.21,
semantic_key: "career.2018.dasha_activation",
}],
@@ -79,14 +79,17 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /不要调用 rectification-set-focus/);
assert.match(prompt, /open_question\.prompt/);
assert.match(prompt, /current_question\.prompt/);
assert.match(prompt, /不得另起高考发挥/);
assert.match(prompt, /不要另写追问/);
assert.match(prompt, /运行器会把口语接到这句题干/);
assert.doesNotMatch(prompt, /不得另起高考发挥/);
assert.doesNotMatch(prompt, /不得根据出生年推算高考或入学年份/);
assert.doesNotMatch(prompt, /不要再问那一件发生在哪一年/);
assert.match(prompt, /「先这样」由服务器/);
assert.match(prompt, /盘外核对(不计分)/);
assert.match(prompt, /verify_adopted_time/);
assert.match(prompt, /event_probe/);
assert.match(prompt, /不得发明年份/);
assert.match(prompt, /不得根据出生年推算高考或入学年份/);
assert.match(prompt, /不要再问那一件发生在哪一年/);
assert.match(prompt, /至少 3 件/);
assert.match(prompt, /2 个领域/);
assert.doesNotMatch(prompt, /两套盘各自的前事/);
assert.doesNotMatch(prompt, /外貌、体质、胎记或疤痕可以问/);
assert.doesNotMatch(prompt, /分盘句和宫位表由界面展示/);
@@ -23,6 +23,10 @@ import {
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { messageContentHash } from "../src/lib/rectification-agentic/v9/message-origin.ts";
import { safeToolErrorCode } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
bindSpokenToOpenQuestion,
openQuestionPromptFromToolResult,
} from "../src/lib/rectification-agentic/v9/turn-narration.ts";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
@@ -1358,6 +1362,44 @@ test("Chinese interview planning stays on reasoning-delta; the spoken answer is
assert.equal(emitted.some((event) => event.type === "thinking.delta"), false);
});
test("persisted choice prompt replaces a competing model follow-up without a topic denylist", async () => {
const spoken = "好的,2020 年 6 月毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试,你当时发挥明显失常、或者压力特别大,有没有发生过?";
const prompt = "2023 年前后,有没有明显入职、升职或职责明显加重?";
assert.equal(
bindSpokenToOpenQuestion(spoken, prompt),
`好的,2020 年 6 月毕业这条也记下了。\n\n${prompt}`,
);
assert.equal(openQuestionPromptFromToolResult({
type: "tool-result",
payload: { result: { open_question: { prompt } } },
}), prompt);
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
}),
chunk("text-delta", { text: spoken }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, `好的,2020 年 6 月毕业这条也记下了。\n\n${prompt}`);
assert.doesNotMatch(result.answerText, /入学考试/);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: result.answerText }],
);
});
test("model terminal text-delta is the reply even when Case narration could be composed", async () => {
const spoken = "职业已经记下。你入职大概是哪一年?说个年份就行。";
const { options, emitted } = runOptions({