diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ad05e6..bdc926b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-12 — 生时校正定向补事能落下;刷新无新题也记住已经问过 + +带年月选择题问完后,定向 / 锚定 / 通用补事题会按表里允许的经历种类写入,不再因为种类名不合法而静默失败。刷新按还剩下的候选分钟来出题;如果没有新题,会记下「这一轮已经问过引擎」,同一批答案不会再打一次,界面走定向补事或给出目前范围。定向题若写不进库,同一轮会出「目前范围」卡,不会停在「没有拿到下一个问题」。Skill 版本不变。 + + ## 2026-09-12 — 生时校正刷新只有真有新题才写库 带年月选择题问完后若引擎没有新的可问题,不会再写空的推理记录,也不会改掉当前候选集。新并入的题号跟已经答过的题用同一套命名,避免下一问在页面上选中了、落库时被拒绝。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index aa2c79ae..a61b550b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10191,8 +10191,56 @@ - 触发条件:训练门已开、带年月池空、刷新调用引擎后没有可并入的新题,或并入探针 id 与已答 id 命名不一致,或写入时候选被清空 / 候选集被改掉。 - 根因:刷新在引擎没有新题时仍写 `reason=supersede` 的推理记录;GET 侧可能选中未映射进 `inference_state.probes` 的引擎探针,`expectedAnswerSchemaFor` 盖不上 `probe_id` 后 persist 返回 `invalid_choice_schema` / `skipped`。 - 修复:只在引擎映射出可问新题时写库;写入前断言候选非空且 `candidate_set_id` 不变,候选沿用刷新前集合;并入探针 id 与已答同一规则(`probe:` 或带 split hash);刷新只把可问探针写进 `inference_state.probes` 和 receipt,GET 选中的 key 必须能 stamp 上 `probe_id`。 -- 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts` T0 打印最后一行推理记录与 GET 探针 key / persist 状态;T3 空刷新与候选集变更不写库。 -- 防复发:刷新写库必须同时满足新题、候选非空、候选集不变、探针 id 与已答同名。不得靠客户端「接着问」补救(BUG-652)。 -- 相关记录:BUG-653、BUG-654、BUG-652、BUG-587、BUG-594 +- 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts` T0 打印最后一行推理记录与 GET 探针 key / persist 状态;有新题时 `supersede`,空刷新改由 BUG-658 写尝试记录。 +- 防复发:新探针写库必须同时满足新题、候选非空、候选集不变、探针 id 与已答同名。空刷新不得写 `supersede`。不得靠客户端「接着问」补救(BUG-652)。 +- 相关记录:BUG-653、BUG-654、BUG-652、BUG-587、BUG-594、BUG-656、BUG-657、BUG-658 - 复发自:BUG-653(刷新总会写库,未校验新题与候选集) - 修复版本:`512be9b7` + +## BUG-656 | 采集焦点 kind 必须映射到表约束枚举 + +- 状态:resolved +- 首次发现:2026-09-11 +- 最近更新:2026-09-12 +- 影响面:`persistCollectFocus`、`persistSpokenChoiceFallback`、`agentic_rectification_conversation_focuses.target_kind` +- 用户现象:带年月题问完后应出现定向补事口述题,但焦点写不进库,界面停在没有下一问。 +- 触发条件:下一问是定向 / 锚定 / 通用采集,`kind_hint` 为 `targeted:`、`anchor:…` 或 `generic:…`。 +- 根因:`targetKind` 直接写入 `kind_hint`,违反 `target_kind` CHECK;插入失败后 persist 返回 `skipped`,定向题从未成为活动焦点。 +- 修复:写库前经 `collectFocusTargetKind` 映射到 CHECK 枚举(产品别名 `relationship_change`→`relationship_start`、`education_milestone`→`education_start`);原 `kind_hint` 写入 `expected_answer_schema.collect_kind`;读取侧先读 `collect_kind` 再回退旧字段。不放宽 CHECK、不加迁移。 +- 验证:`frontend/tests/rectification-server-focus.test.ts` 用真实 CHECK 列表断言三类 followup 的 `targetKind` 合法且 `status=created`;`rectification-probe-pool-exhausted-20260911.test.ts` 第六题后定向题成为活动焦点。 +- 防复发:采集焦点 `target_kind` 必须落在 CHECK 列表内;`kind_hint` 原值只进 schema,不进列。 +- 相关记录:BUG-648、BUG-442、BUG-658 +- 复发自:BUG-648(采集题号可重试,但 kind 仍未映射到表约束) +- 修复版本:待发布 + +## BUG-657 | 引擎刷新必须按剩余候选生成探针 + +- 状态:resolved +- 首次发现:2026-09-11 +- 最近更新:2026-09-12 +- 影响面:`scripts/rectification/refinement_packet.py`、`event_probes.py` `refresh and remaining` 分支 +- 用户现象:第六题后刷新仍按整段约 31 分钟网格出题,剩余几分钟上没有新边界时永远 `no_new_probes`。 +- 触发条件:`refresh_probes=true` 且请求带非空 `column_times`(剩余活跃候选)。 +- 根因:`build_refinement_packet` 把 `grid_times` 交给探针生成,`column_times` 只喂三列对照;`refresh and remaining` 看不到剩余分钟。 +- 修复:刷新且 `column_times` 非空时,用它们作为 `_discriminating_event_probe_lists` 的 `candidate_times`;回执写 `refresh_result=new_probes|no_new_probes`。 +- 验证:`tests/test_candidate_discriminator_contract.py`「31 格 + column_times=5」:探针 `candidate_ids` 只含这 5 个;无边界则 `refresh_result=no_new_probes`。 +- 防复发:刷新探针生成不得默认整网格;`column_times` 非空时必须作为剩余候选。 +- 相关记录:BUG-653、BUG-658 +- 复发自:BUG-653(刷新调用了引擎,但探针仍按全网格算) +- 修复版本:待发布 + +## BUG-658 | 刷新尝试必须落库;等待收窄按已问过判定;定向题落库失败同一事务出卡 + +- 状态:resolved +- 首次发现:2026-09-12 +- 最近更新:2026-09-12 +- 影响面:`refreshDatedDiscriminatorPoolIfNeeded`、`narrowingExhaustion`、`persistExhaustionCollect`、`interviewCollectWaiting` +- 用户现象:引擎没有新题时界面永久「等待收窄」,每次答题再调一次引擎(约 20s),并出现「没有拿到下一个问题」。 +- 触发条件:训练门已开、带年月池空、刷新无新题;或定向补事焦点因 CHECK/落库失败。 +- 根因:512be9b7 空刷新什么都不写,`refresh_count` 恒 0;GET 不调刷新,`refreshExhausted` 永假,`waitToNarrowCapability` 无题无卡。`shouldRefreshDatedPool` 只看 `refresh_count`,同一已答数会反复打引擎。定向题 persist `skipped` 时仍停留在无焦点的口述。 +- 修复:空刷新把 `inference_state.refresh_attempts[]` 以 `reason=already_answered` 写入(幂等键 `refresh_attempt::`);有新题仍走 `supersede` 并附带尝试记录。`refreshExhausted` 由同一候选集、同一已答数下已有尝试记录(或剩余换升层为空)判定;`shouldRefreshDatedPool` 同源,至多一次引擎刷新。定向题 persist `skipped` 时同一事务把 `collect:targeted:` 记为 skipped 并出「目前范围」。客户端 `collect_evidence` 且无 stopReason、无焦点无卡时走 `collect_waiting`。 +- 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts` 空刷新写 `already_answered`、第二次不调引擎、GET 进定向补事;定向 persist 失败同一事务出「目前范围」、不得出现「没有拿到下一个问题」;`rectification-surface-state.test.ts` `collect_waiting` 无 `insufficient_*` 也可成立。 +- 防复发:空刷新不得只打日志;GET 决策不得依赖只存在于内存的刷新结果。新探针仍禁止空 `supersede`(BUG-655)。 +- 相关记录:BUG-655、BUG-651、BUG-652、BUG-656、BUG-657 +- 复发自:BUG-655(堵住空 `supersede` 后未留下「已刷新」事实) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-targeted-collect-persist-fix-20260911.md b/docs/tasks/PROGRESS-rectification-targeted-collect-persist-fix-20260911.md new file mode 100644 index 00000000..da70867d --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-targeted-collect-persist-fix-20260911.md @@ -0,0 +1,29 @@ +# 进度 · 修复单:采集焦点 kind、剩余候选刷新、刷新尝试落库(2026-09-12) + +## 范围 + +- 分支:`codex/rectification-targeted-collect-persist-fix-20260911`(基于 `origin/staging` @ `2b6d28e7`) +- 任务单:`docs/tasks/TASK-rectification-targeted-collect-persist-fix-20260911.md` +- BUG-656:采集焦点 `kind` 映射到表 CHECK +- BUG-657:引擎刷新按剩余候选 +- BUG-658:刷新尝试落库、等待收窄按已问过判定、落库失败同一事务出卡 +- T1 与 T3 不可省;T2 已一并做完 + +## 完成 + +- T1 `collectFocusTargetKind`:`targeted:` / `anchor:` / `generic:` 写入 CHECK 枚举;原 hint 进 `collect_kind` +- T2 `build_refinement_packet`:`refresh_probes` 且 `column_times` 非空时用剩余分钟生成探针;回执 `refresh_result` +- T3 `refresh_attempts` 以 `already_answered` 持久化;`refreshExhausted` / `shouldRefreshDatedPool` 同源;定向 persist `skipped` 出「目前范围」;`collect_waiting` 覆盖无 stopReason 的采集缺口 +- T4 BUG-656~658、CHANGELOG、本文件、场景 0c + +## 验收(本机,未部署) + +- `frontend` `tsc --noEmit` 0 错 +- eslint 变更文件 0 error(既有 unused-import warning 仍在) +- `rectification-server-focus.test.ts` + `rectification-probe-pool-exhausted-20260911.test.ts` + `rectification-surface-state.test.ts` + `rectification-replay-20260911.test.ts`:**51 / 51** +- Python:`test_candidate_discriminator_contract.py` + `test_rectification_event_probes.py`,fail=0(`.venv/bin/python -m pytest`) + +## 未做 + +- 未 commit / 未 push +- 未部署后按场景 0c 真机走一遍 diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index c85aa963..08d927cf 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -41,9 +41,12 @@ 期望: -- 引擎若没有新的带年月题,不得新写推理记录,界面走定向补事或已有载体 +- 第六题答完必须看到定向补事口述题(例如「还有 N 条线能把两端分开」);题干种类必须能写入焦点表,不得静默失败 +- 引擎若没有新的带年月题,界面走定向补事或已有载体;同一候选集、同一已答数下引擎刷新只调用一次,不得反复打约 20 秒的刷新 - 引擎若有新题,下一问必须出现;不得「GET 已有探针、界面没有下一问」 - 刷新不得把候选集改空或换成另一套 `candidate_set_id` +- 说「没有了」后看到「目前范围」卡;定向题若写不进库,同一轮也必须出这张卡 +- 全程不出现「没有拿到下一个问题」 ## 1. 范围:家人说两点到四点 diff --git a/frontend/src/lib/rectification-agentic/core/types.ts b/frontend/src/lib/rectification-agentic/core/types.ts index 873c9338..c46c14f9 100644 --- a/frontend/src/lib/rectification-agentic/core/types.ts +++ b/frontend/src/lib/rectification-agentic/core/types.ts @@ -125,6 +125,15 @@ export type RoundTrace = Readonly<{ kind: "informative" | "low_information" | "tie_break"; }>; +export type RefreshAttemptResult = "no_new_probes" | "new_probes"; + +export type RefreshAttempt = Readonly<{ + candidate_set_id: string; + answer_count: number; + result: RefreshAttemptResult; + at: string; +}>; + export type InferenceState = Readonly<{ algorithm_version: typeof INFERENCE_ALGORITHM_VERSION; candidate_set_id: string; @@ -145,6 +154,8 @@ export type InferenceState = Readonly<{ holdout_passed?: boolean | null; refresh_count?: number; refresh_answer_count?: number; + refresh_attempts?: readonly RefreshAttempt[]; + targeted_collect_status?: "asked" | "skipped"; transitions?: readonly Readonly<{ layer: string; at: string; diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 58b8039c..1c711c7b 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -25,7 +25,12 @@ import { RECTIFICATION_USER_COPY, withLastSuccessfulCompareNotice, } from "../user-copy.ts"; -import { moreCollectHint, preciseGapNarration, rangeNarrowHint } from "./collection-question-pool.ts"; +import { + isTargetedCollectFollowup, + moreCollectHint, + preciseGapNarration, + rangeNarrowHint, +} from "./collection-question-pool.ts"; import { applyChoiceWithoutEvidence, previousInferenceFromReceipt, @@ -73,7 +78,15 @@ import { hadPostAdoptVerifyQuestions, type AdoptNarrationWriter, } from "./adopt-narration.ts"; -import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus"; +import { + persistServerOwnedFocus, + persistSkippedCollectFocus, + persistableFocusDomain, + openQuestionFromPersistedFocus, + isRenderableChoiceOpenQuestion, + linkFocusAskedTurn, + followupHasPersistableDomain, +} from "./server-focus"; import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model"; import { composeCollectSpokenAssistantText } from "./collect-prompt"; import { @@ -87,6 +100,7 @@ import { spokenCollectFallbackFollowup, spokenFollowupForUser, targetedCollectFollowup, + collectQuestionDomain, ledgerHasConfirmedDatedEvent, type MethodCoverage, type MethodFollowup, @@ -454,6 +468,38 @@ function dossierWithClosedFocus>[]; + activeFocus: unknown; + }; +}>(dossier: T, followup: MethodFollowup): T { + const domain = persistableFocusDomain(followup.domain) + ?? persistableFocusDomain(collectQuestionDomain(followup.domain)) + ?? "other"; + const questionId = followup.collection_key + ?? `collect:targeted:${domain}`; + const collectKind = followup.kind_hint ?? `targeted:${domain}`; + return { + ...dossier, + conversationSummary: { + ...dossier.conversationSummary, + activeFocus: null, + declinedSkippedTopics: [ + ...dossier.conversationSummary.declinedSkippedTopics, + { + target_domain: domain, + status: "skipped", + intent: "collect_method_evidence", + questionId, + target_kind: collectKind, + expected_answer_schema: { collect_kind: collectKind }, + }, + ], + }, + }; +} + export async function applyRectificationChoice( accounting: AccountingClient, command: ApplyChoiceCommand, @@ -826,7 +872,7 @@ export async function persistNextInterviewAfterChoice(input: { }); liveDossier = refreshed.dossier; decisionState = refreshed.state; - if (refreshed.refreshed) { + if (refreshed.refreshed || refreshed.attemptRecorded) { decision = decideAfterInferenceChange({ dossier: liveDossier, state: decisionState, @@ -1328,7 +1374,7 @@ export async function persistNextInterviewIfIdle(input: { userStopped: input.userStopped, hasDatedProbe: Boolean(decision.probe), }); - if (refreshed.refreshed) { + if (refreshed.refreshed || refreshed.attemptRecorded) { const latest = dossier.latestResult; const overlay = refreshed.dossier.latestResult; dossier = { @@ -1485,14 +1531,14 @@ async function persistExhaustionCollect(input: { focus?: ConversationFocus | null; terminalNote?: boolean; }> { - const dossier = input.dossier as DecisionDossier; + let dossier = input.dossier as DecisionDossier; const receipt = input.decisionReceipt ?? dossier.latestResult?.decisionReceipt ?? null; - const decision = input.decision; - const catalog = rectificationFollowupCatalog( + let decision = input.decision; + let catalog = rectificationFollowupCatalog( dossier.latestResult ?? null, dossier.evidence, ); - const followup = exhaustionSpokenCollectFollowup({ + let followup = exhaustionSpokenCollectFollowup({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, answeredProbes: catalog.answeredProbes, @@ -1571,16 +1617,43 @@ async function persistExhaustionCollect(input: { focus: persistedFocus.focus, }; } - // duplicate_focus is not "already asked". Keep the spoken collect or the - // intermediate range sentence instead of falling through to adopt. - const hostNarration = (spoken ?? "").trim() || (range ?? "").trim(); - if (hostNarration) { - return { - persisted: false, - choiceReady: false, - hostNarration, - focus: persistedFocus.focus, - }; + if (isTargetedCollectFollowup(followup)) { + await persistSkippedCollectFocus({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + followup, + }); + dossier = dossierWithSkippedTargetedCollect(dossier, followup); + decision = decideFromDossier(dossier); + catalog = rectificationFollowupCatalog( + dossier.latestResult ?? null, + dossier.evidence, + ); + followup = exhaustionSpokenCollectFollowup({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + answeredProbes: catalog.answeredProbes, + eventProbes: catalog.eventProbes, + }) ?? targetedCollectFollowup( + catalog.remainingLayers, + dossier.evidence, + dossier.conversationSummary.declinedSkippedTopics, + catalog.remainingSplitTimes, + ); + if (isTargetedCollectFollowup(followup)) followup = null; + } else { + // duplicate_focus is not "already asked". Keep the spoken collect or the + // intermediate range sentence instead of falling through to adopt. + const hostNarration = (spoken ?? "").trim() || (range ?? "").trim(); + if (hostNarration) { + return { + persisted: false, + choiceReady: false, + hostNarration, + focus: persistedFocus.focus, + }; + } } } else { console.warn(JSON.stringify({ diff --git a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts index 1529f776..73159a0a 100644 --- a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts +++ b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts @@ -115,9 +115,24 @@ function topicKind(topic: CollectionTopic): string { : ""; } +function topicSchema(topic: CollectionTopic): Readonly> | null { + const schema = topic.expected_answer_schema ?? topic.expectedAnswerSchema; + return schema && typeof schema === "object" && !Array.isArray(schema) + ? schema as Readonly> + : null; +} + +export function topicCollectKind(topic: CollectionTopic): string { + const schema = topicSchema(topic); + const fromSchema = schema && typeof schema.collect_kind === "string" + ? schema.collect_kind.trim() + : ""; + return fromSchema || topicKind(topic); +} + export function isInviteCollectTopic(topic: CollectionTopic): boolean { return topicQuestionId(topic).startsWith("collect:invite:") - || topicKind(topic) === "invite_more"; + || topicCollectKind(topic) === "invite_more"; } export function askedCollectKeys(topics: readonly CollectionTopic[]): ReadonlySet { @@ -128,8 +143,8 @@ export function askedCollectKeys(topics: readonly CollectionTopic[]): ReadonlySe if (isInviteCollectTopic(topic)) continue; const questionId = topicQuestionId(topic).replace(/:(?:next|next2|next3)$/, ""); if (questionId.startsWith("collect:")) keys.add(questionId); - const kind = topicKind(topic); - if (kind.startsWith("anchor:") || kind.startsWith("generic:")) { + const kind = topicCollectKind(topic); + if (kind.startsWith("anchor:") || kind.startsWith("generic:") || kind.startsWith("targeted:")) { keys.add(`collect:${kind}`); } } @@ -526,18 +541,42 @@ export function remainingSplitTimes( return [clocks[0], clocks[clocks.length - 1]]; } +export function isTargetedCollectTopic(topic: CollectionTopic): boolean { + const questionId = topicQuestionId(topic); + const kind = topicCollectKind(topic); + return questionId.startsWith("collect:targeted:") + || kind.startsWith("targeted:") + || topicDomain(topic) === "targeted"; +} + +export function isTargetedCollectClosed( + topics: readonly CollectionTopic[] = [], +): boolean { + return topics.some((topic) => { + const status = topicStatus(topic); + if (status === "active" || !status) return false; + return isTargetedCollectTopic(topic); + }); +} + +export function isTargetedCollectFollowup(followup: { + collection_key?: string; + kind_hint?: string | null; +} | null | undefined): boolean { + if (!followup) return false; + const key = followup.collection_key ?? ""; + const hint = followup.kind_hint ?? ""; + return key.startsWith("collect:targeted:") + || hint.startsWith("targeted:"); +} + export function isTargetedCollectDeclined( topics: readonly CollectionTopic[] = [], ): boolean { return topics.some((topic) => { const status = topicStatus(topic); if (status !== "declined" && status !== "skipped") return false; - const questionId = topicQuestionId(topic); - const kind = topicKind(topic); - const domain = topicDomain(topic); - return questionId.startsWith("collect:targeted:") - || kind.startsWith("targeted:") - || domain === "targeted"; + return isTargetedCollectTopic(topic); }); } diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 2f806bd5..1f4b9364 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -56,6 +56,7 @@ import { remainingSplitTimes, targetedCollectPool, isTargetedCollectDeclined, + isTargetedCollectClosed, } from "./collection-question-pool.ts"; import { evidenceLedgerFingerprint } from "./tool-service"; import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts"; @@ -509,13 +510,27 @@ function narrowingExhaustion( live.remainingSplitTimes, ); const remainingLayers = live.remainingLayers; - const refreshed = (inference?.refresh_count ?? 0) >= 1; - // No remaining split layers: BUG-651 pool-empty delivery still holds. - // Remaining layers without a refresh: wait (BUG-653 accident). + const receipt = dossier.latestResult?.decisionReceipt ?? null; + const attempts = inference?.refresh_attempts + ?? (Array.isArray(receipt?.refresh_attempts) ? receipt.refresh_attempts : []); + const attempted = Boolean( + inference + && Array.isArray(attempts) + && attempts.some((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + const row = item as { candidate_set_id?: unknown; answer_count?: unknown }; + return row.candidate_set_id === inference.candidate_set_id + && row.answer_count === inference.answered_probes.length; + }), + ); return { - refreshExhausted: options?.refreshExhausted ?? (refreshed || remainingLayers.length === 0), + refreshExhausted: options?.refreshExhausted ?? ( + attempted + || remainingLayers.length === 0 + || (inference?.refresh_count ?? 0) >= 1 + ), targetedCollectExhausted: options?.targetedCollectExhausted - ?? (isTargetedCollectDeclined(declined) || targeted.length === 0), + ?? (isTargetedCollectClosed(declined) || isTargetedCollectDeclined(declined) || targeted.length === 0), }; } diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 6ee67545..1d1c1c87 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -1468,6 +1468,18 @@ export function exhaustionSpokenCollectFollowup(input: { return nextCollectionFollowup(input.evidence, input.declinedTopics ?? []); } +function collectKindFromFocus(focus: { + targetKind?: string | null; + expectedAnswerSchema?: Readonly> | null; +}): string | null { + const schema = focus.expectedAnswerSchema; + const fromSchema = typeof schema?.collect_kind === "string" ? schema.collect_kind.trim() : ""; + if (fromSchema) return fromSchema; + return typeof focus.targetKind === "string" && focus.targetKind.trim() + ? focus.targetKind + : null; +} + export const OPENING_COLLECT_DOMAIN = "other"; export function collectQuestionDomain(domain: string | null | undefined): string { @@ -2163,7 +2175,7 @@ export function buildMethodFollowupPlan(input: { intent: focus.intent || "reverse_verify", ask_theme: keepAskTheme, domain: focus.targetDomain, - kind_hint: focus.targetKind, + kind_hint: collectKindFromFocus(focus), user_prompt_hint: `${acceptedKeepHint}先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。`, source: focus.intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify", ...(keepProbeYear ? { @@ -2185,7 +2197,7 @@ export function buildMethodFollowupPlan(input: { intent: focus.intent || "active_focus", ask_theme: "active_focus", domain: parseCollectFocusQuestionId(focus.questionId)?.domain ?? focus.targetDomain, - kind_hint: focus.targetKind, + kind_hint: collectKindFromFocus(focus), user_prompt_hint: keepChoice ? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。" : "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。", diff --git a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts index c7bc4a3d..4978197c 100644 --- a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts +++ b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts @@ -7,7 +7,7 @@ */ import { probeFromEngine } from "../core/probes-from-engine.ts"; -import type { ConflictProbe, InferenceState, ProbeAnswer } from "../core/types.ts"; +import type { ConflictProbe, InferenceState, ProbeAnswer, RefreshAttempt, RefreshAttemptResult } from "../core/types.ts"; import { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts"; import { runV9CandidateScore, @@ -43,6 +43,59 @@ export type RefreshDiscriminatorProbesResult = Readonly<{ refreshCount: number; }>; +export function refreshAttemptsOf( + state: InferenceState | null | undefined, + receipt?: Readonly> | null, +): readonly RefreshAttempt[] { + const fromState = state?.refresh_attempts; + if (Array.isArray(fromState) && fromState.length > 0) return fromState; + const fromReceipt = receipt?.refresh_attempts; + if (!Array.isArray(fromReceipt)) return fromState ?? []; + return fromReceipt.flatMap((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return []; + const row = item as Record; + const candidateSetId = typeof row.candidate_set_id === "string" ? row.candidate_set_id : ""; + const answerCount = typeof row.answer_count === "number" ? row.answer_count : null; + const result = row.result === "no_new_probes" || row.result === "new_probes" + ? row.result + : null; + const at = typeof row.at === "string" ? row.at : ""; + if (!candidateSetId || answerCount == null || result == null) return []; + return [{ candidate_set_id: candidateSetId, answer_count: answerCount, result, at }]; + }); +} + +export function hasRefreshAttemptFor( + state: InferenceState | null | undefined, + candidateSetId: string, + answerCount: number, + receipt?: Readonly> | null, +): boolean { + return refreshAttemptsOf(state, receipt).some((item) => ( + item.candidate_set_id === candidateSetId && item.answer_count === answerCount + )); +} + +function withRefreshAttempt( + state: InferenceState, + result: RefreshAttemptResult, +): InferenceState { + const attempt: RefreshAttempt = { + candidate_set_id: state.candidate_set_id, + answer_count: state.answered_probes.length, + result, + at: new Date().toISOString(), + }; + const previous = refreshAttemptsOf(state); + if (previous.some((item) => ( + item.candidate_set_id === attempt.candidate_set_id + && item.answer_count === attempt.answer_count + ))) { + return { ...state, refresh_attempts: previous }; + } + return { ...state, refresh_attempts: [...previous, attempt] }; +} + type RefreshImpl = (input: RefreshDiscriminatorProbesInput) => Promise; function isRefreshableDatedProbe(probe: DiscriminatingEventProbe): boolean { @@ -276,6 +329,7 @@ export function applyRefreshedProbesToDossier( decisionReceipt: { ...receipt, inference_state: state, + refresh_attempts: state.refresh_attempts ?? [], discriminating_event_probes: mergeEventProbes(existing, extraEventProbes), }, }, @@ -292,6 +346,14 @@ function shouldRefreshDatedPool(input: { if (input.dossier.case.acceptedTime) return false; if (!input.state) return false; if (input.hasDatedProbe) return false; + if (hasRefreshAttemptFor( + input.state, + input.state.candidate_set_id, + input.state.answered_probes.length, + input.dossier.latestResult?.decisionReceipt, + )) { + return false; + } const refreshCount = input.state.refresh_count ?? 0; if (refreshCount >= MAX_DISCRIMINATOR_REFRESHES) return false; if ( @@ -329,6 +391,55 @@ function refreshWriteBlockedReason( return null; } +async function persistRefreshAttempt(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + dossier: DecisionDossier; + previous: InferenceState; + next: InferenceState; +}): Promise { + const last = input.next.answered_probes.at(-1); + if (!last) return false; + const evidenceFp = input.dossier.latestResult?.evidenceLedgerFingerprint + ?? evidenceLedgerFingerprint(input.dossier.evidence as never); + try { + await persistV9InferenceState(input.accounting, input.userId, input.caseId, { + expectedRevision: input.previous.revision, + probeId: last.probe_id, + openProbeId: last.probe_id, + semanticKey: last.semantic_key, + candidateSplitHash: last.candidate_split_hash, + answerClass: last.answer_class, + rawAnswer: "refresh_attempt", + inferenceState: input.next as unknown as Record, + posteriorBefore: Object.fromEntries( + input.previous.candidates.map((item) => [item.time, item.posterior_score]), + ), + posteriorAfter: Object.fromEntries( + input.next.candidates.map((item) => [item.time, item.posterior_score]), + ), + scoreDeltas: {}, + decisionStateFingerprint: inferenceFingerprintForState( + input.caseId, + evidenceFp, + input.next, + ), + reason: "already_answered", + idempotencyKey: `refresh_attempt:${input.next.candidate_set_id}:${input.next.answered_probes.length}`, + candidateSetId: input.next.candidate_set_id, + }); + return true; + } catch (error) { + console.warn( + `[rectification-v9] persist refresh attempt failed case=${input.caseId} reason=${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + } +} + function refreshPersistBlockedReason(input: { previous: InferenceState; next: InferenceState; @@ -415,16 +526,26 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: { dossier: DecisionDossier; state: InferenceState | null; refreshed: boolean; + attemptRecorded: boolean; }> { const state = input.state ?? previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null); + const alreadyAttempted = Boolean( + state + && hasRefreshAttemptFor( + state, + state.candidate_set_id, + state.answered_probes.length, + input.dossier.latestResult?.decisionReceipt, + ), + ); if (!shouldRefreshDatedPool({ dossier: input.dossier, state, userStopped: input.userStopped, hasDatedProbe: input.hasDatedProbe, }) || !state) { - return { dossier: input.dossier, state, refreshed: false }; + return { dossier: input.dossier, state, refreshed: false, attemptRecorded: alreadyAttempted }; } const result = await refreshDiscriminatorProbes({ accounting: input.accounting, @@ -444,14 +565,32 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: { candidate_set_id: result.state.candidate_set_id, refresh_count: state.refresh_count ?? 0, })); - return { dossier: input.dossier, state, refreshed: false }; + const attempted = withRefreshAttempt( + { ...state, revision: state.revision + 1 }, + "no_new_probes", + ); + await persistRefreshAttempt({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + dossier: input.dossier, + previous: state, + next: attempted, + }); + return { + dossier: applyRefreshedProbesToDossier(input.dossier, attempted), + state: attempted, + refreshed: false, + attemptRecorded: true, + }; } - const nextState: InferenceState = { + const nextState: InferenceState = withRefreshAttempt({ ...state, + revision: state.revision + 1, probes: mergeConflictProbes(state.probes, askable, state.answered_probes), refresh_count: (state.refresh_count ?? 0) + 1, refresh_answer_count: state.answered_probes.length, - }; + }, "new_probes"); const written = await persistRefreshedInference({ accounting: input.accounting, userId: input.userId, @@ -462,7 +601,12 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: { askableCount: askable.length, }); if (!written) { - return { dossier: input.dossier, state, refreshed: false }; + return { + dossier: applyRefreshedProbesToDossier(input.dossier, nextState), + state: nextState, + refreshed: false, + attemptRecorded: true, + }; } const persistableEventProbes = result.eventProbes.filter((probe) => { const key = probe.semantic_key?.trim() ?? `${probe.domain}.${probe.year}`; @@ -472,5 +616,6 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: { dossier: applyRefreshedProbesToDossier(input.dossier, nextState, persistableEventProbes), state: nextState, refreshed: true, + attemptRecorded: true, }; } diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index f48a9220..dc1eae55 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -15,6 +15,7 @@ import { USER_COLLECT_QUESTION } from "../user-copy"; import { refinementFromDecisionReceipt } from "./refinement-packet"; import { setV10ConversationFocus, + resolveV10ConversationFocus, RectificationToolServiceError, safeToolErrorCode, type AccountingClient, @@ -207,6 +208,34 @@ const PERSISTABLE_FOCUS_DOMAINS = new Set([ "other", ]); +/** Matches `agentic_rectification_conversation_focuses.target_kind` CHECK. */ +export const FOCUS_TARGET_KIND_CHECK = [ + "education_start", "education_completion", "education_interruption", + "career_entry", "career_change", "promotion", "career_pressure", "career_exit", + "relationship_start", "relationship_commitment", "relationship_separation", + "relocation", "finance_gain", "finance_loss", + "self_health_event", "family_event", "other", +] as const; + +const FOCUS_TARGET_KIND_CHECK_SET = new Set(FOCUS_TARGET_KIND_CHECK); + +const COLLECT_FOCUS_KIND_BY_DOMAIN: Readonly> = { + family: "family_event", + finance: "finance_gain", + relocation: "relocation", + relationship: "relationship_change", + career: "career_change", + education: "education_milestone", + health: "self_health_event", +}; + +const FOCUS_TARGET_KIND_ALIASES: Readonly> = { + education_milestone: "education_start", + relationship_change: "relationship_start", + finance_change: "finance_gain", + home_change: "relocation", +}; + export function persistableFocusDomain(domain: string | null | undefined): string | null { if (!domain || domain === "unknown" || domain === "active_focus") return null; if (domain === "health_pressure") return "health"; @@ -215,6 +244,34 @@ export function persistableFocusDomain(domain: string | null | undefined): strin return null; } +function clampFocusTargetKind(kind: string | null | undefined): string | null { + const value = kind?.trim() || ""; + if (!value) return null; + if (FOCUS_TARGET_KIND_CHECK_SET.has(value)) return value; + const aliased = FOCUS_TARGET_KIND_ALIASES[value]; + return aliased && FOCUS_TARGET_KIND_CHECK_SET.has(aliased) ? aliased : null; +} + +export function collectKindFromSchema( + schema: Readonly> | null | undefined, +): string | null { + const value = schema?.collect_kind; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function collectFocusTargetKind(followup: Pick): string | null { + const hint = followup.kind_hint?.trim() || ""; + const fromHint = clampFocusTargetKind(hint); + if (fromHint) return fromHint; + if (hint.startsWith("anchor:")) { + const inner = clampFocusTargetKind(hint.split(":")[1] ?? ""); + if (inner) return inner; + } + const domain = persistableFocusDomain(followup.domain) + ?? persistableFocusDomain(collectQuestionDomain(followup.domain)); + return clampFocusTargetKind(domain ? COLLECT_FOCUS_KIND_BY_DOMAIN[domain] ?? null : null); +} + export function followupHasPersistableDomain(followup: Pick): boolean { return Boolean( persistableFocusDomain(followup.domain) @@ -261,6 +318,7 @@ export function collectFocusSchema(followup: MethodFollowup): Record { + const schema = collectFocusSchema(input.followup); + if (!schema) return null; + const questionId = stableFollowupQuestionId(input.followup); + try { + const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, { + questionId, + intent: input.followup.intent, + targetEvidenceId: input.followup.date_reliability_evidence_id ?? null, + targetDomain: persistableFocusDomain(input.followup.domain) + ?? persistableFocusDomain(collectQuestionDomain(input.followup.domain)), + targetKind: collectFocusTargetKind(input.followup), + expectedAnswerSchema: schema, + }); + if (!result.focus.id) return result.focus; + await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, { + focusId: result.focus.id, + status: "skipped", + }); + return { ...result.focus, status: "skipped" }; + } catch { + return null; + } +} diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 2b47d890..d1dd2f2b 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -355,6 +355,7 @@ export function interviewCollectWaiting(input: Readonly<{ }>): boolean { if (!input.questionMissing || input.offeringCards) return false; if (input.sessionOutcome && input.sessionOutcome !== "collect_evidence") return false; + if (input.sessionOutcome === "collect_evidence" && !input.stopReason) return true; return input.stopReason === "insufficient_dated_events" || input.stopReason === "insufficient_domains" || input.stopReason === "insufficient_events"; diff --git a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts index b07b5fc3..1488cfab 100644 --- a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts +++ b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts @@ -33,7 +33,7 @@ import { setRefreshDiscriminatorProbesForTests, } from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts"; import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; -import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts"; +import { persistServerOwnedFocus, FOCUS_TARGET_KIND_CHECK } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts"; import { @@ -374,7 +374,18 @@ function accidentDossier(answeredCount: number, extra: { } = {}): DecisionDossier { const loaded = liveState(answeredCount); const state = extra.refreshCount != null - ? { ...loaded, refresh_count: extra.refreshCount } + ? { + ...loaded, + refresh_count: extra.refreshCount, + refresh_attempts: extra.refreshCount >= 1 + ? [{ + candidate_set_id: loaded.candidate_set_id, + answer_count: loaded.answered_probes.length, + result: "no_new_probes" as const, + at: "2026-09-11T00:00:00.000Z", + }] + : loaded.refresh_attempts, + } : loaded; const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never); return { @@ -477,7 +488,26 @@ function idleHandlers(decision: DecisionDossier, extra: { } = {}) { return fakeAccounting({ ...receiptHandlers, - get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra), + get_agentic_rectification_case_dossier: () => { + const base = rpcDossier(decision, extra); + const last = extra.transitions?.at(-1); + const inference = last?.p_inference_state; + const latest = base.latest_result as { + decision_receipt?: Record; + } | null | undefined; + if (!inference || typeof inference !== "object" || !latest) return base; + return { + ...base, + latest_result: { + ...latest, + decision_receipt: { + ...(latest.decision_receipt ?? {}), + inference_state: inference, + refresh_attempts: (inference as { refresh_attempts?: unknown }).refresh_attempts, + }, + }, + }; + }, get_agentic_rectification_case_compute: () => computeFixture(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), apply_agentic_rectification_choice_action: (_fn, args) => ({ @@ -598,6 +628,13 @@ test("T0: sixth dated answer must refresh or targeted-collect, not deliver a car assert.match(host, /家里|收入|搬家|感情|还能再收窄|添丁|住院/); assert.doesNotMatch(host, /这次给出|最终|做不了|才会变|没有拿到下一个问题/); assert.equal(persisted.choiceReady, false); + const focusWrite = idleAccounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); + assert.ok(focusWrite, "targeted collect must become the active focus"); + assert.match(String(focusWrite.args.p_question_id ?? ""), /collect:targeted:/); + assert.ok( + (FOCUS_TARGET_KIND_CHECK as readonly string[]).includes(String(focusWrite.args.p_target_kind ?? "")), + String(focusWrite.args.p_target_kind), + ); for (const phrase of COLLECT_FLOW_BANNED_PHRASES) { if (phrase === "领域") continue; assert.equal(host.includes(phrase), false, phrase); @@ -676,6 +713,8 @@ test("T3: skipped persist still leaves a non-empty carrier; 没有了 delivers t })); const next = result as Awaited>; assert.ok((next.hostNarration ?? "").trim(), "BUG-652: never silent empty carrier"); + assert.match(next.hostNarration, /目前范围/); + assert.doesNotMatch(next.hostNarration, /没有拿到下一个问题/); const skippedDirect = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, @@ -908,24 +947,76 @@ test("T0: last inference row and GET probe key vs persist status after a real re resetRefreshDiscriminatorProbesForTests(); }); -test("T3: refresh without new engine probes does not write inference", async () => { +test("T3: refresh without new engine probes persists an already_answered attempt", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); - setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ - state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 }, - eventProbes: [], - candidateSetId: state.candidate_set_id, - refreshCount: (state.refresh_count ?? 0) + 1, - })); + let engineCalls = 0; + setRefreshDiscriminatorProbesForTests(async ({ state }) => { + engineCalls += 1; + return { + state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 }, + eventProbes: [], + candidateSetId: state.candidate_set_id, + refreshCount: (state.refresh_count ?? 0) + 1, + }; + }); const dossier = accidentDossier(6); const transitions: Record[] = []; + const accounting = idleHandlers(dossier, { transitions }); const idle = await persistNextInterviewIfIdle({ - accounting: idleHandlers(dossier, { transitions }).client, + accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); - assert.equal(transitions.length, 0, JSON.stringify(transitions.at(-1) ?? {})); + const last = transitions.at(-1); + const inference = last?.p_inference_state as { + refresh_attempts?: ReadonlyArray<{ + candidate_set_id?: string; + answer_count?: number; + result?: string; + }>; + probes?: unknown[]; + candidate_set_id?: string; + } | undefined; + assert.equal(last?.p_reason, "already_answered", JSON.stringify(last ?? {})); + assert.equal(last?.p_raw_answer, "refresh_attempt"); + assert.equal(inference?.refresh_attempts?.at(-1)?.result, "no_new_probes"); + assert.equal(inference?.refresh_attempts?.at(-1)?.answer_count, liveState(6).answered_probes.length); + assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id); + assert.equal((inference as { refresh_count?: number } | undefined)?.refresh_count ?? 0, 0); + assert.equal( + (inference?.probes ?? []).some((item) => ( + Boolean(item) + && typeof item === "object" + && (item as { semantic_key?: string }).semantic_key === FAMILY_REFRESH.semantic_key + )), + false, + ); assert.ok((idle.hostNarration ?? "").trim()); + const overlayed = { + ...dossier, + latestResult: { + ...dossier.latestResult!, + decisionReceipt: { + ...(dossier.latestResult?.decisionReceipt ?? {}), + inference_state: inference, + refresh_attempts: inference?.refresh_attempts, + }, + }, + }; + const getDecision = decideFromDossier(overlayed, { birthDate: "1997-08-08" }); + assert.equal(getDecision.nextAction, "ask_fact_collection", getDecision.nextAction); + assert.match( + followupPlan(overlayed, getDecision.sessionOutcome).next_followup?.collection_key ?? "", + /collect:targeted:/, + ); + const second = await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal(engineCalls, 1, `second refresh called the engine ${engineCalls} times`); + assert.ok((second.hostNarration ?? idle.hostNarration ?? "").trim()); resetRefreshDiscriminatorProbesForTests(); }); @@ -949,7 +1040,13 @@ test("T3: changed candidate set is not persisted even when engine returns a prob nextAction: publicNextAction(decideFromDossier(dossier, { birthDate: "1997-08-08" })), birthDate: "1997-08-08", }); - assert.equal(transitions.length, 0); + const last = transitions.at(-1); + assert.equal(last?.p_reason, "already_answered"); + assert.notEqual(last?.p_reason, "supersede"); + assert.equal(last?.p_candidate_set_id, liveState(6).candidate_set_id); + const inference = last?.p_inference_state as { candidate_set_id?: string; candidates?: unknown[] } | undefined; + assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id); + assert.equal(inference?.candidates?.length, TIMES.length); resetRefreshDiscriminatorProbesForTests(); }); @@ -973,7 +1070,12 @@ test("T3: empty candidate list is not persisted even when engine returns a probe hasDatedProbe: false, }); assert.equal(refreshed.refreshed, false); - assert.equal(transitions.length, 0); + assert.equal(refreshed.attemptRecorded, true); + const last = transitions.at(-1); + assert.equal(last?.p_reason, "already_answered"); + assert.notEqual(last?.p_reason, "supersede"); + const inference = last?.p_inference_state as { candidates?: unknown[] } | undefined; + assert.equal(inference?.candidates?.length, TIMES.length); resetRefreshDiscriminatorProbesForTests(); }); diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index a1beb262..2d104e21 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -5,6 +5,8 @@ import test from "node:test"; import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { COLLECT_FOCUS_RETRY_SUFFIX, + FOCUS_TARGET_KIND_CHECK, + collectFocusTargetKind, openQuestionFromPersistedFocus, persistableFocusDomain, persistServerOwnedFocus, @@ -924,3 +926,55 @@ test("frameless precision-stage distinguish is skipped instead of opening GENERI GENERIC_COLLECT_QUESTION, ); }); + +function targetKindCheckFromSql(): string[] { + const sql = readFileSync(new URL( + "../supabase/migrations/20260814020000_rectification_v10_runtime.sql", + import.meta.url, + ), "utf8"); + const match = /target_kind text check \(\s*target_kind is null or target_kind in \(\s*([\s\S]*?)\)\s*\)/.exec(sql); + assert.ok(match?.[1], "target_kind CHECK missing"); + return [...match[1].matchAll(/'([a-z_]+)'/g)].map((item) => item[1]); +} + +test("T1: collect focus kinds map to the table CHECK enum", async () => { + const allowed = targetKindCheckFromSql(); + assert.deepEqual([...FOCUS_TARGET_KIND_CHECK], allowed); + const cases: ReadonlyArray<{ + kind_hint: string; + domain: string; + collectKind: string; + }> = [ + { kind_hint: "targeted:family", domain: "family", collectKind: "targeted:family" }, + { kind_hint: "anchor:education_start:2016", domain: "education", collectKind: "anchor:education_start:2016" }, + { kind_hint: "generic:relationship", domain: "relationship", collectKind: "generic:relationship" }, + ]; + for (const item of cases) { + const mapped = collectFocusTargetKind({ kind_hint: item.kind_hint, domain: item.domain }); + assert.ok(mapped && allowed.includes(mapped), `${item.kind_hint} -> ${mapped}`); + const accounting = fakeAccounting({ + set_agentic_rectification_conversation_focus: (_fn, args) => focusRowFromArgs(args), + }); + const persisted = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: null, + followup: collectFollowup({ + domain: item.domain, + kind_hint: item.kind_hint, + collection_key: item.kind_hint.startsWith("targeted:") + ? `collect:targeted:${item.domain}` + : undefined, + }), + }); + assert.equal(persisted.status, "created", item.kind_hint); + assert.ok( + persisted.focus?.targetKind && allowed.includes(persisted.focus.targetKind), + `${item.kind_hint} wrote ${persisted.focus?.targetKind}`, + ); + assert.equal(persisted.focus?.expectedAnswerSchema.collect_kind, item.collectKind); + assert.equal(accounting.calls[0]?.args.p_target_kind, persisted.focus?.targetKind); + } +}); diff --git a/frontend/tests/rectification-surface-state.test.ts b/frontend/tests/rectification-surface-state.test.ts index d26a7def..f5db0bfd 100644 --- a/frontend/tests/rectification-surface-state.test.ts +++ b/frontend/tests/rectification-surface-state.test.ts @@ -72,6 +72,13 @@ test("question gap: collect waiting is not the unavailable repair path", () => { }), true, ); + assert.equal( + interviewCollectWaiting({ + sessionOutcome: "collect_evidence", + questionMissing: true, + }), + true, + ); assert.equal( rectificationQuestionGapState({ ...gapBase, diff --git a/scripts/rectification/refinement_packet.py b/scripts/rectification/refinement_packet.py index 50ee9c65..6190d10c 100644 --- a/scripts/rectification/refinement_packet.py +++ b/scripts/rectification/refinement_packet.py @@ -705,11 +705,21 @@ def build_refinement_packet( prospective_event_windows, ) grid_times = list(built.get("candidate_times") or candidate_times) + refresh_columns = [ + str(item).strip()[:5] + for item in (column_times or request.get("column_times") or []) + if str(item).strip() + ] + probe_times = ( + refresh_columns + if request.get("refresh_probes") is True and refresh_columns + else grid_times + ) bundle = discriminating_event_probe_set( request, built, scan=scan, - candidate_times=grid_times, + candidate_times=probe_times, representative_time=representative_time, precision_current=str(stage.get("current") or "") or None, ) @@ -772,4 +782,9 @@ def build_refinement_packet( "prospective_probes": prospective, "unique_minute_claim": False, "confirmation_allowed": False, + **( + {"refresh_result": "new_probes" if probes else "no_new_probes"} + if request.get("refresh_probes") is True + else {} + ), } diff --git a/tests/test_candidate_discriminator_contract.py b/tests/test_candidate_discriminator_contract.py index 8d723685..23cc804b 100644 --- a/tests/test_candidate_discriminator_contract.py +++ b/tests/test_candidate_discriminator_contract.py @@ -2,7 +2,7 @@ from __future__ import annotations import random import unittest -from datetime import date, datetime +from datetime import date, datetime, timedelta from scripts.rectification.candidate_contrast import ( MIN_DISCRIMINATOR_DOMAINS, @@ -27,7 +27,7 @@ from scripts.rectification.event_probes import ( _partition_ranked_probes, _probe_caps, ) -from scripts.rectification.refinement_packet import window_scan +from scripts.rectification.refinement_packet import build_refinement_packet, window_scan PLANETS = { @@ -511,6 +511,44 @@ class DiscriminatorContractTest(unittest.TestCase): for item in dropped )) + def test_refresh_packet_uses_column_times_not_full_grid(self) -> None: + remaining = ["04:48", "04:53", "04:59", "05:06", "05:07"] + start = datetime(1997, 8, 8, 4, 40) + grid = [(start + timedelta(minutes=index)).strftime("%H:%M") for index in range(31)] + self.assertEqual(len(grid), 31) + contexts = [] + for time in grid: + if time in remaining[:2]: + contexts.append(_context(time, d4_asc=1, d9_asc=1, d10_asc=1, d12_asc=1, d24_asc=1)) + elif time in remaining[2:]: + contexts.append(_context(time, d4_asc=2, d9_asc=2, d10_asc=3, d12_asc=4, d24_asc=2)) + else: + contexts.append(_context(time, d4_asc=1, d9_asc=1, d10_asc=1, d12_asc=1, d24_asc=1)) + built = {"static_contexts": contexts, "candidate_times": grid} + packet = build_refinement_packet( + _request(asked_probe_keys=[ + "career.2020", + "career.2023", + "education.2016", + "education.2020", + "relationship.2023", + "relocation.2015", + ], refresh_probes=True, column_times=remaining), + built, + representative_time="04:53", + candidate_times=grid, + column_times=remaining, + ) + probes = packet.get("discriminating_event_probes") or [] + allowed = set(remaining) + for probe in probes: + ids = {str(item)[:5] for item in (probe.get("candidate_ids") or [])} + self.assertTrue(ids <= allowed, probe.get("semantic_key")) + if not probes: + self.assertEqual(packet.get("refresh_result"), "no_new_probes") + else: + self.assertEqual(packet.get("refresh_result"), "new_probes") + if __name__ == "__main__": unittest.main()