fix(rectification): remove static interview fallbacks
Independent Staging Quality Gate / validate (push) Successful in 10m50s
Independent Staging Quality Gate / publish (push) Successful in 13m29s

This commit is contained in:
Jesse_Chen
2026-08-27 16:40:47 +08:00
parent 5f855c610c
commit f3327235ea
43 changed files with 1798 additions and 509 deletions
@@ -116,7 +116,8 @@ test("failed server turns retain their receipt state without parsing activity fr
test("candidate acceptance copy never presents adoption as confirmation", () => {
assert.match(chatSource, /当前可能的出生时间/);
assert.match(chatSource, /采用不等于确认出生时间/);
assert.match(chatSource, /当前排盘时间(代表性时间,本会话不确认唯一分钟)/);
assert.match(chatSource, /rectification-candidate-badge">已采用/);
assert.match(chatSource, /已确认校正时间/);
assert.doesNotMatch(chatSource, /本轮校正已收口|当前排盘时间(代表性时间|本会话以代表性时间收口/);
assert.doesNotMatch(chatSource, /candidateResult\.confirmationAllowed \? "确认校正时间"/);
});
@@ -398,8 +398,8 @@ test("completed Agent replies restore feedback, copy and safe in-place regenerat
test("candidate state renders from the snapshot API and never from sentinels", () => {
assert.match(chat, /当前可能的出生时间/);
assert.match(chat, /本会话以代表性时间收口,不确认唯一分钟/);
assert.match(chat, /可以先采用,也可以继续补充事件或改选/);
assert.doesNotMatch(chat, /本会话以代表性时间收口,不确认唯一分钟/);
assert.doesNotMatch(chat, /当前排盘时间(代表性时间,本会话不确认唯一分钟)/);
assert.match(chat, /相对支持度不是统计概率/);
assert.match(chat, /改选为此时间/);
assert.doesNotMatch(chat, /disabled=\{Boolean\(candidateResult\.selectedTime\)/);
@@ -616,8 +616,12 @@ test("rectification Agent output stays natural and keeps tool execution silent",
assert.match(agent, /本轮做了什么/);
assert.match(agent, /完成凭证完全由服务端公开 Activity\/receipt 展示/);
assert.match(agent, /禁止只说记下了、会话会保留、以后再继续/);
assert.match(agent, /没有 \/ 没有了 \/ 没有其它 \/ 想不起来了.*只关闭当前 focus 或当前领域/);
assert.match(agent, /结束校正 \/ 不想继续 \/ 直接给结果 \/ 就到这里.*on_user_stop/);
assert.match(agent, /根据用户自然语言语义区分“当前问题没有证据”“停止整个证据收集”和“恢复继续校正”/);
assert.match(agent, /rectification-read-case 传 resume=true/);
assert.doesNotMatch(route, /action === "message" && caseStatus === "paused"/);
assert.match(agent, /前者调用 rectification-resolve-focus/);
assert.match(agent, /全局停止则调用 rectification-stop-and-review/);
assert.doesNotMatch(agent, /USER_STOP_PATTERN|USER_STOP_NEGATION_PATTERN/);
assert.match(agent, /skill_verification_report/);
assert.match(agent, /D9\/D10 类型对照/);
assert.doesNotMatch(agent, /分盘句和宫位表由界面展示/);
@@ -677,7 +681,7 @@ test("adopted time offers a consultation handoff without unique-minute copy", ()
assert.match(page, /startConsultationAfterRectification/);
assert.match(page, /createSession\(modelCatalog\.defaultModelId\)/);
assert.match(agent, /start_consultation/);
assert.match(agent, /本会话以代表性时间收口,不确认唯一分钟/);
assert.doesNotMatch(agent, /本会话以代表性时间收口|本轮校正已收口/);
});
test("stream failures remove empty assistant placeholders", () => {
@@ -187,7 +187,7 @@ test("adopted minute recasts the house table and keeps technique audit collapsed
technique_audit_table: [
{ technique: "D1 本命盘", status: "executed", note: "本轮已按该分钟重算本命宫位。" },
{ technique: "KP 宫头", status: "blocked", note: "KP 宫头本轮未计算。" },
{ technique: "唯一分钟确认", status: "blocked", note: "本会话以代表性时间收口,不确认唯一分钟。" },
{ technique: "唯一分钟确认", status: "blocked", note: "当前确认门未通过。" },
],
precision_stage: {
current: "d4_refine",
+119 -199
View File
@@ -17,12 +17,19 @@ import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
const DYNAMIC_STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" },
{ label: "发生过但程度较弱", answer_class: "weak_yes" },
{ label: "明确没有发生", answer_class: "no" },
{ label: "这段记不清楚", answer_class: "unsure" },
] as const;
const SAMPLE_COPY = {
prompt: "2016 年前后,有没有明显搬家、离乡或长期异地?",
option_a: "是,大概就在那段时间",
option_b: "有类似,但年份不对或不够重大",
option_c: "没有明显发生",
option_d: "不记得 / 不确定",
option_a: DYNAMIC_STYLE_OPTIONS[0].label,
option_b: DYNAMIC_STYLE_OPTIONS[1].label,
option_c: DYNAMIC_STYLE_OPTIONS[2].label,
option_d: DYNAMIC_STYLE_OPTIONS[3].label,
} as const;
const FOCUS_ID = "abababab-abab-4bab-8bab-abababababab";
@@ -44,6 +51,7 @@ const MOVE_PROBE: DiscriminatingEventProbe = {
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
style_options: DYNAMIC_STYLE_OPTIONS,
};
test("choice frames ask one biographical event from a server probe, not competing charts", () => {
@@ -70,12 +78,13 @@ test("choice frames ask one biographical event from a server probe, not competin
probes: [MOVE_PROBE],
},
);
assert.ok(frame);
assert.equal(frame.period, "2016 年前后");
assert.match(frame.why, /年份锁定/);
assert.match(frame.why, /请写成/);
assert.doesNotMatch(frame.why, /^2016 年前后,有没有明显/);
assert.match(frame.option_a_hint, /是,大概就在那段时间/);
assert.match(frame.option_b_hint, /年份不对或不够重大/);
assert.equal(frame.option_a_hint, DYNAMIC_STYLE_OPTIONS[0].label);
assert.equal(frame.option_b_hint, DYNAMIC_STYLE_OPTIONS[1].label);
assert.doesNotMatch(frame.option_a_hint + frame.option_b_hint, /更像哪一件|可能性/);
assert.doesNotMatch(frame.why, /更像哪一件/);
assert.equal(/外貌|疤痕|胎记/.test(frame.option_a_hint + frame.option_b_hint), false);
@@ -93,7 +102,9 @@ test("server-owned card names the event family, not a generic 有没有这件事
},
{ probes: [MOVE_PROBE] },
);
const copy = serverOwnedChoiceCopy(frame);
assert.ok(frame);
if (!frame) assert.fail("expected dynamic choice frame");
const copy = serverOwnedChoiceCopy(frame!);
assert.ok(copy);
assert.match(copy.prompt, /搬家、离乡或长期异地/);
assert.doesNotMatch(copy.prompt, /有没有这件事/);
@@ -116,6 +127,7 @@ test("the visible card uses Agent copy for A/B/C/D, not the server hint", () =>
}],
probes: [MOVE_PROBE],
});
assert.ok(frame);
const card = mergeChoiceCard(frame, SAMPLE_COPY);
assert.equal(card?.prompt, SAMPLE_COPY.prompt);
assert.equal(card?.options[0]?.label, SAMPLE_COPY.option_a);
@@ -133,14 +145,20 @@ test("holdout messages are prefixed and do not count as scoring quotes", () => {
domain: "family",
user_prompt_hint: "unused",
},
{ scoring: false },
{
scoring: false,
probes: [{
...MOVE_PROBE,
domain: "family",
event_family: "家人结婚、添丁或住院等重要变动",
user_meaning: "盘外核对:只核对家人事件,不计入候选评分。",
}],
},
);
assert.ok(frame);
const card = mergeChoiceCard(frame, {
...SAMPLE_COPY,
prompt: "家人这条线还没用过,有没有一件记得大概年份的变化?",
option_a: "是,大概就在那段时间",
option_b: "有类似,但年份不对或不够重大",
option_c: "没有明显发生",
option_d: "不记得 / 不确定",
});
assert.ok(card);
const message = choiceCardUserMessage(card, "A");
@@ -169,11 +187,13 @@ test("reverse-verify after adopt scores the predicted event and invites 改选 o
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。",
user_meaning: "按当前采用时间核对 2018 年前后的事业变化;若不吻合可以改选。",
role: "reverse_verify",
style_options: DYNAMIC_STYLE_OPTIONS,
}],
},
);
assert.ok(frame);
assert.equal(frame.period, "2018 年前后");
assert.equal(frame.scoring, true);
assert.match(frame.why, /按当前采用时间核对/);
@@ -198,60 +218,56 @@ test("life period only reuses years from the same domain", () => {
], "relocation"), "那段时间");
});
test("known-event quality probe uses the recorded year, not a discriminator split", () => {
const frame = buildChoiceFrame({
method_id: "d5_education",
ask_theme: "education_style",
domain: "education",
user_prompt_hint: "unused",
choice_kind: "event_quality",
}, {
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2015-06-01",
occurredTo: null,
}],
probes: [{
year: 2015,
year_label: "2015 年前后",
domain: "education",
event_family: "高考或重要考试发挥明显失常、压力很大",
source: "known_event_quality",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2015 年前后。已有相关经历。请按事件家族改写成自然语言。不得改年份。",
role: "clarify",
phase: "event_clarification",
choice_kind: "event_quality",
}],
});
assert.equal(frame.period, "2015 年前后");
assert.equal(frame.choice_kind, "event_quality");
assert.doesNotMatch(`${frame.why}${frame.option_a_hint}${frame.option_b_hint}`, /更像哪一件/);
});
test("age-band fallback uses birth date, not another event's year", () => {
const frame = buildChoiceFrame({
test("choice frame requires a real non-empty event family", () => {
const missingFamily = { ...MOVE_PROBE, event_family: undefined } as unknown as DiscriminatingEventProbe;
assert.equal(buildChoiceFrame({
method_id: "d4_home",
ask_theme: "home_change",
domain: "relocation",
user_prompt_hint: "unused",
}, {
birthDate: "1997-08-08",
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
});
assert.equal(frame.period, "2018 年前后");
assert.match(frame.option_a_hint, /是,大概就在那段时间/);
assert.doesNotMatch(frame.period, /2016/);
}, { probes: [missingFamily] }), null);
assert.equal(buildChoiceFrame({
method_id: "d4_home",
ask_theme: "home_change",
domain: "relocation",
user_prompt_hint: "unused",
}, { probes: [{ ...MOVE_PROBE, event_family: " " }] }), null);
});
test("choice frame rejects incomplete, duplicate-class, illegal, and duplicate-label style options", () => {
const build = (style_options: DiscriminatingEventProbe["style_options"]) => buildChoiceFrame({
method_id: "d4_home",
ask_theme: "home_change",
domain: "relocation",
user_prompt_hint: "unused",
}, { probes: [{ ...MOVE_PROBE, style_options }] });
assert.equal(build(DYNAMIC_STYLE_OPTIONS.slice(0, 3)), null);
assert.equal(build([
...DYNAMIC_STYLE_OPTIONS,
{ label: "另一种明确发生", answer_class: "yes" },
]), null);
assert.equal(build([
{ ...DYNAMIC_STYLE_OPTIONS[0], label: "外貌更接近第一种" },
...DYNAMIC_STYLE_OPTIONS.slice(1),
]), null);
assert.equal(build([
DYNAMIC_STYLE_OPTIONS[0],
{ ...DYNAMIC_STYLE_OPTIONS[1], label: DYNAMIC_STYLE_OPTIONS[0].label },
DYNAMIC_STYLE_OPTIONS[2],
DYNAMIC_STYLE_OPTIONS[3],
]), null);
});
test("style options without a real event probe do not generate a card", () => {
assert.equal(buildChoiceFrame({
method_id: "d10_career",
ask_theme: "career_style",
domain: "career",
user_prompt_hint: "unused",
choice_kind: "varga_style",
style_options: DYNAMIC_STYLE_OPTIONS,
}), null);
});
test("agent choice copy rejects appearance, scars, clock times, and incomplete C/D", () => {
@@ -323,13 +339,14 @@ test("GET hides a legacy known-event quality focus and resumes evidence collecti
year: 2016,
year_label: "2016 年前后",
domain: "education",
event_family: "高考或重要考试发挥明显失常、压力很大",
event_family: "升学结果或学习环境出现明显变化",
source: "known_event_quality",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "已知事件质量",
role: "clarify",
style_options: DYNAMIC_STYLE_OPTIONS,
phase: "event_clarification",
choice_kind: "event_quality",
semantic_key: "education.2016",
@@ -432,6 +449,7 @@ test("GET card stays hidden on an empty ledger even if the Agent already wrote c
unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。事件家族:认真关系进入、结束或关系观明显转变。请写成一句自然语言是/否题。不得改年份。",
role: "distinguish",
style_options: DYNAMIC_STYLE_OPTIONS,
information_gain: 0.4,
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
@@ -636,6 +654,7 @@ test("GET reverse-verify card still appears after a time is accepted", () => {
unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。请写成一句自然语言,问是否入职或职责加重。",
role: "reverse_verify",
style_options: DYNAMIC_STYLE_OPTIONS,
}],
activeFocus: {
id: FOCUS_ID,
@@ -650,10 +669,10 @@ test("GET reverse-verify card still appears after a time is accepted", () => {
});
test("choice card user messages expose A/B/C/D as a leading key", () => {
assert.equal(parseChoiceKeyFromUserMessage("C. 没有明显发生"), "C");
assert.equal(parseChoiceKeyFromUserMessage("D、不记得 / 不确定"), "D");
assert.equal(parseChoiceKeyFromUserMessage(`${HOLDOUT_MESSAGE_PREFIX}:B. 有类似但年份不对`), "B");
assert.equal(parseChoiceKeyFromUserMessage("没有明显发生"), null);
assert.equal(parseChoiceKeyFromUserMessage(`C. ${DYNAMIC_STYLE_OPTIONS[2].label}`), "C");
assert.equal(parseChoiceKeyFromUserMessage(`D、${DYNAMIC_STYLE_OPTIONS[3].label}`), "D");
assert.equal(parseChoiceKeyFromUserMessage(`${HOLDOUT_MESSAGE_PREFIX}:B. ${DYNAMIC_STYLE_OPTIONS[1].label}`), "B");
assert.equal(parseChoiceKeyFromUserMessage(DYNAMIC_STYLE_OPTIONS[2].label), null);
});
test("GET choice_card stays after coverage when remaining minutes still split on D24", () => {
@@ -716,6 +735,28 @@ test("GET choice_card stays after coverage when remaining minutes still split on
],
decisionReceipt: {
propose_allowed: true,
discriminating_event_probes: [{
year: 2017,
year_label: "2017 年前后",
domain: "education",
event_family: "学习方向或升学环境出现明显变化",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2017 年前后,核对学习方向或升学环境变化。",
role: "distinguish",
phase: "candidate_discriminator",
information_gain: 0.16,
semantic_key: "varga.d24.05:00/05:06|05:07",
candidate_split_hash: "varga.d24.05:00/05:06|05:07",
candidate_ids: ["05:00", "05:06", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] },
{ answer_class: "no", supports: ["05:06", "05:07"], conflicts: ["05:00"] },
],
style_options: DYNAMIC_STYLE_OPTIONS,
}],
window_scan: {
scanned: true,
d9_lagna_count: 1,
@@ -845,6 +886,7 @@ test("dynamic option labels keep the A/B/C/D answer-class contract", () => {
{ label: "发生过但程度较弱", answer_class: "weak_yes" },
],
}, { probes: [MOVE_PROBE] });
assert.ok(frame);
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.equal(copy.option_a, "明确发生且时间吻合");
@@ -853,78 +895,6 @@ test("dynamic option labels keep the A/B/C/D answer-class contract", () => {
assert.equal(copy.option_d, "这段记不清楚");
});
test("remaining D10 style card uses type-table labels instead of existence", () => {
const frame = buildChoiceFrame({
method_id: "d10_career",
ask_theme: "career_style",
domain: "career",
user_prompt_hint: "unused",
choice_kind: "varga_style",
style_options: [
{ label: "照顾、情感", answer_class: "yes", sign: "巨蟹座" },
{ label: "领导、表演", answer_class: "weak_yes", sign: "狮子座" },
{ label: "细致、服务", answer_class: "no", sign: "处女座" },
],
});
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.equal(frame.choice_kind, "varga_style");
assert.match(copy.prompt, /长期工作更接近哪一类/);
assert.doesNotMatch(copy.prompt, /有没有明显/);
assert.equal(copy.option_a, "照顾、情感");
assert.equal(copy.option_b, "领导、表演");
assert.equal(copy.option_c, "细致、服务");
});
test("two-way remaining D9 puts 都不像 on C", () => {
const frame = buildChoiceFrame({
method_id: "d9_relationship",
ask_theme: "relationship_style",
domain: "relationship",
user_prompt_hint: "unused",
choice_kind: "varga_style",
style_options: [
{ label: "情感丰富、家庭导向", answer_class: "yes", sign: "巨蟹座" },
{ label: "骄傲、戏剧性、领导欲", answer_class: "weak_yes", sign: "狮子座" },
],
});
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.match(copy.prompt, /相处/);
assert.match(copy.option_c, /都不像/);
assert.match(copy.option_d, /不确定/);
});
test("known exam quality card keeps the recorded year and quality mapping", () => {
const frame = buildChoiceFrame({
method_id: "d5_education",
ask_theme: "education_style",
domain: "education",
user_prompt_hint: "unused",
choice_kind: "event_quality",
}, {
probes: [{
year: 2015,
year_label: "2015 年前后",
domain: "education",
event_family: "高考或重要考试发挥明显失常、压力很大",
source: "known_event_quality",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2015 年前后。已有相关经历。请按事件家族改写成自然语言。不得改年份。",
role: "clarify",
phase: "event_clarification",
choice_kind: "event_quality",
}],
});
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.equal(frame.period, "2015 年前后");
assert.equal(frame.choice_kind, "event_quality");
assert.doesNotMatch(copy.prompt, /有没有/);
});
test("GET card prompt keeps the agent's year-locked question", () => {
const fallback = {
prompt: "2018 年前后 · 认真关系进入、结束或关系观明显转变",
@@ -964,6 +934,7 @@ test("GET card prompt keeps the agent's year-locked question", () => {
unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。",
role: "reverse_verify",
style_options: DYNAMIC_STYLE_OPTIONS,
}],
activeFocus: {
id: FOCUS_ID,
@@ -979,60 +950,7 @@ test("GET card prompt keeps the agent's year-locked question", () => {
assert.doesNotMatch(card.prompt, /有没有明显认真关系进入/);
});
test("GET without agent speech shows the lock label, not a yes/no template", () => {
const fallback = {
prompt: "2018 年前后 · 认真关系进入、结束或关系观明显转变",
option_a: SAMPLE_COPY.option_a,
option_b: SAMPLE_COPY.option_b,
option_c: SAMPLE_COPY.option_c,
option_d: SAMPLE_COPY.option_d,
};
const card = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}, {
status: "confirmed",
domain: "relationship",
datePrecision: "year",
occurredFrom: "2017-01-01",
occurredTo: null,
}, {
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2019-01-01",
occurredTo: null,
}],
eventProbes: [{
year: 2018,
year_label: "2018 年前后",
domain: "relationship",
event_family: "认真关系进入、结束或关系观明显转变",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。",
role: "reverse_verify",
}],
activeFocus: {
id: FOCUS_ID,
intent: "distinguish_candidates",
targetDomain: "relationship",
targetKind: "relationship_change",
expectedAnswerSchema: { choice: fallback, probe_id: "relationship.2018" },
},
});
assert.ok(card);
assert.equal(card.prompt, fallback.prompt);
assert.doesNotMatch(card.prompt, /有没有/);
});
test("career event_quality does not reuse the exam underperformance copy", () => {
test("career event_quality uses the supplied dynamic labels", () => {
const frame = buildChoiceFrame({
method_id: "d10_career",
ask_theme: "career_style",
@@ -1051,21 +969,22 @@ test("career event_quality does not reuse the exam underperformance copy", () =>
unique_minute_claim: false,
user_meaning: "年份锁定 2020 年前后。已有相关经历。请写成一句自然语言,问入职、升职或职责明显加重有没有发生过。不得改年份。",
role: "distinguish",
style_options: DYNAMIC_STYLE_OPTIONS,
choice_kind: "event_quality",
}],
});
assert.ok(frame);
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.match(copy.prompt, /2020 年前后/);
assert.match(copy.prompt, /入职、升职或职责明显加重/);
assert.doesNotMatch(copy.prompt, /高考|失常|压力很大/);
assert.doesNotMatch(copy.prompt, /有没有/);
assert.match(copy.prompt, /·/);
assert.equal(copy.option_a, "是,大概就在那段时间");
assert.equal(copy.option_a, DYNAMIC_STYLE_OPTIONS[0].label);
assert.doesNotMatch(copy.option_a, /失常/);
});
test("dasha existence cards lock the year and family, not a yes/no template", () => {
test("dasha existence cards lock the event and use supplied dynamic labels", () => {
const frame = buildChoiceFrame({
method_id: "d4_home",
ask_theme: "home_change",
@@ -1073,11 +992,12 @@ test("dasha existence cards lock the year and family, not a yes/no template", ()
user_prompt_hint: "unused",
choice_kind: "existence",
}, { probes: [MOVE_PROBE] });
assert.ok(frame);
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.match(copy.prompt, /2016 年前后/);
assert.match(copy.prompt, /搬家、离乡或长期异地/);
assert.doesNotMatch(copy.prompt, /有没有明显/);
assert.match(copy.option_a, /是,大概就在那段时间/);
assert.match(copy.option_c, /没有明显发生/);
assert.equal(copy.option_a, DYNAMIC_STYLE_OPTIONS[0].label);
assert.equal(copy.option_c, DYNAMIC_STYLE_OPTIONS[2].label);
});
@@ -4,7 +4,6 @@ import test from "node:test";
import {
SEALED_MINUTE_HOLDOUT,
UNIQUE_MINUTE_CLOSED_COPY,
buildConfirmationGate,
readVedastroMinuteSensitiveStatus,
sessionOutcomeFromGate,
@@ -104,13 +103,14 @@ test("VedAstro not_evaluated is not described as fail and still forbids confirma
assert.equal(vedastro.status, "not_evaluated");
assert.notEqual(vedastro.status, "fail");
assert.notEqual(vedastro.status, "failed");
assert.match(vedastro.user_meaning, /不等于失败|不是失败|尚未跑通/);
assert.doesNotMatch(vedastro.user_meaning, /失败了|校验失败/);
assert.match(vedastro.user_meaning, /尚未执行/);
assert.doesNotMatch(vedastro.user_meaning, /执行失败|校验失败/);
assert.equal(adjacent.status, "passed");
assert.equal(holdout.status, "not_ready");
assert.equal(gate.confirmation_allowed, false);
assert.equal(gate.unique_minute_path, "closed_at_representative");
assert.ok(holdout.user_meaning.includes(UNIQUE_MINUTE_CLOSED_COPY));
assert.match(holdout.user_meaning, /未达发布门槛/);
assert.match(holdout.user_meaning, /不能确认唯一分钟/);
assert.equal(uniqueMinutePath(false), "closed_at_representative");
assert.equal(uniqueMinutePath(true), "awaiting_user_consent");
});
@@ -152,7 +152,7 @@ test("a 25-minute plateau stays confirmation-blocked as an indistinguishable ran
proposeAllowed: true,
confirmationAllowed: false,
interviewOpen: false,
}).user_meaning, /本会话以代表性时间收口,不确认唯一分钟/);
}).user_meaning, /可以采用代表性候选作当前排盘/);
assert.equal(sessionOutcomeFromGate({
selectionAllowed: true,
confirmationAllowed: false,
@@ -198,15 +198,15 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
assert.match(skill, /confirmation_gate/);
assert.match(skill, /not_evaluated/);
assert.match(skill, /session_outcome=adopt_representative/);
assert.match(skill, /本会话以代表性时间收口,不确认唯一分钟/);
assert.doesNotMatch(skill, /本会话以代表性时间收口|本轮校正已收口/);
assert.match(skill, /unique_minute_path=closed_at_representative/);
assert.doesNotMatch(skill, /还不能确认唯一分钟/);
assert.match(candidateComparison, /confirmation_gate/);
assert.match(agentSource, /confirmation_gate/);
assert.match(agentSource, /session_outcome=adopt_representative/);
assert.doesNotMatch(agentSource, /±2 分钟/);
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 13);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.12");
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
const accounting = fakeAccounting({
...receiptHandlers,
+229 -28
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup, latestUserStoppedCollecting } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import {
askedKeysFromLedgerEvidence,
@@ -14,9 +14,16 @@ import {
} 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 { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { readVedastroMinuteSensitiveStatus } from "../src/lib/rectification-agentic/v9/confirmation-gate.ts";
import { authoritativeCandidateProjection } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { createRectificationV9Tools, latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts";
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
import { RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
candidateRangeFingerprint,
evidenceLedgerFingerprint,
parseV9CaseDossier,
RectificationToolServiceError,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
resolveExactSkillPackage,
} from "../src/lib/skill-package-registry.ts";
@@ -42,6 +49,13 @@ const FAMILY_ID = "44444444-4444-4444-8444-444444444443";
const CAREER_ID = "44444444-4444-4444-8444-444444444444";
const UNIQUE_MINUTE_COPY = /±5 分钟确定性/;
const DYNAMIC_STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "不记得这段经历", answer_class: "unsure" as const },
];
const CLASSIC_COVERAGE = [
{ status: "confirmed", domain: "education", datePrecision: "year" as const, occurredFrom: "2016-01-01", occurredTo: null },
{ status: "confirmed", domain: "relationship", datePrecision: "year" as const, occurredFrom: "2018-01-01", occurredTo: null },
@@ -94,6 +108,7 @@ const CAREER_CONFLICT_PROBE = {
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
style_options: DYNAMIC_STYLE_OPTIONS,
};
const EDUCATION_QUALITY_PROBE = {
@@ -111,6 +126,7 @@ const EDUCATION_QUALITY_PROBE = {
choice_kind: "event_quality" as const,
information_gain: 0,
semantic_key: "education.2016",
style_options: DYNAMIC_STYLE_OPTIONS,
};
const CAREER_QUALITY_PROBE = {
@@ -128,6 +144,7 @@ const CAREER_QUALITY_PROBE = {
choice_kind: "event_quality" as const,
information_gain: 0,
semantic_key: "career.2020",
style_options: DYNAMIC_STYLE_OPTIONS,
};
const ENGINE_SCORE = {
@@ -866,6 +883,172 @@ test("accepted batch evidence triggers server rescore without offering adoption"
}
});
test("cached candidates retry a failed VedAstro validation without recomputing ranking", async () => {
const validationResponse = {
status: "passed",
can_confirm_exact_minute: true,
event_validation: {
search_events_primary_supports_local_winner: true,
},
minute_sensitive_validation: {
status: "passed",
},
};
const restore = stubEngine(validationResponse);
try {
const compute = computeFixture();
const evidence = [educationEvidence];
const evidenceFingerprint = evidenceLedgerFingerprint([{
id: educationEvidence.id,
sourceTurnId: educationEvidence.source_turn_id,
subject: educationEvidence.subject,
eventKind: educationEvidence.event_kind,
domain: educationEvidence.domain,
occurredFrom: educationEvidence.occurred_from,
occurredTo: educationEvidence.occurred_to,
datePrecision: educationEvidence.date_precision,
summary: educationEvidence.summary,
status: educationEvidence.status,
supersedesEvidenceId: educationEvidence.supersedes_evidence_id,
createdAt: educationEvidence.created_at,
}]);
const rangeFingerprint = candidateRangeFingerprint(
compute.candidate_range,
compute.baseline_profile_fingerprint,
);
const cached = {
...candidateSnapshotFixture({
representativeTime: "05:02",
evidenceLedgerFingerprint: evidenceFingerprint,
decisionReceipt: {
inference_state: {
algorithm_version: "rectification-inference-v1",
candidate_set_id: "04:55-05:02:04:55,05:02",
revision: 1,
phase: "discrimination",
result_status: "credible_range",
range_start: "04:55",
range_end: "05:02",
candidates: [
{ id: CANDIDATE_ID, time: "05:02", cluster_range: ["05:02", "05:02"], prior_score: 58, posterior_score: 58, probability: 0.58, status: "active", rank: 1, strong_conflict_count: 0 },
{ id: SECOND_CANDIDATE_ID, time: "04:55", cluster_range: ["04:55", "04:55"], prior_score: 42, posterior_score: 42, probability: 0.42, status: "active", rank: 2, strong_conflict_count: 0 },
],
events: [],
probes: [],
answered_probes: [],
rounds: [],
entropy: 0.98,
representative_time: "05:02",
credible_range: ["04:55", "05:02"],
},
gates: {
exact_confirmation: {
external_validation_status: "failed",
vedastro_event_validation: {
status: "failed",
search_events_primary_supports_local_winner: false,
can_confirm_exact_minute: false,
failure: { code: "timeout" },
},
},
},
},
}),
candidate_range_fingerprint: rangeFingerprint,
};
const refreshedReceipt = {
...cached.decision_receipt,
gates: {
exact_confirmation: {
external_validation_status: "passed",
vedastro_event_validation: {
status: "passed",
search_events_primary_supports_local_winner: true,
can_confirm_exact_minute: true,
failure: null,
},
},
},
};
const parsedCached = parseV9CaseDossier(dossierFixture({ evidence, latestResult: cached }));
assert.equal(parsedCached?.latestResult?.selectionAllowed, true);
assert.equal(parsedCached?.latestResult?.evidenceLedgerFingerprint, evidenceFingerprint);
assert.equal(parsedCached?.latestResult?.candidateRangeFingerprint, rangeFingerprint);
assert.equal(readVedastroMinuteSensitiveStatus(parsedCached?.latestResult?.decisionReceipt), "failed");
assert.equal(authoritativeCandidateProjection(parsedCached!.latestResult!).candidates.length, 2);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence,
latestResult: cached,
}),
get_agentic_rectification_case_compute: () => compute,
refresh_agentic_rectification_vedastro_validation: () => ({
result_id: RESULT_ID,
decision_receipt: refreshedReceipt,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-27T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
userMessage: "继续比较",
accounting: accounting.client as never,
});
const result = await (tools["rectification-compare-candidates"] as unknown as {
execute(input: unknown): Promise<{
cached: boolean;
candidates: Array<{ time: string; relativeSupport: number }>;
confirmation_gate: { blockers: Array<{ id: string; status: string }> };
}>;
}).execute({ caseId: CASE_ID });
const refreshCall = accounting.calls.find((call) =>
call.fn === "refresh_agentic_rectification_vedastro_validation"
);
assert.ok(refreshCall, JSON.stringify(accounting.calls.map((call) => call.fn)));
assert.deepEqual(refreshCall.args.p_validation, {
status: "passed",
search_events_primary_supports_local_winner: true,
can_confirm_exact_minute: true,
failure: null,
});
assert.equal(refreshCall.args.p_minute_sensitive_status, "passed");
assert.equal(
accounting.calls.some((call) => call.fn === "persist_agentic_rectification_candidate_v2"),
false,
);
assert.equal(result.cached, true);
assert.deepEqual(result.candidates.map((item) => [item.time, item.relativeSupport]), [
["05:02", 58],
["04:55", 42],
]);
assert.ok(result.confirmation_gate, JSON.stringify(result));
assert.equal(
result.confirmation_gate.blockers.find((gate) => gate.id === "vedastro_minute_sensitive")?.status,
"passed",
);
} finally {
restore();
}
});
test("evidence batch returns the persisted choice prompt as open_question", async () => {
const restore = stubEngine({
...ENGINE_SCORE,
@@ -892,6 +1075,7 @@ test("evidence batch returns the persisted choice prompt as open_question", asyn
{ answer_class: "yes", supports: ["04:50"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["04:50"] },
],
style_options: DYNAMIC_STYLE_OPTIONS,
choice_kind: "existence",
}],
},
@@ -1033,9 +1217,9 @@ test("rescore failure does not fail the evidence write", async () => {
assert.ok(result.rescore.error_code);
});
test("public tool surface stays at 13 and new cases bind 10.0.12", () => {
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 13);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.12");
test("public tool surface stays at 14 and new cases bind 10.0.13", () => {
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
const deprecated = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.2",
@@ -1253,6 +1437,7 @@ test("accepted representative time reverse-verifies predicted events then hands
unique_minute_claim: false as const,
user_meaning: "年份锁定 2018 年前后。请写成一句自然语言,问是否入职或职责加重。",
role: "reverse_verify" as const,
style_options: DYNAMIC_STYLE_OPTIONS,
};
const plan = buildMethodFollowupPlan({
evidence: [{
@@ -1287,7 +1472,7 @@ test("accepted representative time reverse-verifies predicted events then hands
assert.equal(plan.next_followup?.method_id, "reverse_verify");
assert.equal(plan.next_followup?.domain, "career");
assert.equal(plan.next_followup?.choice_frame?.scoring, true);
assert.match(plan.next_followup?.choice_frame?.why ?? "", /按当前采用时间核对/);
assert.match(plan.next_followup?.choice_frame?.why ?? "", /2018 年前后/);
assert.equal(plan.deferred_followup, null);
assert.equal(plan.next_followup?.source, "reverse_verify");
const action = buildNextUserAction({
@@ -1770,18 +1955,7 @@ test("coverage-complete tie with no remaining split offers a provisional range",
assert.equal(action.id, "offer_provisional_range");
});
test("only explicit rectification exit language is a global user stop", () => {
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没有" }]), false);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没有了" }]), false);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "想不起来了" }]), false);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "结束校正" }]), true);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "我不想继续了" }]), true);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "直接给结果吧" }]), true);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "就到这里" }]), true);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "我不是不想继续,只是想先补工作" }]), false);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "不要直接给结果,我还想补一件事" }]), false);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "还没到这里结束,我继续说" }]), false);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "还有一件升学" }]), false);
test("structured paused state ends evidence collection without parsing user copy", () => {
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
@@ -2015,6 +2189,7 @@ test("accepted time reverse-verifies an uncovered year in a covered domain", ()
unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。请写成一句自然语言,问是否入职或职责加重。",
role: "reverse_verify",
style_options: DYNAMIC_STYLE_OPTIONS,
}],
});
assert.equal(plan.next_followup?.source, "reverse_verify");
@@ -2112,19 +2287,12 @@ test("offer-candidates refuses while method coverage remains", async () => {
);
});
test("user stop with selection_allowed may offer the escape hatch", async () => {
test("paused case with selection_allowed may offer the escape hatch", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
status: "paused",
evidence: [educationEvidence],
turns: [{
id: TURN_ID,
role: "user",
text: "结束校正",
status: "completed",
created_at: "2026-08-12T10:00:00.000Z",
completed_at: "2026-08-12T10:00:05.000Z",
}],
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:48",
@@ -2178,6 +2346,40 @@ test("user stop with selection_allowed may offer the escape hatch", async () =>
call.fn === "transition_agentic_rectification_case_status"
&& call.args.p_status === "candidate_ready"
),
false,
);
});
test("paused case resumes only when the Agent explicitly requests it", async () => {
let reads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
status: reads++ === 0 ? "paused" : "collecting_evidence",
evidence: [],
}),
transition_agentic_rectification_case_status: (_fn, args) => ({
case_id: CASE_ID,
status: args.p_status,
idempotent: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
userMessage: "继续校正",
accounting: accounting.client as never,
});
const projection = await (tools["rectification-read-case"] as unknown as {
execute(input: unknown): Promise<{ status: string }>;
}).execute({ caseId: CASE_ID, resume: true });
assert.equal(projection.status, "collecting_evidence");
assert.equal(
accounting.calls.some((call) =>
call.fn === "transition_agentic_rectification_case_status"
&& call.args.p_status === "collecting_evidence"
),
true,
);
});
@@ -2287,4 +2489,3 @@ test("offer-candidates allows a 34/33/33 tie after method coverage when remainin
true,
);
});
@@ -206,9 +206,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.12 and the prompt prefers batch ingest", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.12");
assert.match(skill, /^version: 10\.0\.12$/m);
test("new-case skill identity is 10.0.13 and the prompt prefers batch ingest", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
assert.match(skill, /^version: 10\.0\.13$/m);
assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/);
assert.match(agentSource, /当前轮新事件一律走 rectification-record-evidence-batch/);
assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
@@ -5,8 +5,16 @@ import { buildInferenceState } from "../src/lib/rectification-agentic/core/build
import { persistServerOwnedFocus, shouldSkipDiscriminatorFollowup, stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { buildChoiceFrame, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { EventProbeStyleOption } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { fakeAccounting, CASE_ID, FOCUS_ID, USER_ID, activeFocusFixture } from "./rectification-v9-test-support.ts";
const DYNAMIC_STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" },
{ label: "发生过但程度较弱", answer_class: "weak_yes" },
{ label: "明确没有发生", answer_class: "no" },
{ label: "这段记不清楚", answer_class: "unsure" },
] as const;
function discriminatorFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
const frame = buildChoiceFrame({
method_id: "dasha_events",
@@ -18,17 +26,19 @@ function discriminatorFollowup(overrides: Partial<MethodFollowup> = {}): MethodF
year: 2016,
year_label: "2016 年前后",
domain: "education",
event_family: "高考或重要考试发挥明显失常",
event_family: "升学结果或学习环境出现明显变化",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "2016 年前后高考或重要考试发挥明显失常",
user_meaning: "2016 年前后升学结果或学习环境出现明显变化",
role: "distinguish",
information_gain: 0.4,
semantic_key: "education:2016",
style_options: DYNAMIC_STYLE_OPTIONS,
}],
});
assert.ok(frame);
return {
method_id: "dasha_events",
intent: "distinguish_candidates",
@@ -51,6 +61,107 @@ function discriminatorFollowup(overrides: Partial<MethodFollowup> = {}): MethodF
};
}
function invalidStyleFollowup(
styleOptions: readonly EventProbeStyleOption[] | undefined,
eventFamily = "升学结果或学习环境出现明显变化",
): MethodFollowup {
const valid = discriminatorFollowup();
return {
...valid,
choice_frame: buildChoiceFrame({
method_id: "dasha_events",
ask_theme: "dated_event",
domain: "education",
user_prompt_hint: "ask",
}, {
probes: [{
year: 2016,
year_label: "2016 年前后",
domain: "education",
event_family: eventFamily,
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "2016 年前后升学结果或学习环境出现明显变化",
role: "distinguish",
information_gain: 0.4,
semantic_key: "education:2016",
style_options: styleOptions,
}],
}),
};
}
async function assertInvalidChoiceSkipped(followup: MethodFollowup) {
const accounting = fakeAccounting({}, {
fallback: () => {
throw new Error("invalid choice frame must not call the database");
},
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
assert.equal(result.status, "skipped");
assert.equal(accounting.calls.length, 0);
}
test("complete dynamic event options persist a server-owned focus", async () => {
const followup = discriminatorFollowup({ source: "precision_stage", information_gain: 0.2 });
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: null,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-27T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
assert.equal(result.status, "created");
assert.equal(accounting.calls.length, 1);
assert.deepEqual(result.focus?.expectedAnswerSchema.choice, {
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
option_a: DYNAMIC_STYLE_OPTIONS[0].label,
option_b: DYNAMIC_STYLE_OPTIONS[1].label,
option_c: DYNAMIC_STYLE_OPTIONS[2].label,
option_d: DYNAMIC_STYLE_OPTIONS[3].label,
});
});
test("missing, duplicate, incomplete, or illegal dynamic options skip focus persistence", async () => {
await assertInvalidChoiceSkipped(invalidStyleFollowup(undefined));
await assertInvalidChoiceSkipped(invalidStyleFollowup(DYNAMIC_STYLE_OPTIONS.slice(0, 3)));
await assertInvalidChoiceSkipped(invalidStyleFollowup([
...DYNAMIC_STYLE_OPTIONS,
{ label: "另一种明确发生", answer_class: "yes" },
]));
await assertInvalidChoiceSkipped(invalidStyleFollowup([
{ ...DYNAMIC_STYLE_OPTIONS[0], label: "08:12 左右发生" },
...DYNAMIC_STYLE_OPTIONS.slice(1),
]));
await assertInvalidChoiceSkipped(invalidStyleFollowup(DYNAMIC_STYLE_OPTIONS, " "));
});
test("does not ask an already-answered discriminator probe again", async () => {
const followup = discriminatorFollowup();
const active = activeFocusFixture({
@@ -33,6 +33,7 @@ const EXACT_TOOL_KEYS = [
"rectification-offer-candidates",
"rectification-accept-candidate",
"rectification-confirm-birth-time",
"rectification-stop-and-review",
"rectification-close-case",
] as const;
@@ -113,20 +114,21 @@ const validInputs: Record<(typeof EXACT_TOOL_KEYS)[number], Record<string, unkno
candidateId: CANDIDATE_ID,
consentQuote: "就用05:02",
},
"rectification-stop-and-review": { caseId: CASE_ID },
"rectification-close-case": {
caseId: CASE_ID,
reason: "completed_by_user",
},
};
test("V10 exposes exactly the 13 allowlisted rectification tool keys", () => {
test("V10 exposes exactly the 14 allowlisted rectification tool keys", () => {
const tools = toolsUnderTest();
assert.deepEqual(Object.keys(tools), [...EXACT_TOOL_KEYS]);
assert.deepEqual([...PUBLIC_RECTIFICATION_TOOLS], [...EXACT_TOOL_KEYS]);
assert.equal(new Set(Object.keys(tools)).size, 13);
assert.equal(new Set(Object.keys(tools)).size, 14);
});
test("all 13 tool input schemas accept their minimal contract and reject unknown fields", () => {
test("all 14 tool input schemas accept their minimal contract and reject unknown fields", () => {
const tools = toolsUnderTest() as unknown as Record<string, ToolSchema>;
for (const toolName of EXACT_TOOL_KEYS) {
const schema = tools[toolName]?.inputSchema;
@@ -62,7 +62,7 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /不可分区间/);
assert.match(prompt, /confirmation_gate/);
assert.match(prompt, /session_outcome=adopt_representative/);
assert.match(prompt, /本会话以代表性时间收口,不确认唯一分钟/);
assert.doesNotMatch(prompt, /本会话以代表性时间收口|本轮校正已收口/);
assert.match(prompt, /unique_minute_path=closed_at_representative/);
assert.match(prompt, /next_user_action/);
assert.match(prompt, /rectification-offer-candidates/);
@@ -105,11 +105,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.12"));
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.13"));
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.12");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
});
test("step budgets are bounded per action with a hard ceiling", () => {
@@ -142,6 +142,7 @@ test("no tool accepts event arrays, birth data or candidate ranges as input", ()
"rectification-offer-candidates",
"rectification-accept-candidate",
"rectification-confirm-birth-time",
"rectification-stop-and-review",
"rectification-close-case",
];
for (const name of toolNames) {
@@ -94,9 +94,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.12");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
assert.match(skill, /^version: 10\.0\.12$/m);
assert.match(skill, /^version: 10\.0\.13$/m);
for (const reference of references) {
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
assert.ok(content.length > 0, `${reference} must be non-empty`);
@@ -349,13 +349,13 @@ test("engine http failures surface as safe engine errors, never raw stack traces
}
});
test("vedastro-validate maps pass/fail and never grants unique-minute confirmation", async () => {
test("vedastro-validate propagates the engine minute confirmation signal into the combined gate input", async () => {
const restore = stubEngine({
success: true,
endpoint: "rectification_v5_vedastro_validate",
status: "pass",
passed: true,
can_confirm_exact_minute: false,
can_confirm_exact_minute: true,
minute_sensitive_validation: { status: "pass", discriminated: true },
event_validation: {
status: "pass",
@@ -370,7 +370,7 @@ test("vedastro-validate maps pass/fail and never grants unique-minute confirmati
candidateTimes: ["04:50", "04:51"],
});
assert.equal(result.status, "passed");
assert.equal(result.canConfirmExactMinute, false);
assert.equal(result.canConfirmExactMinute, true);
assert.equal(result.searchEventsSupportsLocalWinner, true);
const merged = mergeVedastroValidateIntoReceipt(
{ ...DECISION_RECEIPT, gates: { exact_confirmation: { external_validation_status: "passed" } } },
@@ -389,11 +389,13 @@ test("vedastro-validate maps pass/fail and never grants unique-minute confirmati
}
});
test("vedastro-validate timeout stays not_evaluated and is not described as fail", async () => {
const previous = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error("timeout");
}) as unknown as typeof fetch;
test("vedastro-validate cannot pass the minute gate when the overall validation failed", async () => {
const restore = stubEngine({
status: "failed",
can_confirm_exact_minute: true,
minute_sensitive_validation: { status: "passed" },
event_validation: { search_events_primary_supports_local_winner: true },
});
try {
const result = await runV9VedastroValidate({
baselineBirthSnapshot: SNAPSHOT,
@@ -401,9 +403,55 @@ test("vedastro-validate timeout stays not_evaluated and is not described as fail
events: toEngineEvents(EVIDENCE),
candidateTimes: ["04:50", "04:51"],
});
assert.equal(result.status, "not_evaluated");
assert.notEqual(result.status, "failed");
assert.equal(result.status, "failed");
assert.equal(result.minuteSensitiveStatus, "failed");
assert.equal(result.canConfirmExactMinute, false);
const merged = mergeVedastroValidateIntoReceipt(
{ ...DECISION_RECEIPT, gates: { exact_confirmation: { external_validation_status: "not_evaluated" } } },
result,
);
const exact = (merged.gates as { exact_confirmation: Record<string, unknown> }).exact_confirmation;
assert.equal(exact.external_validation_status, "failed");
} finally {
restore();
}
});
test("vedastro-validate keeps safe timeout, HTTP, and invalid-response failure codes", async () => {
const input = {
baselineBirthSnapshot: SNAPSHOT,
candidateRange: RANGE,
events: toEngineEvents(EVIDENCE),
candidateTimes: ["04:50", "04:51"] as const,
};
const previous = globalThis.fetch;
try {
globalThis.fetch = (async () => {
throw new DOMException("secret upstream detail", "TimeoutError");
}) as unknown as typeof fetch;
const timeout = await runV9VedastroValidate(input);
assert.equal(timeout.status, "failed");
assert.deepEqual(timeout.failure, { code: "timeout" });
assert.doesNotMatch(JSON.stringify(timeout.raw), /secret upstream detail/);
globalThis.fetch = (async () => ({
ok: false,
status: 503,
json: async () => ({ error: "private upstream URL and token" }),
})) as unknown as typeof fetch;
const requestFailure = await runV9VedastroValidate(input);
assert.equal(requestFailure.status, "failed");
assert.deepEqual(requestFailure.failure, { code: "engine_request_failed" });
assert.doesNotMatch(JSON.stringify(requestFailure.raw), /private upstream URL|token/);
globalThis.fetch = (async () => ({
ok: true,
status: 200,
json: async () => null,
})) as unknown as typeof fetch;
const invalid = await runV9VedastroValidate(input);
assert.equal(invalid.status, "failed");
assert.deepEqual(invalid.failure, { code: "engine_invalid_response" });
} finally {
globalThis.fetch = previous;
}
@@ -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.12",
skill_version: "10.0.13",
};
}
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.12");
assert.equal(response.skillVersion, "10.0.13");
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.12");
assert.equal(openCall.args.p_skill_version, "10.0.13");
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);
@@ -13,6 +13,19 @@ const migration = readFileSync(
const dbMigrationsCopy = fileURLToPath(
new URL("../db/migrations/20260812010000_agentic_rectification_v9_runtime.sql", import.meta.url),
);
const vedastroRetryMigration = readFileSync(
new URL(
"../supabase/migrations/20260827020000_rectification_vedastro_validation_retry.sql",
import.meta.url,
),
"utf8",
);
const vedastroRetryMigrationCopy = fileURLToPath(
new URL(
"../db/migrations/20260827020000_rectification_vedastro_validation_retry.sql",
import.meta.url,
),
);
test("v9 runtime migration sorts after the newest business migration and stays unique", () => {
assert.ok(
@@ -1409,3 +1422,95 @@ test("candidate freshness trigger uses only calculation inputs", () => {
/after update of[^;]*birth_place_label|after update of[^;]*active_birth_time/,
);
});
test("VedAstro retry migration is forward-only, unique and registers the stop tool", () => {
assert.match(vedastroRetryMigration, /^begin;[\s\S]*^commit;$/m);
assert.equal(
existsSync(vedastroRetryMigrationCopy),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
assert.match(
vedastroRetryMigration,
/agentic_rectification_tool_receipts_tool_name_check check \([\s\S]*'rectification-stop-and-review'/,
);
const receiptRpc = vedastroRetryMigration.slice(
vedastroRetryMigration.indexOf(
"create or replace function public.insert_agentic_rectification_tool_receipt(",
),
vedastroRetryMigration.indexOf(
"create or replace function public.refresh_agentic_rectification_vedastro_validation(",
),
);
assert.match(receiptRpc, /p_tool_name not in \([\s\S]*'rectification-stop-and-review'/);
assert.match(receiptRpc, /security definer\s+set search_path = ''/);
assert.match(
receiptRpc,
/revoke all on function public\.insert_agentic_rectification_tool_receipt\([\s\S]*from public, anon, authenticated/,
);
assert.match(
receiptRpc,
/grant execute on function public\.insert_agentic_rectification_tool_receipt\([\s\S]*to service_role/,
);
});
test("VedAstro retry RPC is service-role only and validates current result ownership", () => {
assert.match(
vedastroRetryMigration,
/create or replace function public\.refresh_agentic_rectification_vedastro_validation\(/,
);
assert.match(vedastroRetryMigration, /security definer\s+set search_path = ''/);
assert.match(
vedastroRetryMigration,
/revoke all on function public\.refresh_agentic_rectification_vedastro_validation\([\s\S]*from public, anon, authenticated/,
);
assert.match(
vedastroRetryMigration,
/grant execute on function public\.refresh_agentic_rectification_vedastro_validation\([\s\S]*to service_role/,
);
assert.match(vedastroRetryMigration, /where id = p_case_id and user_id = p_user_id/);
assert.match(
vedastroRetryMigration,
/where id = p_result_id\s+and user_id = p_user_id\s+and case_id = p_case_id/,
);
assert.match(vedastroRetryMigration, /evidence_ledger_fingerprint is distinct from p_evidence_ledger_fingerprint/);
assert.match(vedastroRetryMigration, /candidate_range_fingerprint is distinct from p_candidate_range_fingerprint/);
assert.match(vedastroRetryMigration, /newer\.created_at > v_result\.created_at/);
assert.match(vedastroRetryMigration, /v_profile\.birth_date is distinct from/);
assert.match(vedastroRetryMigration, /v_profile\.declared_window_start is distinct from/);
assert.match(vedastroRetryMigration, /agentic_rectification_candidate_profile_changed/);
});
test("VedAstro retry RPC accepts only safe validation fields and mutates only the receipt", () => {
const refreshRpc = vedastroRetryMigration.slice(
vedastroRetryMigration.indexOf(
"create or replace function public.refresh_agentic_rectification_vedastro_validation(",
),
);
assert.match(
refreshRpc,
/jsonb_typeof\(p_validation -> 'failure'\) is distinct from 'object'[\s\S]*jsonb_typeof\(p_validation -> 'failure'\) is distinct from 'null'/,
);
assert.match(
refreshRpc,
/v_failure_code not in \('timeout', 'engine_request_failed', 'engine_invalid_response', 'unknown'\)/,
);
assert.match(
refreshRpc,
/v_exact := jsonb_set\(v_exact, '\{vedastro_event_validation\}', v_safe_validation, true\)/,
);
assert.match(
refreshRpc,
/p_minute_sensitive_status = 'passed' and v_status <> 'passed'/,
);
assert.match(
refreshRpc,
/v_exact := jsonb_set\(v_exact, '\{external_validation_status\}', to_jsonb\(v_minute_sensitive_status\), true\)/,
);
const update = refreshRpc.slice(
refreshRpc.indexOf("update public.agentic_rectification_results"),
refreshRpc.indexOf("return jsonb_build_object("),
);
assert.match(update, /set decision_receipt = v_receipt,\s+updated_at = pg_catalog\.now\(\)/);
assert.doesNotMatch(update, /candidates\s*=|inference_state\s*=|selected_time\s*=|selected_candidate_id\s*=/);
});
+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.12",
sha256: "cc4510111f43f109b8483e3fd9699dbd15ef77f14a45ba1149697412075800be",
version: "10.0.13",
sha256: "fa38ce8529a497d24101c841371e067d00904ba6a091120496da178fa9df40e2",
},
{
name: "jyotish-personal-report",