Compare commits

...

4 Commits

Author SHA1 Message Date
Jesse_Chen 643f6f7a15 fix(report): give the interpretive fixture structured D2/D11/D24
Independent Staging Quality Gate / validate (push) Successful in 8m22s
Independent Staging Quality Gate / publish (push) Successful in 1m47s
BUG-486 demotion dropped wealth and education cards when the snapshot only had machine receipts, which failed Gitea validate. Keep write-theme cards only when varga_full is present.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:27:59 +08:00
Jesse_Chen 45f190a1d0 fix(report): extract D2/D11 varga aliases so wealth chapters pass final parse
Independent Staging Quality Gate / validate (push) Failing after 7m34s
Independent Staging Quality Gate / publish (push) Has been cancelled
Engine keys like D2_Hora never became structured charts, so wealth write
themes failed the document contract. Canonicalize document vargas, demote
missing charts to blocked, and classify the parse failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 14:55:06 +08:00
Jesse_Chen 661a0dab14 fix(rectification): stop the opening body from repeating the collect prompt
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
Persist the interview slot before the opening turn, keep the stem in the question slot, and hide that slot while a run is busy so the same ask does not appear twice.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 14:53:10 +08:00
Jesse_Chen aab3ad4b84 fix(rectification): keep choice stems visible and align overlay adopt with the engine
Independent Staging Quality Gate / validate (push) Successful in 7m44s
Independent Staging Quality Gate / publish (push) Successful in 1m48s
Walkthrough polish: fail-closed empty D9 prompts, stop asserting the next question is on screen, vary same-domain collect copy, switch to reverse-verify after adopt, and let coverage route interview without blocking can_adopt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 13:11:22 +08:00
32 changed files with 1432 additions and 163 deletions
+110
View File
@@ -7378,3 +7378,113 @@
- 相关记录:BUG-464(消息按需加载引入的首屏消息等待,流式轮已把当前会话消息前移到揭幕前)、BUG-470effect 内 setState 规则)
- 复发自:无
- 修复版本:待发布
## BUG-480 | 第一道 D9 选择卡只有 A-D、没有题干
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`persistServerOwnedFocus``persistPlanFocus``finalizeSuccessfulTurnExit`、选择题 `choice_frame.prompt`
- 用户现象:走查第一道 D9 相处方式卡只有 A–D,题干不在卡片上,也不在 turn 历史里。后续点选题走 answer_choice 路径则正常。
- 触发条件:message/agent 路径首次为 `distinguish_candidates` 建 focus`serverOwnedChoiceCopy` 因空 prompt/period 返回 null,或 turn-exit 只在 idle 新建 focus 时写题干 turn。
- 根因:copy 为 null 时仍可能留下不可渲染 choiceagent `persistPlanFocus` 没有 spoken collect 回退;turn-exit 在 agent 已建 focus 时跳过题干持久化。
- 修复:copy 为 null 的区分题 fail-closed,回退为 `spokenCollectFallbackFollowup`,不建空题干 choice。agent 路径同样回退。turn-exit 只要当前问题有 prompt 就按既有结构规则写题干 turn。D9 `varga_style` 优先接上探针问句。不改 Python 引擎,不 bump Skill。
- 验证:`rectification-walkthrough-polish` D9 不变量与 unwired 回退;`rectification-server-focus``agent-voice-copy-contract`。未做登录后的真实页面点选。
- 防复发:任何可渲染 `choice_card` 必须有非空 prompt;不得再把空 prompt 的 choice focus 当成功创建。agent 与 idle 创建链都要走同一 fail-closed。
- 相关记录:BUG-460、BUG-461、BUG-469、BUG-471
- 复发自:BUG-471(可见 legend 修好后,空 prompt 创建链仍会丢题干)
- 修复版本:待发布
## BUG-481 | 正文说「界面上有下一问」时问题槽还是空的
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`agentic-rectification` 指令、`VOICE.md`、流结束后 `loadCaseSnapshot`
- 用户现象:助手说界面上已有下一问,用户当下看不到题干。题干 turn 在流结束后的 turn-exit 才写入。
- 触发条件:Agent 正文断言界面当前状态;前端在 `done` 后才刷新 case 快照。
- 根因:指令允许「过渡到界面上的下一步」;模型把尚未刷新的槽位说成已经出现。
- 修复:指令改为中性过渡(「接下来我们继续」),明确禁止断言界面当前状态。时序沿用已有 `await loadCaseSnapshot()`,不新增 `question.ready` 事件。
- 验证:`agent-voice-copy-contract``rectification-walkthrough-polish` 指令合同。未做登录后的真实页面点选。
- 防复发:正文不得写「界面上有/出现了下一问」。题干真源仍是服务端问题槽。
- 相关记录:BUG-441、BUG-449、BUG-471
- 复发自:BUG-471(直播看不到题干时,模型仍被要求过渡到「界面上的下一步」)
- 修复版本:待发布
## BUG-482 | 同域采集问句逐字复读
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`USER_COLLECT_QUESTION_RETRY``spokenFollowupForUser``buildMethodFollowupPlan`
- 用户现象:「感情这边,还记得哪年认真在一起、分开,或结婚吗?」在历史里逐字出现两次。第一次用工作事件回答后重问正确,但措辞完全一样。
- 触发条件:该域 collect focus 已关闭且未被拒答,覆盖仍未完成,下一问再次采集同一域。
- 根因:采集 copy 每域只有一句;没有用结构化「已问过」状态切换第二措辞。
- 修复:每域增加 retry 变体。`closedCollectFocuses` 里该域 collect 已建立且 status 不是 declined 时设 `collect_retry`。不做正文字符串匹配。
- 验证:`rectification-walkthrough-polish` 同域 retry`agent-voice-copy-contract` 可见文案词表。未做登录后的真实页面点选。
- 防复发:同域重问必须换第二措辞;判定只用 focus 结构化状态,不读正文。
- 相关记录:BUG-028、BUG-087
- 修复版本:待发布
## BUG-483 | 采用后仍挂着采用前的采集题,没有切入前事核对
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`persistNextInterviewIfIdle`、accept 路由、`.rectification-question-slot` 渲染位置
- 用户现象:采用 04:53 成功后,当前问题仍是采用前的财务采集,显示在候选卡下方、脱离对话流。预期是最多两件前事核对。
- 触发条件:accept RPC 成功,active focus 仍是 collectidle 见任何 activeFocus 就直接返回。
- 根因:`buildMethodFollowupPlan` 在 accepted 时会出 reverse_verify,但 idle 被旧 collect 挡住;accept 成功后也不替换 focus。问题槽是消息列表的兄弟节点。
- 修复:`accepted_time` 存在且当前 focus 不是 reverse_verify/oos 时,先 skipped 关闭再 persist 下一问。accept 成功后调用同一 idle。问题槽改到最新一条消息内、候选卡之后。GET 不作为主变更路径。
- 验证:`rectification-walkthrough-polish` 采用后不变量;`rectification-eight-method` reverse_verify`rectification-spoken-collect` 槽位合同。未做登录后的真实页面点选。
- 防复发:`accepted_time` 非空时 `current_question` 不得仍是采用前 collect。问题槽必须留在对话流内。
- 相关记录:BUG-117、BUG-120
- 修复版本:待发布
## BUG-484 | overlay `can_adopt` 与引擎出牌/采用不一致
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`deliveryCapability`、GET overlay `can_adopt`、offer/accept 可执行性
- 用户现象:职业从未被问,overlay 判 `can_adopt:false`,但 offer 工具与引擎 `accept_allowed=true` 仍出牌且采用成功。
- 触发条件:blocking coverage 未齐(含 occupation 路由缺口),引擎 ceiling 已放行采用。
- 根因:`deliveryCapability``methodCoverageAll` 和训练门绑进 `locallySelectable`。BUG-472 已把 occupation 移出 blocking 集合,其余 coverage 仍挡 overlay 采用。
- 修复:方案 1(对齐引擎)。coverage 从采用门移除,只保留为问询路由优先级。训练门 3/2、`keep_collecting` 证据下限、引擎 ceiling、确认门不变。`canAdopt` 为真且 nextAction 已是出牌/采用时,仍持久化 `deferred_followup` 里的剩余区分题与未覆盖 blocking collect;点选回执不得用采用口播覆盖已写入的下一题题干。职业/财务/已覆盖层的耗尽采集在出牌态仍跳过。不 bump Skill 10.0.14;「职业挡出牌」的 skill 文本偏差记在本条,下次 skill 版本再改。
- 验证:`rectification-walkthrough-polish` overlay 与引擎对齐;`rectification-occupation-coverage-exit``rectification-decide-next-action``rectification-range-offer-deadend``rectification-answer-choice` 点选后仍持久化下一张带年区分卡与家人采集;`rectification-decision-authority` 非终态出口仍等待家人采集写入。`./node_modules/.bin/tsc --noEmit` 与四组测试见当轮记录。未做登录后的真实页面点选。
- 防复发:overlay `can_adopt` 必须与 offer/accept 实际可执行性一致。不得把 coverage 重新写回采用门。不得放宽确认门。不得因为 `canAdopt` 就跳过剩余区分题或未覆盖的 dasha/D9/D10/家人采集。点选回执在下一题已写入时不得改写成采用口播。
- 相关记录:BUG-453、BUG-463、BUG-472
- 复发自:BUG-472occupation 移出 blocking 后,deliveryCapability 仍用 coverageComplete 挡采用)
- 修复版本:待发布
## BUG-485 | 生时校正开场正文与问题槽同时提问
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:POST `/api/rectification/agent` opening、`openingBrief``.rectification-question-slot__prompt`、口述采集直播界面
- 用户现象:开场助手气泡已经邀请用户说一件经历(例如哪年上大学、哪年换工作),气泡下方问题槽再用一句几乎相同的采集题再问一遍。用户发出「2016 年 9 月上大学」后,分析中的时间线下面仍挂着同一句旧题。
- 触发条件:新 Case `action=opening``current_question.kind=collect_spoken`;或在该采集题仍有效时提交下一条自由文本。
- 根因:两条规则并行。BUG-471 规定口述题干真源是 GET `current_question.prompt`,必须在问题槽可见。开场模型却在 `current_question` 落库之前生成正文,opening brief 还写「当前可询问范围」,于是正文自己提问。轮末 `persistNextInterviewIfIdle` 再写入 `GENERIC_COLLECT_QUESTION``shouldPersistFocusPromptTurn` 只挡住第二条助手消息,挡不住问题槽。采集题槽也没有选择题那样的 `!busy` 门,分析中仍渲染旧题。
- 修复:开场流开始前先 `persistNextInterviewIfIdle`,让 read-case 能看到已持久化题干。opening brief 与系统指令改为:题干由问题槽承担,正文只打招呼、不得提问或举大学/工作/搬家的例子。问题槽口述题干与输入框 describedBy 在 `busy` / 重生成时隐藏,与选择卡一致。不 bump Skill,不改 Python 引擎,不放宽确认门。
- 验证:`rectification-spoken-collect` 锁开场先持久化、brief 不再写「可询问范围」、槽位 `showCollectSpokenPrompt``!busy``rectification-v9-agent` 锁 opening brief`agent-voice-copy-contract` 锁开场不得举例提问。
- 防复发:口述采集直播题干只由问题槽展示。开场正文不得并行提问。采集槽与选择卡一样在忙碌时隐藏。不得用正文字符串判断「有没有问过」。不得把题干再追加成第二条 opening 消息。
- 相关记录:BUG-441、BUG-449、BUG-461、BUG-469、BUG-471
- 复发自:BUG-469opening 正文仍提问)与 BUG-471(槽位必须可见题干)同时生效
- 修复版本:待发布
## BUG-486 | 个人报告财富章有 D2/D11 回执却无结构化分盘,终态 parse 整份失败
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`buildReportEvidenceBundleV2``generatePersonalReport``readAllVargaCharts` / `readVargaHouses``POST /api/reports` 后台生成、`report_schema_invalid`
- 用户现象:计费配置可用后,完整个人报告创建成功但生成停在失败;列表 `status=failed``failureCode=report_schema_invalid`。日志内层是 `final_parse_rejected`,进度可到约 85%,财富章可能已写成 ready,事业/婚恋/时机没有章节行。
- 触发条件:请求含财富主题;引擎 `modules.varga_full` 使用 `D2_Hora` / `D11_Rudramsa` 这类带流派后缀的键;工作流把 D2/D11 标成已执行。财富章因此被标成 write,终态文档却没有 `charts[].id` 为 D2/D11。
- 根因:两层叠加。提取只把 `^D\d{1,3}$` 当成分盘 id,丢掉 `D2_Hora` / `D11_Rudramsa``readVargaHouses` 也只认 D9/D10。主题计划仍可用 D2/D11 回执闭合财富章。组装后 `findThemeCoverageViolations` 要求财富主题必须带结构化 D2 与 D11,最终 parse 失败,公开码压成 `report_schema_invalid`
- 修复:把文档允许的分盘键归一成 D2/D9/D10/D11/D24(含 `D2_Hora` 等别名),占据星体走 celestial allowlist。write 主题若缺少对应结构化分盘,降成 blocked disclosure,不再组装成会解析失败的文档。终态 parse 失败时把缺分盘与 hash 失配从笼统的 `final_parse_rejected` 分出来;日志仍只写 allowlist token,不含用户或模型原文。不放宽 `.strict()` 文档 schema,也不改 assertionLevel 规则。
- 验证:`frontend/tests/personal-report-generation.test.ts``D2_Hora` / `D11_Rudramsa` 抽出 D2/D11 且财富章成为 claim`frontend/tests/personal-report-generation-v2.test.ts` 财富 write 只有 D1 时 ready 且财富进 blocked`frontend/tests/personal-report-contract.test.ts` 财富主题缺 D2/D11 仍拒绝。Gitea run 2298 在 `report-interpretive-facts.test.ts` 6 fail:夹具只有 D2/D11/D24 回执、没有 `varga_full`,降级后 wealth/education 卡被拿掉。补上结构化 D2/D11/D24 后该文件本地绿,并锁「仅回执则 demote」。未用登录会话在 staging 重生。
- 防复发:文档分盘 id 必须从引擎 `varga_full` 别名归一,不得只认 `D2` 这种短键。财富/事业/婚恋/教育 write 主题缺对应结构化分盘时必须降级 blocked,不得把 `final_parse_rejected` 当正常完成。新增分盘别名必须同时覆盖提取与文档 `CHART_IDS`。改 `REQUIRED_THEME_CHARTS` 或 demote 后必须跑 `frontend/tests/report-interpretive-facts.test.ts`;期望 write 主题 claim card 的夹具必须带对应结构化分盘,不能只标 machine section used。
- 相关记录:BUG-352、BUG-451、BUG-467
- 复发自:无
- 修复版本:待发布
+2
View File
@@ -30,11 +30,13 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 |
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
| 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 |
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在问题槽,过渡用中性句。 |
| ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 |
## 红线(语气改不了这些)
- 不得虚构星盘事实或唯一出生分钟。
- 问题槽仍由服务端 focus 唯一所有;正文不得出题、复述或改写题干。
- 正文不得断言界面当前状态(「界面上有/出现了下一问」);过渡用「接下来我们继续」这类中性句。
- 交付/采用轮必须出现「不是已确认的唯一出生分钟」这一语义。
- 技法审计表仍在本命回答文末。
@@ -662,6 +662,19 @@ export async function POST(request: Request) {
};
try {
if (action === "opening") {
try {
await persistNextInterviewIfIdle({
accounting: accounting as never,
userId,
caseId,
});
} catch (error) {
console.warn(
`[rectification-v9] persist opening interview failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
}
const result = await runV9AgentTurn({
userId,
caseId,
@@ -122,6 +122,18 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: "暂时无法采用该候选时间", code: "candidate_accept_rejected" }, { status: 409 });
}
const result = row as Record<string, unknown>;
try {
const { persistNextInterviewIfIdle } = await import(
"@/lib/rectification-agentic/v9/answer-choice"
);
await persistNextInterviewIfIdle({
accounting,
userId: user.id,
caseId,
});
} catch {
// Accept already committed; the next successful turn-exit will replace the stale collect.
}
return NextResponse.json({
ok: true,
saved_time: result.saved_time,
+8
View File
@@ -2905,6 +2905,14 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
margin-block: var(--space-3);
margin-inline-start: var(--assistant-content-inset);
}
.rectification-message-wrap .rectification-question-slot {
width: calc(100% - var(--assistant-content-inset));
margin-inline-start: var(--assistant-content-inset);
}
.rectification-message-wrap .rectification-question-slot > .rectification-choice-card {
width: 100%;
margin-inline-start: 0;
}
.rectification-question-slot__prompt {
margin: 0;
color: var(--color-ink);
@@ -1047,6 +1047,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const showCollectSpokenPrompt = Boolean(
collectSpokenPrompt
&& !showLiveChoiceCard
&& !readonly
&& !busy
&& regeneratingMessageKey === null,
);
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const showMissingQuestion = Boolean(
caseSnapshotLoaded
@@ -1142,6 +1149,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
const isLatestMessage = message.renderKey === messages[messages.length - 1]?.renderKey;
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
@@ -1185,9 +1193,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{isLatestMessage && (
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}`}
card={choiceCard}
pending={busy}
disabled={readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{showCollectSpokenPrompt && (
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
{collectSpokenPrompt}
</p>
)}
{showMissingQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
{showUnavailableQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
</section>
)}
</div>
);
})}
{messages.length === 0 && (
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
@@ -1200,7 +1239,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{collectSpokenPrompt && !showLiveChoiceCard && !readonly && (
{showCollectSpokenPrompt && (
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
{collectSpokenPrompt}
</p>
@@ -1216,6 +1255,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</p>
)}
</section>
)}
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
{savedTime}
@@ -1244,7 +1284,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)}
<ChatComposer
inputRef={composer}
describedBy={collectSpokenPrompt && !showLiveChoiceCard && !readonly
describedBy={showCollectSpokenPrompt
? collectSpokenPromptId
: undefined}
value={draft}
@@ -1254,7 +1294,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
: collectSpokenPrompt
: showCollectSpokenPrompt
? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
+1 -1
View File
@@ -479,7 +479,7 @@ export function findChartSetViolations(document: ReportDocument): readonly strin
return violations;
}
const REQUIRED_THEME_CHARTS: Readonly<Record<string, readonly (typeof CHART_IDS)[number][]>> = {
export const REQUIRED_THEME_CHARTS: Readonly<Record<string, readonly (typeof CHART_IDS)[number][]>> = {
career: ["D10"],
wealth: ["D2", "D11"],
marriage: ["D9"],
+184 -73
View File
@@ -3,12 +3,13 @@ import {
computeEvidenceHash,
safeParseServerReportDocument,
} from "./personal-report-contract.server-core.ts";
import type {
ClaimStatus,
EvidenceAppendix,
ReportDepth,
ReportDocumentV1,
ReportDocumentV2,
import {
REQUIRED_THEME_CHARTS,
type ClaimStatus,
type EvidenceAppendix,
type ReportDepth,
type ReportDocumentV1,
type ReportDocumentV2,
} from "./personal-report-contract.ts";
import {
PersonalReportAgentOutputError,
@@ -307,51 +308,84 @@ function readHouses(
return houses;
}
const DOCUMENT_VARGA_CHART_IDS = ["D2", "D9", "D10", "D11", "D24"] as const;
type DocumentVargaChartId = (typeof DOCUMENT_VARGA_CHART_IDS)[number];
const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = {
D2: ["D2_Hora", "D2"],
D9: ["D9_Navamsa", "D9"],
D10: ["D10_Dasamsa", "D10"],
D11: ["D11_Rudramsa", "D11"],
D24: ["D24_Siddhamsa", "D24"],
};
function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | null {
const match = /^(D(?:24|11|10|9|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase());
return match ? match[1] as DocumentVargaChartId : null;
}
/**
* Divisional charts in the real engine live under modules.varga_full with keys
* D9_Navamsa / D10_Dasamsa (or D9 / D10). Each varga is {Ascendant: {sign_idx|
* sign}, <planet>: {sign_idx|sign}, _meta, _dignity, ...} there are no house
* arrays. House signs are derived whole-sign from the divisional ascendant and
* occupants from each planet's sign index (the same derivation the engine uses
* for D11). Nothing is fabricated; missing varga data simply omits the chart.
* such as D2_Hora / D9_Navamsa / D10_Dasamsa / D11_Rudramsa (or D2 / D9 / D10 /
* D11). Each varga is {Ascendant: {sign_idx|sign}, <planet>: {sign_idx|sign},
* _meta, _dignity, ...} there are no house arrays. House signs are derived
* whole-sign from the divisional ascendant and occupants from each planet's
* sign index. Missing or non-document vargas are omitted; occupant names go
* through the celestial allowlist.
*/
function deriveVargaHousesFromEngine(
varga: JsonRecord,
): Readonly<{
ascIndex: number;
degree: number;
houses: ReportEvidencePacket["chart"]["houses"];
}> | null {
const ascendant = record(varga.Ascendant);
const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null;
if (ascIndex === null) return null;
const occupants: string[][] = Array.from({ length: 12 }, () => []);
for (const [name, item] of Object.entries(varga)) {
if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue;
const row = record(item);
const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null;
if (planetIndex === null) continue;
const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1;
const safeName = safeCelestialName(name);
if (safeName) occupants[house - 1].push(safeName);
}
return {
ascIndex,
degree: finiteNumber(ascendant?.degree_in_sign ?? ascendant?.degree) ?? 0,
houses: Array.from({ length: 12 }, (_, index) => ({
number: index + 1,
sign: SIGNS[((ascIndex + index) % 12 + 12) % 12],
signDerived: true,
occupants: occupants[index].slice(0, 12),
})),
};
}
function readVargaHouses(
vargaFull: JsonRecord | null,
): ReportEvidencePacket["chart"]["vargaHouses"] {
if (!vargaFull) return [];
const result: { id: "D9" | "D10"; houses: ReportEvidencePacket["chart"]["houses"] }[] = [];
const variants: Readonly<Record<"D9" | "D10", readonly string[]>> = {
D9: ["D9_Navamsa", "D9"],
D10: ["D10_Dasamsa", "D10"],
const result: ReportEvidencePacket["chart"]["vargaHouses"][number][] = [];
const seen = new Set<DocumentVargaChartId>();
const take = (id: DocumentVargaChartId, raw: unknown): void => {
if (seen.has(id)) return;
const varga = record(raw);
if (!varga) return;
const derived = deriveVargaHousesFromEngine(varga);
if (!derived) return;
seen.add(id);
result.push({ id, houses: derived.houses });
};
for (const [id, keys] of Object.entries(variants) as ReadonlyArray<readonly ["D9" | "D10", readonly string[]]>) {
const varga = keys
.map((key) => record(vargaFull[key]))
.find((entry): entry is JsonRecord => entry !== null);
if (!varga) continue;
const ascendant = record(varga.Ascendant);
const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null;
if (ascIndex === null) continue;
const occupants: string[][] = Array.from({ length: 12 }, () => []);
for (const [name, item] of Object.entries(varga)) {
if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue;
const row = record(item);
const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null;
if (planetIndex === null) continue;
const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1;
const safeName = safeCelestialName(name);
if (safeName) occupants[house - 1].push(safeName);
}
const houses: ReportEvidencePacket["chart"]["houses"] = Array.from(
{ length: 12 },
(_, index) => ({
number: index + 1,
sign: SIGNS[((ascIndex + index) % 12 + 12) % 12],
signDerived: true,
occupants: occupants[index].slice(0, 12),
}),
);
result.push({ id, houses });
for (const id of DOCUMENT_VARGA_CHART_IDS) {
for (const key of VARGA_KEY_ALIASES[id]) take(id, vargaFull[key]);
}
for (const [rawId, rawValue] of Object.entries(vargaFull)) {
const id = canonicalDocumentVargaChartId(rawId);
if (id) take(id, rawValue);
}
return result;
}
@@ -904,38 +938,31 @@ function readAllVargaCharts(workflow: JsonRecord): ReportChartFact[] {
const vargaFull = record(modules.varga_full);
if (!vargaFull) return [];
const charts: ReportChartFact[] = [];
for (const [rawId, rawValue] of Object.entries(vargaFull)) {
const id = rawId.toUpperCase();
if (!/^D\d{1,3}$/.test(id)) continue;
const varga = record(rawValue);
if (!varga) continue;
const ascendant = record(varga.Ascendant);
const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null;
if (ascIndex === null) continue;
const occupants: string[][] = Array.from({ length: 12 }, () => []);
for (const [name, item] of Object.entries(varga)) {
if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue;
const row = record(item);
const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null;
if (planetIndex === null) continue;
const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1;
occupants[house - 1].push(name);
}
const seen = new Set<DocumentVargaChartId>();
const take = (id: DocumentVargaChartId, raw: unknown): void => {
if (seen.has(id)) return;
const varga = record(raw);
if (!varga) return;
const derived = deriveVargaHousesFromEngine(varga);
if (!derived) return;
seen.add(id);
charts.push({
id,
title: `${id} 分盘`,
ascendant: {
sign: SIGNS[ascIndex],
degree: finiteNumber(ascendant?.degree_in_sign ?? ascendant?.degree) ?? 0,
sign: SIGNS[derived.ascIndex],
degree: derived.degree,
},
houses: Array.from({ length: 12 }, (_, index) => ({
number: index + 1,
sign: SIGNS[(ascIndex + index) % 12],
signDerived: true,
occupants: occupants[index].slice(0, 20),
})),
houses: derived.houses,
planets: [],
});
};
for (const id of DOCUMENT_VARGA_CHART_IDS) {
for (const key of VARGA_KEY_ALIASES[id]) take(id, vargaFull[key]);
}
for (const [rawId, rawValue] of Object.entries(vargaFull)) {
const id = canonicalDocumentVargaChartId(rawId);
if (id) take(id, rawValue);
}
return charts;
}
@@ -950,8 +977,10 @@ function buildLegacyPacketFromBundle(bundle: ReportEvidenceBundleV2): ReportEvid
const d1 = bundle.charts.find((chart) => chart.id === "D1");
if (!d1?.ascendant) throw new ReportEvidenceInsufficientError("ascendant_missing");
const vargaHouses = bundle.charts
.filter((chart): chart is ReportChartFact & { id: "D9" | "D10" } => chart.id === "D9" || chart.id === "D10")
.map((chart) => ({ id: chart.id, houses: chart.houses }));
.flatMap((chart) => {
const id = canonicalDocumentVargaChartId(chart.id);
return id ? [{ id, houses: chart.houses }] : [];
});
const receiptById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt]));
return {
schemaVersion: "report_evidence_packet.v1",
@@ -1875,7 +1904,7 @@ export function buildReportEvidenceBundleV2(
? "candidate_directional_only"
: "reported_directional_only";
return finalizeReportEvidenceBundleV2({
return demoteThemesMissingRequiredCharts(finalizeReportEvidenceBundleV2({
schemaVersion: "report_evidence_bundle.v2",
subject: input.subject,
requestedThemes: requestedPlans.map((plan) => plan.theme),
@@ -1909,6 +1938,74 @@ export function buildReportEvidenceBundleV2(
birthTimePolicy,
deterministicClaimsForbiddenFor,
},
}));
}
function requiredStructuredChartsForTheme(theme: string): readonly string[] {
return REQUIRED_THEME_CHARTS[theme] ?? [];
}
/**
* A write-theme claim without its required structured charts would assemble
* into a document that the final parse rejects. Demote that theme to a blocked
* disclosure instead of failing the whole report.
*/
function demoteThemesMissingRequiredCharts(bundle: ReportEvidenceBundleV2): ReportEvidenceBundleV2 {
const presentCharts = new Set(bundle.charts.map((chart) => chart.id));
const demoted = bundle.claimCards.filter((card) => (
requiredStructuredChartsForTheme(card.theme).some((chartId) => !presentCharts.has(chartId))
));
if (demoted.length === 0) return bundle;
const extraReceipts: TechniqueExecutionReceipt[] = [];
const extraBlocked: ReportEvidenceBundleV2["blockedSections"][number][] = [];
const existingIds = new Set([
...bundle.executionLedger.map((receipt) => receipt.id),
...bundle.blockedSections.map((section) => section.id),
]);
for (const card of demoted) {
const missing = requiredStructuredChartsForTheme(card.theme)
.filter((chartId) => !presentCharts.has(chartId));
const refs: string[] = [];
for (const chartId of missing) {
const technique = `${card.theme}:${chartId}`;
let id = `ev-tech-${evidenceSlug(technique, "missing")}`;
if (existingIds.has(id)) id = `ev-tech-${evidenceSlug(`${technique}_chart`, "missing")}`;
extraReceipts.push({
id,
technique,
status: "blocked",
executed: false,
note: safeReceiptNote("blocked", false),
});
existingIds.add(id);
refs.push(id);
}
const blockedId = existingIds.has(`ev-blocked-${evidenceSlug(card.theme, "theme")}`)
? `ev-blocked-${evidenceSlug(`${card.theme}_charts`, "theme")}`
: `ev-blocked-${evidenceSlug(card.theme, "theme")}`;
extraBlocked.push({
id: blockedId,
theme: card.theme,
section: card.section,
reason: `缺少结构化分盘:${missing.join("、")}`,
missingTechniqueRefs: refs,
});
existingIds.add(blockedId);
}
const demotedThemes = new Set(demoted.map((card) => card.theme));
const { bundleHash: _bundleHash, ...content } = bundle;
void _bundleHash;
return finalizeReportEvidenceBundleV2({
...content,
claimCards: bundle.claimCards.filter((card) => !demotedThemes.has(card.theme)),
blockedSections: [...bundle.blockedSections, ...extraBlocked],
executionLedger: [...bundle.executionLedger, ...extraReceipts],
evidenceRefs: [
...bundle.evidenceRefs,
...extraReceipts.map(({ id, technique, status }) => ({ id, technique, status })),
],
themeNarrativeSeeds: bundle.themeNarrativeSeeds.filter((seed) => !demotedThemes.has(seed.theme)),
});
}
@@ -2053,6 +2150,7 @@ export function assembleReportDocument(
claimStatus: packet.blockedTechniques.length > 0 ? "blocked" : "single_system_inference",
}];
for (const varga of packet.chart.vargaHouses) {
if (varga.id !== "D9" && varga.id !== "D10") continue;
if (varga.houses.length === 0) continue;
charts.push({
id: varga.id,
@@ -2845,6 +2943,18 @@ export function classifyReportSchemaInnerReason(error: unknown): ReportSchemaInn
return "schema_invalid_unclassified";
}
export function classifyFinalParseInnerReason(
errors: readonly Readonly<{ path: string; message: string }>[],
): ReportSchemaInnerReason {
if (errors.some((error) => error.message.includes("requires structured"))) {
return "final_parse_missing_divisional_chart";
}
if (errors.some((error) => error.path === "provenance.evidenceHash")) {
return "final_parse_evidence_hash";
}
return "final_parse_rejected";
}
export function isPersonalReportGenerationAbort(error: unknown, signal?: AbortSignal): boolean {
if (signal?.aborted) return true;
return error instanceof Error && error.name === "AbortError";
@@ -3041,7 +3151,7 @@ async function generateSectionedPersonalReport(
const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle));
if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" };
const parsed = safeParseServerReportDocument(guarded.document);
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema("final_parse_rejected");
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), usage: deps.agent.getUsage?.() };
} catch (error) {
rethrowIfAborted(error, deps.signal);
@@ -3063,6 +3173,7 @@ export async function generatePersonalReport(
let bundle: ReportEvidenceBundleV2;
try {
bundle = validateReportEvidenceBundleV2(deps.bundle);
bundle = demoteThemesMissingRequiredCharts(bundle);
} catch (error) {
rethrowIfAborted(error, deps.signal);
return failSchema("bundle_invalid");
@@ -3142,7 +3253,7 @@ export async function generatePersonalReport(
}
const parsed = safeParseServerReportDocument(guarded.document);
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
return failSchema("final_parse_rejected");
return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
}
return {
status: "ready",
@@ -205,10 +205,10 @@ function deliveryCapability(input: {
holdout: HoldoutValidationStatus;
engineCeiling: EngineCapabilityCeiling;
confirmationAllowed: boolean;
coverageComplete: boolean;
trainingGateOpen: boolean;
}): DeliveryCapability {
const locallySelectable = input.separation.ranked.length > 0
&& input.coverageComplete
&& input.trainingGateOpen
&& input.stopClass?.kind !== "keep_collecting";
return {
canAdopt: locallySelectable && input.engineCeiling.acceptanceAllowed,
@@ -246,7 +246,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
holdout,
engineCeiling: input.engineCeiling,
confirmationAllowed,
coverageComplete: input.methodCoverageAll && input.trainingGateOpen !== false,
trainingGateOpen: input.trainingGateOpen !== false,
});
if (input.snapshotCurrent === false) {
@@ -39,9 +39,23 @@ export const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
other: "也可以再说一件你记得大概时间的事。",
};
/** Second phrasing when that domain's collect focus was already established and not declined. */
export const USER_COLLECT_QUESTION_RETRY: Readonly<Record<string, string>> = {
relationship: "回到感情这边——刚才说的工作我记下了,哪年认真在一起或分开还记得吗?",
career: "回到工作这边——刚才那件我记下了,哪年入职或换工作还记得吗?",
family: "再问一次家里:结婚、添丁或住院,大概哪年?",
occupation: "你平时主要做什么工作?刚才还没说到这块。",
education: "回到上学这边——哪年升学、转学或大考,还记得吗?",
relocation: "搬家或开始长期住外地,大概是哪年?",
finance: "钱的方面再对一下:哪年收入明显变过、有过大笔支出,或欠过债?",
health_pressure: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?",
other: "也可以再说一件你记得大概时间的事,跟刚才那件分开就好。",
};
export const RECTIFICATION_USER_COPY = {
genericCollectQuestion: GENERIC_COLLECT_QUESTION,
collectQuestionByDomain: USER_COLLECT_QUESTION,
collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY,
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
@@ -185,6 +199,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.continueCollectFallback,
...Object.values(USER_COLLECT_QUESTION),
...Object.values(USER_COLLECT_QUESTION_RETRY),
];
return values;
}
@@ -225,7 +225,7 @@ function openingBrief(dossier: V9CaseDossier): string {
`Case 状态:${dossier.case.status}`,
`出生时间不确定类型:${uncertaintyType}`,
`已有证据摘要:已确认 ${confirmed.length} 条,待澄清或待确认 ${pending.length}${domains.length ? `;已覆盖 ${domains.join("、")}` : ""}`,
"当前可询问范围:只围绕用户真实经历、真实记忆中的日期精度与当前 active focus;不要要求一次说完。",
"当前 active focus 的题干由服务端问题槽展示。正文只打招呼,说明可以慢慢说、记得大概年份即可,不要要求一次说完。不要提问,不要举大学、工作、搬家的例子。",
].join("\n");
}
@@ -45,14 +45,18 @@ import {
type PersistChoiceActionInput,
type V9CaseDossier,
} from "./tool-service";
import type { ChoiceKey } from "./choice-card";
import { isPersistedFocusId, type ChoiceKey } from "./choice-card";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion } from "./server-focus";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
exhaustionSpokenCollectFollowup,
GENERIC_COLLECT_QUESTION,
spokenCollectFallbackFollowup,
spokenFollowupForUser,
type MethodCoverage,
type MethodFollowup,
type MethodFollowupPlan,
} from "./method-followup";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet";
@@ -78,14 +82,36 @@ function openingRangeFromDossier(dossier: {
return openingRangeFromCandidateRange(dossier.case?.candidateRange ?? null);
}
function interviewToPersist(plan: MethodFollowupPlan): MethodFollowup | null {
return plan.next_followup ?? plan.deferred_followup ?? null;
}
function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): boolean {
if (!followup) return false;
return followup.source === "event_probe"
|| followup.source === "varga_observation"
|| followup.source === "precision_stage"
|| followup.source === "nakshatra_boundary"
|| followup.intent === "distinguish_candidates";
}
function shouldSkipFollowupPersist(input: {
canAdopt: boolean;
nextAction: string;
followup: MethodFollowup | null;
methods?: readonly MethodCoverage[];
}): boolean {
return input.canAdopt
&& input.nextAction !== "ask_fact_collection"
&& input.nextAction !== "ask_candidate_discriminator"
&& input.nextAction !== "ask_holdout_validation";
if (!input.canAdopt) return false;
if (
input.nextAction === "ask_fact_collection"
|| input.nextAction === "ask_candidate_discriminator"
|| input.nextAction === "ask_holdout_validation"
) {
return false;
}
if (input.methods && !blockingMethodsCovered(input.methods)) return false;
if (isRemainingDiscriminatorFollowup(input.followup)) return false;
return true;
}
function adoptHostNarration(input: {
@@ -339,21 +365,6 @@ export async function persistNextInterviewAfterChoice(input: {
nextAction: ReturnType<typeof publicNextAction>;
birthDate?: string | null;
}): Promise<{ hostNarration: string; choiceReady: boolean; persisted?: boolean }> {
if (shouldSkipFollowupPersist({
canAdopt: input.nextAction.can_adopt,
nextAction: input.nextAction.type,
})) {
return {
hostNarration: adoptHostNarration({
credibleRange: input.nextAction.credible_range,
representativeTime: input.nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
receipt: input.dossier.latestResult?.decisionReceipt,
}),
choiceReady: false,
persisted: false,
};
}
const latest = input.dossier.latestResult
? {
...input.dossier.latestResult,
@@ -382,7 +393,24 @@ export async function persistNextInterviewAfterChoice(input: {
candidatesSeparated: input.nextAction.type !== "ask_candidate_discriminator"
&& input.nextAction.type !== "ask_holdout_validation",
});
const followup = plan.next_followup;
const followup = interviewToPersist(plan);
if (shouldSkipFollowupPersist({
canAdopt: input.nextAction.can_adopt,
nextAction: input.nextAction.type,
followup,
methods: plan.methods,
})) {
return {
hostNarration: adoptHostNarration({
credibleRange: input.nextAction.credible_range,
representativeTime: input.nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
receipt: input.dossier.latestResult?.decisionReceipt,
}),
choiceReady: false,
persisted: false,
};
}
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
@@ -394,6 +422,9 @@ export async function persistNextInterviewAfterChoice(input: {
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
return { hostNarration: open.prompt, choiceReady: true };
}
if (open?.kind === "collect_spoken" && open.prompt) {
return { hostNarration: open.prompt, choiceReady: false };
}
if (followup?.choice_frame) {
const spokenFollowup = spokenCollectFallbackFollowup(followup);
const spoken = spokenFollowupForUser(spokenFollowup) ?? GENERIC_COLLECT_QUESTION;
@@ -574,12 +605,39 @@ export async function applyCollectFocusDenial(
};
}
export function isStalePreAdoptFocus(
acceptedTime: string | null | undefined,
focus: { intent?: string | null } | null | undefined,
): boolean {
if (!acceptedTime || !focus) return false;
const intent = focus.intent ?? "";
return intent !== "reverse_verify" && intent !== "out_of_sample_check";
}
export async function persistNextInterviewIfIdle(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const staleFocus = dossier.conversationSummary.activeFocus;
const staleFocusId = staleFocus?.id;
if (
isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus)
&& isPersistedFocusId(staleFocusId)
) {
try {
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
focusId: staleFocusId,
status: "skipped",
});
dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
} catch (error) {
console.warn(
`[rectification-v9] close stale pre-adopt focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
}
}
if (dossier.conversationSummary.activeFocus) {
return { persisted: false, choiceReady: false, hostNarration: null };
}
@@ -591,7 +649,24 @@ export async function persistNextInterviewIfIdle(input: {
birthDate = null;
}
const decision = decideFromDossier(dossier, { birthDate });
if (shouldSkipFollowupPersist(decision)) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: null,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
...catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
});
const followup = interviewToPersist(plan);
if (shouldSkipFollowupPersist({
canAdopt: decision.canAdopt,
nextAction: decision.nextAction,
followup,
methods: plan.methods,
})) {
return {
persisted: false,
choiceReady: false,
@@ -613,18 +688,7 @@ export async function persistNextInterviewIfIdle(input: {
decisionReceipt: dossier.latestResult?.decisionReceipt,
});
}
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: null,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
...catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
});
if (!plan.next_followup) {
if (!followup) {
return { persisted: false, choiceReady: false, hostNarration: null };
}
const nextAction = publicNextAction(decision);
@@ -748,6 +812,7 @@ async function persistApplied(
let nextInterviewPersisted = false;
let nextChoiceReady = false;
let hostNarration = input.narration;
let skippedNextInterview = false;
if (
command.deferFollowup !== true
&& input.userStopped !== true
@@ -763,8 +828,9 @@ async function persistApplied(
birthDate,
});
nextChoiceReady = nextInterview.choiceReady;
skippedNextInterview = nextInterview.persisted === false;
if (nextInterview.hostNarration) {
hostNarration = nextInterview.persisted === false
hostNarration = skippedNextInterview
? `${input.narration}
${nextInterview.hostNarration}`
@@ -790,7 +856,8 @@ ${nonConvergingRangeNarration({
variant: "delivery",
}, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt)
: null;
if (adoptionNarration || completedRangeNarration) {
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion) {
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
}
@@ -281,6 +281,8 @@ function eventQuestionPrompt(
const time = period.trim();
const topic = family.replace(/[?。]+$/g, "").trim();
if (kind === "varga_style") {
const fromProbe = clippedCopy(probe?.user_meaning, 4, 80);
if (fromProbe && /[?]$/.test(fromProbe)) return fromProbe;
return domain === "relationship"
? "亲密关系里,你更接近哪一种相处方式?"
: "平时做事,你更接近下面哪一种?";
@@ -492,7 +494,7 @@ export function mergeChoiceCard(
focus_id?: string | null;
} = {},
): RectificationChoiceCard | null {
if (!copy) return null;
if (!copy?.prompt.trim()) return null;
return {
question_id: meta.question_id ?? frame.question_id,
method_id: frame.method_id,
@@ -79,6 +79,7 @@ import { overlayChoicePromptFromSpoken } from "./turn-narration.ts";
import {
GENERIC_COLLECT_QUESTION,
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
} from "../user-copy.ts";
export { GENERIC_COLLECT_QUESTION };
@@ -168,6 +169,7 @@ export type MethodFollowup = Readonly<{
}>[];
selection_score?: number;
probe_id?: string;
collect_retry?: boolean;
}>;
export type MethodFollowupPlan = Readonly<{
@@ -361,6 +363,30 @@ function declinedDomains(
return domains;
}
function domainCollectFocusAsked(
topics: readonly Readonly<Record<string, unknown>>[],
domain: string,
): boolean {
return topics.some((topic) => {
const status = typeof topic.status === "string" ? topic.status : "";
if (status === "active" || status === "declined") return false;
const target = typeof topic.target_domain === "string"
? topic.target_domain
: typeof topic.targetDomain === "string"
? topic.targetDomain
: null;
const questionId = typeof topic.questionId === "string"
? topic.questionId
: typeof topic.question_id === "string"
? topic.question_id
: "";
const intent = typeof topic.intent === "string" ? topic.intent : "";
if (questionId.startsWith(`collect:${domain}:`)) return true;
return target === domain
&& (intent === "collect_method_evidence" || intent === "");
});
}
function occupationCollectFocusClosed(
topics: readonly Readonly<Record<string, unknown>>[],
): boolean {
@@ -949,8 +975,10 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string |
if (!followup) return null;
if (followup.choice_frame) return serverOwnedChoiceCopy(followup.choice_frame)?.prompt ?? null;
if (followup.intent !== "collect_method_evidence") return null;
const base = USER_COLLECT_QUESTION[followup.domain ?? ""]
?? GENERIC_COLLECT_QUESTION;
const domain = followup.domain ?? "";
const base = followup.collect_retry === true
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION)
: (USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION);
const period = followup.year_label
?? (followup.probe_year && followup.probe_year > 0 ? `${followup.probe_year} 年前后` : null);
return period ? `${period}${base}` : base;
@@ -1375,8 +1403,16 @@ export function buildMethodFollowupPlan(input: {
...(input.evidenceCollectionProbes ?? []),
].some((probe) => probe.semantic_key === base.semantic_key);
const ownedProbe = keyed ? null : followupOwnedProbe(base);
const askedRows = [
...(input.closedCollectFocuses ?? []),
...(input.declinedTopics ?? []),
];
const collectRetry = base.intent === "collect_method_evidence"
&& typeof base.domain === "string"
&& domainCollectFocusAsked(askedRows, base.domain);
return {
...base,
...(collectRetry ? { collect_retry: true } : {}),
choice_frame: attach
? buildChoiceFrame(base, {
observations: input.observations,
@@ -10,7 +10,7 @@ import {
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { spokenFollowupForUser, type MethodFollowup } from "./method-followup";
import { spokenFollowupForUser, spokenCollectFallbackFollowup, type MethodFollowup } from "./method-followup";
import { refinementFromDecisionReceipt } from "./refinement-packet";
import {
setV10ConversationFocus,
@@ -299,6 +299,22 @@ async function persistCollectFocus(input: {
}
}
async function persistSpokenChoiceFallback(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
activeFocus: ConversationFocus | null;
followup: MethodFollowup;
}): Promise<PersistServerFocusResult> {
return persistCollectFocus({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
activeFocus: input.activeFocus,
followup: spokenCollectFallbackFollowup(input.followup),
});
}
export async function persistServerOwnedFocus(input: {
accounting: AccountingClient;
userId: string;
@@ -319,12 +335,13 @@ export async function persistServerOwnedFocus(input: {
}
if (!frame) {
if (followup.intent === "distinguish_candidates") {
return {
status: "invalid_choice_schema",
focus: input.activeFocus,
questionId: null,
prompt: null,
};
return persistSpokenChoiceFallback({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
activeFocus: input.activeFocus,
followup,
});
}
if (followup.intent === "collect_method_evidence") {
return persistCollectFocus({
@@ -356,11 +373,23 @@ export async function persistServerOwnedFocus(input: {
prompt: null,
};
}
const copy = serverOwnedChoiceCopy(frame);
if (!copy) {
if (followup.intent === "distinguish_candidates") {
return persistSpokenChoiceFallback({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
activeFocus: input.activeFocus,
followup,
});
}
return { status: "invalid_choice_schema", focus: input.activeFocus, questionId, prompt: null };
}
const schema = expectedAnswerSchemaFor(frame, questionId, input.decisionReceipt, followup);
if (!schema?.choice) {
return { status: "invalid_choice_schema", focus: input.activeFocus, questionId, prompt: null };
}
const copy = serverOwnedChoiceCopy(frame);
const prompt = copy?.prompt ?? null;
const active = input.activeFocus;
if (
@@ -60,25 +60,21 @@ export async function finalizeSuccessfulTurnExit(input: {
// Read-only requests must never mutate the interview or create a focus.
return;
}
let focusCreated = false;
try {
const next = await persistNextInterviewIfIdle(input);
focusCreated = next.persisted;
await persistNextInterviewIfIdle(input);
} catch (error) {
console.warn(
`[rectification-v9] persist next interview after turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
try {
const repaired = await ensureNonTerminalTurnExit(input);
focusCreated ||= repaired.persisted;
await ensureNonTerminalTurnExit(input);
} catch (error) {
console.warn(
`[rectification-v9] nonterminal turn exit repair failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
throw error;
}
if (!focusCreated) return;
try {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const question = projectCurrentQuestion(dossier.conversationSummary.activeFocus);
+1 -1
View File
@@ -63,7 +63,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
1. rectification-read-case
2. display_date_label
3. rectification-record-evidence-batch
4. current_question UI 2-4 choice collect_spoken current_question
4. current_question UI 2-4 choice collect_spoken current_question
5. confirmation_allowed false 5 skill_verification_report80%/60%
6. Skill
2016 9
+1 -1
View File
@@ -39,7 +39,7 @@ export const claimStatusSchema = z.enum([
]);
export type ReportVargaHouses = Readonly<{
id: "D9" | "D10";
id: "D2" | "D9" | "D10" | "D11" | "D24";
houses: readonly ReportChartHouse[];
}>;
+28 -5
View File
@@ -64,6 +64,7 @@ import { publicChartForMinute } from "@/lib/rectification-candidate-result";
import {
buildMethodFollowupPlan,
buildNextUserAction,
spokenCollectFallbackFollowup,
} from "@/lib/rectification-agentic/v9/method-followup";
import { refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet";
import { rectificationLabel } from "@/lib/rectification-agentic/v9/rectification-label";
@@ -1005,23 +1006,45 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
// Ranking stays fail-open when compute is unavailable.
}
const { plan: collectingPlan, contrastPacket, decision } = sessionAwareFollowupForParsed(parsed, latest, { birthDate });
const persistedFocus = await persistServerOwnedFocus({
let followup = collectingPlan.next_followup;
let persistedFocus = await persistServerOwnedFocus({
accounting,
userId,
caseId,
activeFocus: parsed.conversationSummary.activeFocus,
decisionReceipt: latest.decisionReceipt,
followup: collectingPlan.next_followup,
followup,
});
const visibleFollowup = collectingPlan.next_followup?.intent === "distinguish_candidates"
if (
followup?.intent === "distinguish_candidates"
&& persistedFocus.status !== "created"
&& persistedFocus.status !== "already_open"
) {
followup = spokenCollectFallbackFollowup(followup);
persistedFocus = await persistServerOwnedFocus({
accounting,
userId,
caseId,
activeFocus: parsed.conversationSummary.activeFocus,
decisionReceipt: latest.decisionReceipt,
followup,
});
} else if (
followup?.intent === "distinguish_candidates"
&& persistedFocus.focus
&& isCollectFocusSchema(persistedFocus.focus.expectedAnswerSchema)
) {
followup = spokenCollectFallbackFollowup(followup);
}
const visibleFollowup = followup?.intent === "distinguish_candidates"
&& persistedFocus.status !== "created"
&& persistedFocus.status !== "already_open"
? null
: collectingPlan.next_followup;
: followup;
return {
collectingPlan: visibleFollowup === collectingPlan.next_followup
? collectingPlan
: { ...collectingPlan, next_followup: null },
: { ...collectingPlan, next_followup: visibleFollowup ?? null },
persistedFocus,
contrastPacket,
decision,
@@ -97,7 +97,12 @@ test("question-slot ownership stays in the rectification agent prompt", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const exit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8");
assert.match(agent, /正文不得提问、复述、改写或拼接题干/);
assert.match(agent, /开场轮同样适用/);
assert.match(agent, /不要举大学、工作、搬家的例子/);
assert.match(agent, /每轮正文 2-4 句/);
assert.match(agent, /正文不得断言界面当前状态/);
assert.match(agent, /接下来我们继续/);
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
assert.doesNotMatch(agent, /每轮正文只输出一句确认或承接/);
assert.doesNotMatch(route, /接下来请点选下面这一问/);
assert.doesNotMatch(exit, /接下来请点选下面这一问/);
@@ -283,6 +283,20 @@ test("v2 thematic sections require their real structured divisional charts", ()
});
assert.ok(findThemeCoverageViolations(unsupportedCareer).some((error) => error.includes("requires structured D10")));
assert.equal(safeParseReportDocument(unsupportedCareer).ok, false);
const unsupportedWealth = cloneV2();
unsupportedWealth.blockedConflictDisclosure = unsupportedWealth.blockedConflictDisclosure.filter(
(section) => section.theme !== "wealth",
);
unsupportedWealth.thematicNarrative.push({
...structuredClone(unsupportedWealth.thematicNarrative[0]),
id: "theme-wealth",
theme: "wealth",
});
const wealthViolations = findThemeCoverageViolations(unsupportedWealth);
assert.ok(wealthViolations.some((error) => error.includes("requires structured D2")));
assert.ok(wealthViolations.some((error) => error.includes("requires structured D11")));
assert.equal(safeParseReportDocument(unsupportedWealth).ok, false);
assert.equal(safeParseReportDocument(v2Fixture).ok, true);
});
@@ -14,6 +14,7 @@ import {
type TechniqueExecutionReceipt,
} from "../src/lib/report-evidence-bundle-v2.ts";
import {
classifyFinalParseInnerReason,
classifyReportSchemaInnerReason,
generatePersonalReport,
type GeneratePersonalReportResult,
@@ -606,6 +607,29 @@ test("plan binding failure consumes the single writer repair retry", async () =>
assert.equal(observed.calls, 2);
});
test("wealth write without structured D2/D11 charts demotes to blocked instead of failing schema parse", async () => {
const bundle = makeBundle({
themes: [{
theme: "wealth",
section: "财富结构",
refs: ["ev-tech-d2", "ev-tech-d11"],
}],
charts: [chart("D1")],
});
const sectionCalls: string[] = [];
const result = await runSectioned(
bundle,
sectionedAgent({ sectionCalls }),
inMemorySectionService(),
);
const document = readyV2(result);
assert.deepEqual(sectionCalls, []);
assert.deepEqual(document.thematicNarrative.map((section) => section.theme), []);
assert.equal(document.blockedConflictDisclosure.length, 1);
assert.equal(document.blockedConflictDisclosure[0].theme, "wealth");
assert.match(document.blockedConflictDisclosure[0].reason, /D2|D11|结构化分盘/);
});
test("schema inner reasons stay on the allowlist and never include model text", () => {
assert.equal(
classifyReportSchemaInnerReason(new Error("report_writer_theme_count_mismatch")),
@@ -619,6 +643,25 @@ test("schema inner reasons stay on the allowlist and never include model text",
classifyReportSchemaInnerReason(new Error("Unexpected token in JSON at position 12")),
"schema_invalid_unclassified",
);
assert.equal(
classifyFinalParseInnerReason([
{ path: "(guard)", message: "thematic section wealth requires structured D2 chart data or a blocked disclosure" },
{ path: "(guard)", message: "thematic section wealth requires structured D11 chart data or a blocked disclosure" },
]),
"final_parse_missing_divisional_chart",
);
assert.equal(
classifyFinalParseInnerReason([
{ path: "thematicNarrative.0.title", message: "String must contain at most 160 character(s)" },
]),
"final_parse_rejected",
);
assert.equal(
classifyFinalParseInnerReason([
{ path: "provenance.evidenceHash", message: "does not match recomputed evidence hash" },
]),
"final_parse_evidence_hash",
);
});
test("accepted birth time never creates a fake candidate range", async () => {
@@ -1306,6 +1306,65 @@ test("bundle v2 builder blocks incomplete wealth evidence without leaking workfl
}
});
function wealthClosedWorkflow(): Record<string, unknown> {
const workflow = pythonStyleChartPayload() as Record<string, unknown>;
const chart = workflow.chart as Record<string, unknown>;
const modules = chart.modules as Record<string, unknown>;
const vargaFull = modules.varga_full as Record<string, unknown>;
vargaFull.D2_Hora = {
_meta: { div: 2 },
Ascendant: { sign: "Cancer", sign_idx: 3 },
Sun: { sign: "Leo", sign_idx: 4 },
Moon: { sign: "Virgo", sign_idx: 5 },
AuthorizationSecretPlanet: { sign: "Libra", sign_idx: 6 },
};
vargaFull.D11_Rudramsa = {
_meta: { div: 11 },
Ascendant: { sign: "Scorpio", sign_idx: 7 },
Sun: { sign: "Sagittarius", sign_idx: 8 },
Moon: { sign: "Capricorn", sign_idx: 9 },
};
const machine = workflow.machine_evidence_packet as Record<string, unknown>;
const sections = machine.sections as Record<string, unknown>;
sections.D2 = { status: "used", source_path: "modules.varga_full.D2" };
sections.D11 = { status: "used", source_path: "modules.varga_full.D11" };
sections.Yoga = { status: "used", source_path: "modules.yogas" };
sections.Ashtakavarga = { status: "used", source_path: "modules.ashtakavarga" };
return workflow;
}
test("bundle v2 canonicalizes D2_Hora and D11_Rudramsa into structured D2/D11 charts for wealth", () => {
const bundle = buildReportEvidenceBundleV2({
workflows: [{ theme: "wealth", workflow: wealthClosedWorkflow() }],
subject: {
displayName: "测试用户",
birthTimeStatus: "accepted",
birthPlaceLabel: "北京",
},
requestedThemes: ["wealth"],
reportType: "personal_thematic",
presentationMode: "default",
skillSnapshot: {
name: "jyotish-vedic-astrology",
version: "6.9.14",
sha256: "a".repeat(64),
sourceCommit: "b".repeat(40),
},
});
assert.ok(bundle.charts.some((chart) => chart.id === "D2"));
assert.ok(bundle.charts.some((chart) => chart.id === "D11"));
assert.equal(bundle.charts.some((chart) => chart.id.includes("Hora") || chart.id.includes("Rudramsa")), false);
const d2 = bundle.charts.find((chart) => chart.id === "D2");
assert.ok(d2);
assert.equal(d2.houses.length, 12);
assert.equal(d2.houses[0].sign, "Cancer");
assert.ok(d2.houses[1].occupants.includes("Sun"));
assert.equal(d2.houses.some((house) => house.occupants.includes("AuthorizationSecretPlanet")), false);
assert.ok(bundle.claimCards.some((card) => card.theme === "wealth"));
assert.equal(bundle.blockedSections.some((section) => section.theme === "wealth"), false);
});
test("bundle v2 never treats an available-only machine section as executed evidence", () => {
const workflow = pythonStyleChartPayload() as Record<string, unknown>;
const consumer = workflow.consumer_context as Record<string, unknown>;
@@ -315,7 +315,12 @@ test("user stop skips remaining collection and holdout when candidates already e
],
holdoutValidation: "not_started",
});
assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped");
// 原断言 sessionOutcome=provisional_range_user_stopped
// → 新断言 sessionOutcome=adopt_representative。
// coverage 不再挡代表性采用;completionStatus 仍标记用户停止。确认门仍关。
assert.equal(stopped.sessionOutcome, "adopt_representative");
assert.equal(stopped.canAdopt, true);
assert.equal(stopped.canConfirmExactMinute, false);
assert.equal(stopped.completionStatus, "provisional_range_user_stopped");
assert.equal(stopped.validated, false);
});
@@ -548,7 +548,10 @@ test("nonterminal invariant 3: answer_choice response waits until incident fallb
const parsedIncident = parseV9CaseDossier(raw);
assert.ok(parsedIncident);
const decision = decideFromDossier(parsedIncident);
assert.equal(decision.canAdopt, false);
// 原断言 canAdopt=falsecoverage 未齐不得采用)→ 新断言 canAdopt=true。
// 问题 E 方案 1:coverage 只做问询路由,不挡 overlay 采用。确认门与引擎 ceiling 仍关。
// 本例仍继续采集家人层,nextAction 不得变成 offer_provisional_range。
assert.equal(decision.canAdopt, true);
assert.notEqual(decision.nextAction, "offer_provisional_range");
let activeFocus: Record<string, unknown> | null = null;
@@ -613,7 +616,9 @@ test("nonterminal invariant 3: answer_choice response waits until incident fallb
assert.ok(refreshed);
const currentQuestion = projectCurrentQuestion(refreshed.conversationSummary.activeFocus);
assert.equal(response.status, 200);
assert.equal(decision.canAdopt, false);
// 原断言 canAdopt=false → 新断言 canAdopt=truecoverage 不再挡采用)。
// 保留语义:answer_choice 出口仍必须先把家人采集题干写入 focus/turn,再返回 HTTP。
assert.equal(decision.canAdopt, true);
assert.ok(currentQuestion?.prompt);
assert.equal(currentQuestion.prompt, "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。");
const historyTurn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
@@ -309,7 +309,7 @@ test("training gate closed still withholds yearless varga cards", () => {
assert.notEqual(plan.next_followup?.intent, "distinguish_candidates");
});
test("training complete with no renderable distinguish card allows offering a range without adopt", () => {
test("training complete with no renderable distinguish card still allows adopt when the engine does", () => {
const blocked = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
@@ -317,11 +317,14 @@ test("training complete with no renderable distinguish card allows offering a ra
candidateScores: NARROW_LEAD,
discriminatorProbe: null,
});
// 原断言 canAdopt=false、sessionOutcome=provisional_range
// → 新断言 canAdopt=true、sessionOutcome=adopt_representative。
// coverage(含 occupation)只做问询路由,不再挡采用;确认门仍 fail-closed。
assert.equal(blocked.canOfferRange, true);
assert.equal(blocked.canAdopt, false);
assert.equal(blocked.canAdopt, true);
assert.equal(blocked.canConfirmExactMinute, false);
assert.equal(blocked.nextAction, "offer_provisional_range");
assert.equal(blocked.sessionOutcome, "provisional_range");
assert.equal(blocked.sessionOutcome, "adopt_representative");
const stillClosed = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
@@ -574,10 +574,13 @@ test("userStopped completes with a review-only provisional range", () => {
userStopped: true,
candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })),
});
assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped");
// 原值是 true;用户停止只结束追问,不能绕过 coverage 和 holdout 门槛。
assert.equal(stopped.canAdopt, false);
assert.equal(stopped.sessionOutcome, "adopt_representative");
// 原断言 sessionOutcome=provisional_range_user_stopped 且 canAdopt=false
// → 新断言 adopt_representative 且 canAdopt=true。
// 用户停止结束追问;coverage 不再挡代表性采用。确认门仍关。
assert.equal(stopped.canAdopt, true);
assert.equal(stopped.canOfferRange, true);
assert.equal(stopped.canConfirmExactMinute, false);
assert.equal(isNonConvergingRangeOffer(stopped), false);
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" });
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
@@ -110,11 +110,22 @@ function invalidStyleFollowup(
};
}
async function assertInvalidChoiceSkipped(followup: MethodFollowup) {
const accounting = fakeAccounting({}, {
fallback: () => {
throw new Error("invalid choice frame must not call the database");
},
async function assertUnrenderableChoiceFallsBackToSpoken(followup: MethodFollowup) {
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-31T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
@@ -124,8 +135,11 @@ async function assertInvalidChoiceSkipped(followup: MethodFollowup) {
decisionReceipt: null,
followup,
});
assert.equal(result.status, "invalid_choice_schema");
assert.equal(accounting.calls.length, 0);
assert.ok(result.status === "created" || result.status === "already_open");
assert.equal(result.focus?.intent, "collect_method_evidence");
const open = openQuestionFromPersistedFocus(result);
assert.equal(open?.kind, "collect_spoken");
assert.ok(open?.prompt?.trim());
}
function persistedFocus(questionId = "probe:education:2016") {
@@ -235,13 +249,13 @@ test("complete dynamic event options persist a server-owned focus", async () =>
});
});
test("non-renderable varga styles and empty event family skip focus persistence", async () => {
await assertInvalidChoiceSkipped(invalidStyleFollowup(
test("non-renderable varga styles and empty event family fall back to spoken collect", async () => {
await assertUnrenderableChoiceFallsBackToSpoken(invalidStyleFollowup(
[{ label: "巨蟹相处主动热情", answer_class: "yes" }],
"升学结果或学习环境出现明显变化",
"varga_style",
));
await assertInvalidChoiceSkipped(invalidStyleFollowup(DYNAMIC_STYLE_OPTIONS, " "));
await assertUnrenderableChoiceFallsBackToSpoken(invalidStyleFollowup(DYNAMIC_STYLE_OPTIONS, " "));
});
test("does not ask an already-answered discriminator probe again", async () => {
@@ -94,6 +94,7 @@ test("cases current_question drives the unified question slot", () => {
assert.match(chat, /current_question\?: unknown/);
assert.match(chat, /setCurrentQuestion\(nextQuestion\)/);
assert.match(chat, /className="rectification-question-slot"/);
assert.match(chat, /isLatestMessage/);
assert.match(chat, /currentQuestion\?\.kind === "choice"/);
assert.match(chat, /<RectificationChoiceCard/);
assert.match(chat, /onSelect=\{submitChoice\}/);
@@ -109,12 +110,17 @@ test("collect_spoken current_question renders the prompt in the question slot",
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
assert.match(chat, /const showCollectSpokenPrompt = Boolean\(/);
assert.match(chat, /showCollectSpokenPrompt && \(/);
assert.match(chat, /!busy/);
assert.match(chat, /showCollectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
assert.match(chat, /rectification-question-slot__prompt/);
// Former lock: `aria-describedby={collectSpokenPrompt` on the inline <Textarea>. The rectification
// surface now renders the shared ChatComposer (BUG-477) and hands the stem id in as `describedBy`,
// which the composer merges into the field's aria-describedby together with the count id.
assert.match(chat, /describedBy=\{collectSpokenPrompt && !showLiveChoiceCard && !readonly/);
// BUG-485: the live collect prompt (and its describedBy) must hide while a run is busy, matching
// choice cards, so the opening stem does not sit under “正在分析”.
assert.match(chat, /describedBy=\{showCollectSpokenPrompt/);
assert.match(
readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"),
/\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/,
@@ -343,6 +349,12 @@ test("agent route keeps question ownership in the server Case projection", () =>
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const turnExit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8");
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
const beforeRun = route.slice(
route.indexOf("async start(controller)"),
route.indexOf("const result = await runV9AgentTurn"),
);
assert.match(beforeRun, /if \(action === "opening"\)/);
assert.match(beforeRun, /persistNextInterviewIfIdle/);
assert.match(afterRun, /await finalizeSuccessfulTurnExit/);
assert.match(turnExit, /persistNextInterviewIfIdle/);
assert.doesNotMatch(afterRun, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/);
@@ -352,4 +364,6 @@ test("agent route keeps question ownership in the server Case projection", () =>
assert.doesNotMatch(successExit, /send\(\{\s*type:\s*"error"/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus|composeCollectSpokenAssistantText/);
assert.match(agentRun, /题干由服务端问题槽展示/);
assert.doesNotMatch(agentRun, /当前可询问范围/);
});
@@ -235,6 +235,8 @@ test("agent receives the exact server-owned case id for tool calls", async () =>
assert.match(openingPrompt, /服务端 opening brief/);
assert.match(openingPrompt, /不要要求一次说完/);
assert.match(openingPrompt, /当前 active focus/);
assert.match(openingPrompt, /不要提问/);
assert.doesNotMatch(openingPrompt, /当前可询问范围/);
assert.doesNotMatch(openingPrompt, /说明你会通过已发生的人生事件来校正出生时间/);
});
@@ -0,0 +1,602 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildCandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { isStalePreAdoptFocus } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import {
buildChoiceFrame,
parseRectificationChoiceCard,
serverOwnedChoiceCopy,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import {
buildMethodFollowupPlan,
buildNextUserAction,
spokenFollowupForUser,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { completeStyleOptions } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
import {
openQuestionFromPersistedFocus,
persistServerOwnedFocus,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { d9StyleLabel } from "../src/lib/rectification-agentic/v9/varga-type-tables.ts";
import {
CASE_ID,
FOCUS_ID,
OPEN_ENGINE_CAPABILITY_CEILING,
USER_ID,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
const D9_QUESTION = "亲密关系里,你更接近哪一种相处方式?";
const WALKTHROUGH_EVIDENCE = [
{
status: "confirmed" as const,
domain: "education",
datePrecision: "year" as const,
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_start",
},
{
status: "confirmed" as const,
domain: "relationship",
datePrecision: "year" as const,
occurredFrom: "2018-01-01",
occurredTo: null,
eventKind: "relationship_start",
},
{
status: "confirmed" as const,
domain: "career",
datePrecision: "year" as const,
occurredFrom: "2019-01-01",
occurredTo: null,
eventKind: "career_entry",
},
{
status: "confirmed" as const,
domain: "family",
datePrecision: "year" as const,
occurredFrom: "2020-01-01",
occurredTo: null,
eventKind: "family_event",
},
];
function d9WalkthroughPacket() {
return buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: ["05:00", "05:04"],
transitions: [
{ layer: "d9", at: "05:04", from_sign: "巨蟹座", to_sign: "狮子座" },
],
});
}
function d9StyleOptions() {
const completed = completeStyleOptions({
choiceKind: "varga_style",
styleOptions: [
{ sign: "巨蟹座", label: d9StyleLabel("巨蟹座"), answer_class: "yes" },
{ sign: "狮子座", label: d9StyleLabel("狮子座"), answer_class: "weak_yes" },
],
});
assert.equal(completed.ok, true);
if (!completed.ok) throw new Error("D9 style options");
return completed.options;
}
function d9Followup(frame: NonNullable<ReturnType<typeof buildChoiceFrame>>): MethodFollowup {
const probe = d9WalkthroughPacket().probes.find((item) => item.semanticKey.startsWith("varga.d9."));
assert.ok(probe);
return {
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: probe.question,
must_not_label: false,
choice_frame: frame,
source: "event_probe",
information_gain: probe.informationGain,
semantic_key: probe.semanticKey,
candidate_split_hash: probe.candidateSplitHash,
choice_kind: "varga_style",
candidate_ids: ["05:00", "05:04"],
expected_outcomes: probe.expectedOutcomes.map((row) => ({
answer_class: row.outcomeId,
supports: row.supportsCandidateIds,
conflicts: row.conflictsCandidateIds,
})),
style_options: d9StyleOptions(),
};
}
function focusFromArgs(args: Record<string, unknown>) {
return {
id: FOCUS_ID,
caseId: CASE_ID,
questionId: String(args.p_question_id ?? ""),
intent: String(args.p_intent ?? ""),
targetEvidenceId: null,
targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : null,
targetKind: null,
expectedAnswerSchema: (args.p_expected_answer_schema ?? {}) as Record<string, unknown>,
status: "active" as const,
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: null,
};
}
function persistAccounting() {
return 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-09-02T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
}
function assertChoiceInvariants(input: {
card: ReturnType<typeof parseRectificationChoiceCard> | ReturnType<typeof choiceCardFromCaseDossier>;
question: ReturnType<typeof projectCurrentQuestion>;
}) {
if (input.card) {
assert.ok(input.card.prompt?.trim(), "choice_card nonempty ⇒ prompt nonempty");
}
if (input.question?.kind === "choice") {
assert.ok(input.question.prompt?.trim(), "active choice focus ⇒ projectCurrentQuestion prompt nonempty");
}
}
test("walkthrough D9 probe question is in the 4-80 window and must survive frame assembly", () => {
const packet = d9WalkthroughPacket();
const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9."));
assert.ok(probe);
assert.equal(probe.question, D9_QUESTION);
assert.ok(probe.question.length >= 4 && probe.question.length <= 80);
const plan = buildMethodFollowupPlan({
evidence: WALKTHROUGH_EVIDENCE,
contrastPacket: packet,
sessionOutcome: "discriminate_candidates",
topCandidateTimes: ["05:00", "05:04"],
candidatesSeparated: false,
});
const followup = plan.next_followup;
assert.equal(followup?.intent, "distinguish_candidates", JSON.stringify({
intent: followup?.intent,
source: followup?.source,
method_id: followup?.method_id,
dropped: plan.dropped_probes,
}));
assert.equal(followup?.method_id, "d9_relationship");
const frame = followup?.choice_frame ?? null;
assert.ok(frame, "D9 probe must assemble a choice_frame");
assert.equal(frame?.prompt, D9_QUESTION);
const copy = frame ? serverOwnedChoiceCopy(frame) : null;
assert.ok(copy?.prompt, "assembled D9 copy must keep the probe question");
});
test("D9 probe shape with unwired prompt makes the choice invariants red", async () => {
const packet = d9WalkthroughPacket();
const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9."));
assert.ok(probe);
const wired = buildChoiceFrame({
method_id: "d9_relationship",
ask_theme: "relationship_style",
domain: "relationship",
user_prompt_hint: probe.question,
choice_kind: "varga_style",
semantic_key: probe.semanticKey,
style_options: d9StyleOptions(),
}, {
evidence: WALKTHROUGH_EVIDENCE,
probes: [{
year: 0,
year_label: "当前这几个候选",
domain: "relationship",
event_family: "相处方式更接近其中一种",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.informationGain,
semantic_key: probe.semanticKey,
candidate_split_hash: probe.candidateSplitHash,
candidate_ids: ["05:00", "05:04"],
expected_outcomes: probe.expectedOutcomes.map((row) => ({
answer_class: row.outcomeId,
supports: row.supportsCandidateIds,
conflicts: row.conflictsCandidateIds,
})),
choice_kind: "varga_style",
style_options: d9StyleOptions(),
}],
});
assert.ok(wired);
const unwired = { ...wired, prompt: "", period: "" };
assert.equal(serverOwnedChoiceCopy(unwired), null);
const accounting = persistAccounting();
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: d9Followup(unwired),
});
const open = openQuestionFromPersistedFocus(persisted);
const question = persisted.focus
? projectCurrentQuestion(persisted.focus)
: null;
const card = persisted.focus
? choiceCardFromCaseDossier({
evidence: WALKTHROUGH_EVIDENCE,
conversationSummary: {
activeFocus: persisted.focus,
declinedSkippedTopics: [],
},
latestResult: {
decisionReceipt: null,
selectionAllowed: false,
confirmationAllowed: false,
candidates: [
{ time: "05:00", rank: 1, relativeSupport: 0.6 },
{ time: "05:04", rank: 2, relativeSupport: 0.4 },
],
},
case: { acceptedTime: null, status: "collecting_evidence" },
})
: null;
assertChoiceInvariants({ card, question });
assert.ok(persisted.status === "created" || persisted.status === "already_open");
assert.equal(open?.kind, "collect_spoken");
assert.ok(open?.prompt?.trim());
assert.notEqual(question?.kind, "choice");
});
test("agent persistPlanFocus path must fail-closed when D9 choice copy is null", () => {
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
const persistPlan = tools.slice(tools.indexOf("const persistPlanFocus"));
assert.match(persistPlan, /spokenCollectFallbackFollowup/);
});
test("wired D9 persist keeps a nonempty choice prompt", async () => {
const packet = d9WalkthroughPacket();
const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9."));
assert.ok(probe);
const frame = buildChoiceFrame({
method_id: "d9_relationship",
ask_theme: "relationship_style",
domain: "relationship",
user_prompt_hint: probe.question,
choice_kind: "varga_style",
semantic_key: probe.semanticKey,
style_options: d9StyleOptions(),
}, {
evidence: WALKTHROUGH_EVIDENCE,
probes: [{
year: 0,
year_label: "当前这几个候选",
domain: "relationship",
event_family: "相处方式更接近其中一种",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.informationGain,
semantic_key: probe.semanticKey,
candidate_split_hash: probe.candidateSplitHash,
candidate_ids: ["05:00", "05:04"],
expected_outcomes: probe.expectedOutcomes.map((row) => ({
answer_class: row.outcomeId,
supports: row.supportsCandidateIds,
conflicts: row.conflictsCandidateIds,
})),
choice_kind: "varga_style",
style_options: d9StyleOptions(),
}],
});
assert.ok(frame);
const persisted = await persistServerOwnedFocus({
accounting: persistAccounting().client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: d9Followup(frame),
});
const open = openQuestionFromPersistedFocus(persisted);
const question = persisted.focus ? projectCurrentQuestion(persisted.focus) : null;
const card = parseRectificationChoiceCard({
question_id: persisted.focus?.questionId,
method_id: "d9_relationship",
prompt: persisted.prompt,
choice_mode: "A/B/C/D",
options: [
{ key: "A", label: d9StyleOptions()[0]!.label, answer_class: "yes", role: "primary" },
{ key: "B", label: d9StyleOptions()[1]!.label, answer_class: "weak_yes", role: "primary" },
{ key: "C", label: d9StyleOptions()[2]!.label, answer_class: "no", role: "primary" },
{ key: "D", label: d9StyleOptions()[3]!.label, answer_class: "unsure", role: "primary" },
],
stop_label: "先这样,先看当前范围",
stop_message: "先这样",
scoring: true,
focus_id: FOCUS_ID,
});
assert.equal(persisted.status, "created");
assert.equal(open?.kind, "choice");
assert.equal(open?.prompt, D9_QUESTION);
assertChoiceInvariants({ card, question });
});
test("unwired D9 persist falls back to spoken collect instead of an empty-prompt card", async () => {
const packet = d9WalkthroughPacket();
const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9."));
assert.ok(probe);
const wired = buildChoiceFrame({
method_id: "d9_relationship",
ask_theme: "relationship_style",
domain: "relationship",
user_prompt_hint: probe.question,
choice_kind: "varga_style",
semantic_key: probe.semanticKey,
style_options: d9StyleOptions(),
}, {
evidence: WALKTHROUGH_EVIDENCE,
probes: [{
year: 0,
year_label: "当前这几个候选",
domain: "relationship",
event_family: "相处方式更接近其中一种",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.informationGain,
semantic_key: probe.semanticKey,
candidate_split_hash: probe.candidateSplitHash,
candidate_ids: ["05:00", "05:04"],
expected_outcomes: probe.expectedOutcomes.map((row) => ({
answer_class: row.outcomeId,
supports: row.supportsCandidateIds,
conflicts: row.conflictsCandidateIds,
})),
choice_kind: "varga_style",
style_options: d9StyleOptions(),
}],
});
assert.ok(wired);
const persisted = await persistServerOwnedFocus({
accounting: persistAccounting().client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: d9Followup({ ...wired, prompt: "", period: "" }),
});
const open = openQuestionFromPersistedFocus(persisted);
const question = persisted.focus ? projectCurrentQuestion(persisted.focus) : null;
assert.ok(persisted.status === "created" || persisted.status === "already_open");
assert.equal(open?.kind, "collect_spoken");
assert.ok(open?.prompt?.trim());
assert.notEqual(question?.kind, "choice");
assertChoiceInvariants({ card: null, question });
});
test("agent instructions do not assert that the next question is already on screen", () => {
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, /接下来我们继续/);
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
assert.match(chat, /await loadCaseSnapshot\(\)/);
assert.match(chat, /isLatestMessage/);
});
test("same-domain collect retry uses structured closed-focus state, not body matching", () => {
const base = {
method_id: "d9_relationship" as const,
intent: "collect_method_evidence",
ask_theme: "relationship_style" as const,
domain: "relationship",
kind_hint: "relationship_start",
user_prompt_hint: "ask",
must_not_label: false as const,
choice_frame: null,
source: "method_coverage" as const,
};
const first = spokenFollowupForUser(base);
const retry = spokenFollowupForUser({ ...base, collect_retry: true });
assert.equal(first, USER_COLLECT_QUESTION.relationship);
assert.equal(retry, USER_COLLECT_QUESTION_RETRY.relationship);
assert.notEqual(first, retry);
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2019-01-01",
occurredTo: null,
eventKind: "career_entry",
}],
closedCollectFocuses: [{
target_domain: "relationship",
intent: "collect_method_evidence",
status: "resolved",
question_id: "collect:relationship:collect_method_evidence",
}],
sessionOutcome: "collect_evidence",
});
assert.equal(plan.next_followup?.domain, "relationship");
assert.equal(plan.next_followup?.collect_retry, true);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION_RETRY.relationship);
const declined = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2019-01-01",
occurredTo: null,
eventKind: "career_entry",
}],
declinedTopics: [{
target_domain: "relationship",
intent: "collect_method_evidence",
status: "declined",
}],
sessionOutcome: "collect_evidence",
});
assert.notEqual(declined.next_followup?.domain, "relationship");
});
test("accepted time replaces leftover collect with reverse_verify or consultation handoff", () => {
assert.equal(isStalePreAdoptFocus("04:53", { intent: "collect_method_evidence" }), true);
assert.equal(isStalePreAdoptFocus("04:53", { intent: "distinguish_candidates" }), true);
assert.equal(isStalePreAdoptFocus("04:53", { intent: "reverse_verify" }), false);
assert.equal(isStalePreAdoptFocus(null, { intent: "collect_method_evidence" }), false);
const idle = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const accept = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url), "utf8");
assert.match(idle, /isStalePreAdoptFocus/);
assert.match(idle, /resolveV10ConversationFocus/);
assert.match(accept, /persistNextInterviewIfIdle/);
const leftover = {
intent: "collect_method_evidence",
targetDomain: "finance",
targetKind: null,
expectedAnswerSchema: { collect: true, prompt: USER_COLLECT_QUESTION.finance },
};
const plan = buildMethodFollowupPlan({
evidence: WALKTHROUGH_EVIDENCE,
activeFocus: leftover,
accepted: true,
eventProbes: [{
year: 2021,
year_label: "2021 年前后",
domain: "finance",
event_family: "收入或债务明显变化",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "2021 年前后财务变化",
role: "distinguish",
information_gain: 0.4,
semantic_key: "finance.2021",
candidate_split_hash: "finance:2021",
candidate_ids: ["04:53", "05:00"],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:53"], conflicts: ["05:00"] },
{ answer_class: "no", supports: ["05:00"], conflicts: ["04:53"] },
],
}],
});
assert.equal(plan.next_followup?.intent, "reverse_verify");
const action = buildNextUserAction({
scorableCount: 4,
evidenceCount: 4,
hasLatestResult: true,
selectionAllowed: true,
sessionOutcome: "adopt_representative",
nextFollowup: plan.next_followup,
workingTime: "04:53",
accepted: true,
});
assert.equal(action.id, "verify_adopted_time");
const empty = buildMethodFollowupPlan({
evidence: WALKTHROUGH_EVIDENCE,
activeFocus: leftover,
accepted: true,
eventProbes: [],
});
assert.equal(empty.next_followup, null);
assert.equal(buildNextUserAction({
scorableCount: 4,
evidenceCount: 4,
hasLatestResult: true,
selectionAllowed: true,
sessionOutcome: "adopt_representative",
nextFollowup: empty.next_followup,
workingTime: "04:53",
accepted: true,
}).id, "start_consultation");
});
test("coverage does not block overlay can_adopt when the engine allows accept", () => {
const offered = decideRectification({
engineCeiling: {
acceptanceAllowed: true,
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
},
methodCoverageAll: false,
trainingGateOpen: true,
candidateScores: [
{ time: "04:53", score: 34 },
{ time: "04:51", score: 33 },
{ time: "04:47", score: 33 },
],
discriminatorProbe: null,
});
assert.equal(offered.canAdopt, true);
assert.equal(offered.canConfirmExactMinute, false);
const refused = decideRectification({
engineCeiling: {
acceptanceAllowed: false,
selectionAllowed: false,
proposeAllowed: false,
confirmationAllowed: false,
},
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: [
{ time: "04:53", score: 34 },
{ time: "04:51", score: 33 },
{ time: "04:47", score: 33 },
],
discriminatorProbe: null,
});
assert.equal(refused.canAdopt, false);
assert.equal(refused.canConfirmExactMinute, false);
const trainingClosed = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
trainingGateOpen: false,
candidateScores: [
{ time: "04:53", score: 34 },
{ time: "04:51", score: 33 },
],
discriminatorProbe: null,
});
assert.equal(trainingClosed.canAdopt, false);
});
@@ -91,10 +91,21 @@ function machineSections(): JsonRecord {
};
}
function engineVarga(ascIndex: number): JsonRecord {
return {
_meta: { fixture: true },
Ascendant: { sign: SIGNS[ascIndex], sign_idx: ascIndex, degree_in_sign: 10.5 },
Sun: { sign: SIGNS[ascIndex], sign_idx: ascIndex },
Moon: { sign: SIGNS[(ascIndex + 1) % 12], sign_idx: (ascIndex + 1) % 12 },
Mars: { sign: SIGNS[(ascIndex + 2) % 12], sign_idx: (ascIndex + 2) % 12 },
};
}
type FixtureOptions = Readonly<{
interpretive?: boolean;
financeNarrative?: boolean;
pollutedNames?: boolean;
structuredVargas?: boolean;
}>;
function workflowFixture(options: FixtureOptions = {}): JsonRecord {
@@ -112,6 +123,16 @@ function workflowFixture(options: FixtureOptions = {}): JsonRecord {
modules: {},
};
const modules = chart.modules as JsonRecord;
// Machine sections already mark D2/D11/D24 executed. Without the actual
// varga_full objects, demoteThemesMissingRequiredCharts strips wealth and
// education claim cards (Gitea run 2298).
if (options.structuredVargas !== false) {
modules.varga_full = {
D2: engineVarga(ASC_INDEX),
D11: engineVarga((ASC_INDEX + 2) % 12),
D24: engineVarga((ASC_INDEX + 4) % 12),
};
}
if (interpretive) {
chart.yogas = [
{ name: "Amala Yoga", planets: ["Mercury"], category: "extended" },
@@ -294,6 +315,21 @@ test("bundle carries the interpretive layers the engine actually computes", () =
assert.deepEqual([...facts.convergenceDomains], []);
});
test("machine-section D2/D11/D24 receipts without varga_full demote wealth and education", () => {
const bundle = buildBundle({ structuredVargas: false });
assert.equal(bundle.claimCards.some((card) => card.theme === "wealth"), false);
assert.equal(bundle.claimCards.some((card) => card.theme === "education"), false);
assert.ok(bundle.blockedSections.some((section) => section.theme === "wealth"));
assert.ok(bundle.blockedSections.some((section) => section.theme === "education"));
assert.ok(bundle.claimCards.some((card) => card.theme === "general"));
assert.deepEqual(
bundle.themeNarrativeSeeds.map((seed) => seed.theme).filter((theme) => (
theme === "wealth" || theme === "education"
)),
[],
);
});
test("interpretive extraction rejects polluted planet names and over-long narrative text", () => {
const bundle = buildBundle({ pollutedNames: true, financeNarrative: true });
const benefics = bundle.interpretiveFacts.functionalRoles