From d2296c26544d020630913176f00e3f5f823624a4 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 15 Oct 2026 22:22:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=E5=89=8D=E5=90=8E=E7=AB=AF=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E8=AF=B7=E6=B1=82=E5=A5=91=E7=BA=A6=E5=92=8C?= =?UTF-8?q?=E5=B0=8F=E5=B7=A5=E5=85=B7=E6=94=B6=E6=88=90=E4=B8=80=E4=BB=BD?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E5=86=8D=E5=90=84=E6=8A=84=E4=B8=80?= =?UTF-8?q?=E4=BB=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 报告创建请求、校正入口和同名类型改从 contracts 推导;同义的对象判断与 sha256 改走共享函数。调用处用 import 别名保留原名,使 diff 低于 1500 行。行为、哈希和状态字符串不变。 Co-Authored-By: Grok (agent loop) --- ...GRESS-contracts-helpers-dedupe-20261013.md | 98 +++++++++++++++++ .../birth-time-evidence-draft-card.tsx | 4 +- .../generate-personal-report-button.tsx | 27 ++--- .../personal-report/personal-report-page.tsx | 5 +- .../components/rectification-choice-card.tsx | 2 +- .../rectification-message-entry.tsx | 2 +- .../hooks/use-rectification-case-snapshot.ts | 2 +- frontend/src/lib/account-deletion.ts | 5 +- .../admin/rectification-telemetry-summary.ts | 7 +- frontend/src/lib/birth-profile-timezone.ts | 7 +- .../lib/birth-time-dynamic-choice-internal.ts | 4 +- .../src/lib/birth-time-dynamic-preview.ts | 10 +- .../src/lib/birth-time-dynamic-stop-policy.ts | 10 +- .../src/lib/birth-time-dynamic-transitions.ts | 4 +- .../src/lib/birth-time-evidence-service.ts | 4 +- frontend/src/lib/birth-time-evidence.ts | 10 +- frontend/src/lib/birth-time-guide-agent.ts | 8 +- .../src/lib/birth-time-journey-adapters.ts | 6 +- .../src/lib/birth-time-journey-case-loader.ts | 4 +- .../birth-time-journey-score-transition.ts | 6 +- .../src/lib/birth-time-journey-service.ts | 8 +- .../lib/birth-time-journey-stored-protocol.ts | 4 +- .../src/lib/birth-time-journey-transitions.ts | 14 +-- .../birth-time-journey-turn-persistence.ts | 10 +- frontend/src/lib/birth-time-journey-turn.ts | 16 +-- .../src/lib/birth-time-question-planner.ts | 16 +-- .../src/lib/birth-time-scoring-service.ts | 6 +- frontend/src/lib/chart-birth-truth.ts | 7 +- frontend/src/lib/chart-subject-write.ts | 7 +- frontend/src/lib/chart-view-mapper.ts | 7 +- .../src/lib/consultation-evidence-card.ts | 5 +- .../src/lib/consultation-judgment-table.ts | 5 +- .../src/lib/consultation-route-service.ts | 7 +- frontend/src/lib/consultation-step-timing.ts | 7 +- .../src/lib/consultation-subject-resolver.ts | 7 +- .../lib/contracts/personal-report-request.ts | 38 +++++++ .../src/lib/contracts/rectification-entry.ts | 78 +++++++++++++ frontend/src/lib/daily-starlanguage.ts | 5 +- frontend/src/lib/ephemeris-view.ts | 7 +- frontend/src/lib/general-daily-panchanga.ts | 7 +- frontend/src/lib/guards.ts | 20 ++++ frontend/src/lib/hash.ts | 9 ++ frontend/src/lib/personal-report-contract.ts | 5 +- .../src/lib/personal-report-generation.ts | 20 +--- .../lib/personal-report-longform-appendix.ts | 4 +- .../lib/personal-report-longform-generate.ts | 7 +- frontend/src/lib/personal-report-plan.ts | 4 +- .../src/lib/personal-report-route-core.ts | 30 +---- frontend/src/lib/personal-report-worker.ts | 12 +- .../lib/rectification-adopted-chart-tiers.ts | 5 +- .../core/compose-receipt.ts | 5 +- .../rectification-agentic/v9/answer-choice.ts | 3 +- .../rectification-agentic/v9/block-scan.ts | 2 +- .../rectification-agentic/v9/case-status.ts | 44 ++------ .../rectification-agentic/v9/choice-action.ts | 2 +- .../rectification-agentic/v9/choice-card.ts | 5 +- .../v9/confirmation-gate.ts | 7 +- .../rectification-agentic/v9/engine-client.ts | 7 +- .../v9/inference-adapter.ts | 9 +- .../v9/message-origin.ts | 4 +- .../rectification-agentic/v9/open-request.ts | 42 ++----- .../v9/refinement-packet.ts | 7 +- .../v9/segment-contract.ts | 5 +- .../v9/turn-intent-classifier.ts | 3 +- .../rectification-agentic/v9/turn-question.ts | 3 +- .../v9/varga-observations.ts | 7 +- .../rectification-agentic/v9/window-widen.ts | 2 +- .../src/lib/rectification-candidate-result.ts | 5 +- .../src/lib/rectification-chat-choice-run.ts | 3 +- frontend/src/lib/rectification-entry.ts | 55 +++------- frontend/src/lib/reply-quality-capture.ts | 5 +- frontend/src/lib/report-birth-binding.ts | 7 +- frontend/src/lib/report-evidence-bundle-v2.ts | 9 +- frontend/src/lib/report-fact-table-values.ts | 2 +- frontend/src/lib/report-fact-tables.ts | 3 +- frontend/src/lib/report-public-projection.ts | 4 +- frontend/src/lib/skill-package-registry.ts | 5 +- frontend/src/lib/subject-birth.ts | 7 +- frontend/src/lib/supabase/codes.ts | 5 +- frontend/src/mastra/consultation-workflow.ts | 5 +- ...rth-time-dynamic-range-progression.test.ts | 4 +- .../tests/birth-time-dynamic-scoring.test.ts | 10 +- .../birth-time-dynamic-stop-policy.test.ts | 6 +- frontend/tests/birth-time-guide-agent.test.ts | 6 +- ...irth-time-journey-score-transition.test.ts | 4 +- .../tests/birth-time-journey-test-support.ts | 8 +- .../tests/birth-time-journey-turn.test.ts | 4 +- .../tests/birth-time-scoring-memory-store.ts | 4 +- .../tests/birth-time-scoring-test-support.ts | 4 +- .../consultation-workflow-contract.test.ts | 5 +- frontend/tests/guards-and-hash.test.ts | 103 ++++++++++++++++++ .../rectification-adaptive-selector.test.ts | 4 +- 92 files changed, 575 insertions(+), 461 deletions(-) create mode 100644 docs/tasks/PROGRESS-contracts-helpers-dedupe-20261013.md create mode 100644 frontend/src/lib/contracts/personal-report-request.ts create mode 100644 frontend/src/lib/contracts/rectification-entry.ts create mode 100644 frontend/src/lib/guards.ts create mode 100644 frontend/src/lib/hash.ts create mode 100644 frontend/tests/guards-and-hash.test.ts diff --git a/docs/tasks/PROGRESS-contracts-helpers-dedupe-20261013.md b/docs/tasks/PROGRESS-contracts-helpers-dedupe-20261013.md new file mode 100644 index 00000000..faa3bf1a --- /dev/null +++ b/docs/tasks/PROGRESS-contracts-helpers-dedupe-20261013.md @@ -0,0 +1,98 @@ +# PROGRESS · 契约与小工具去重 · 2026-10-13 + +分支 `codex/contracts-helpers-dedupe-20261013`,基线 `origin/staging` `cd7e0a34933bd288ad7a529d51477c7099e1f6b1`。只搬定义和引用。哈希字节、状态字符串、主题枚举值不变。没有改表、迁移、依赖、`page.tsx`、`scripts/jyotish_api_server.py`。没有写 BUG_HISTORY、CHANGELOG、DESIGN.md。`docs/agents/domain.md` 与 `CONTEXT.md` 里没有 EvidenceDomain,未加术语句。本分支 BUG 最大号 1357,本单没有新缺陷,不占新号。 + +## 做了什么 + +- T1:报告创建请求的 zod、默认主题、深度枚举收进 `frontend/src/lib/contracts/personal-report-request.ts`。服务端类型用 `z.infer`,客户端发送类型用 `z.input`。原导出名在 route-core、plan、contract 里 re-export。按钮删掉手写 interface。 +- T2:校正状态列表与入口类型收进 `frontend/src/lib/contracts/rectification-entry.ts`。线上形状的 status 仍是 `string`。服务端类型在此之上收窄。`case-status.ts` 再导出这些数组,import 无环。客户端解析器仍接受任意字符串 status。 +- T3:旧引导流程的 `EvidenceDomain` / `evidenceDomains` 改名为 `LegacyGuidedEvidenceDomain` / `legacyGuidedEvidenceDomains`(数组里的字符串没变)。出生时间证据的 `CandidateResult` 改名为 `LegacyCandidateResult`。`ChoiceKey` 只留在 `probe-explain.ts`(`choice-card.ts` 已经 import 它,反向会成环),choice-card 再导出。`ClaimStatus` 只留在 `personal-report-contract.ts`。v9 的 EvidenceDomain 与快照的 CandidateResult 没改名。 +- T4:同义的对象判断和有限数判断收进 `frontend/src/lib/guards.ts`。`sha256Hex` 收进 `frontend/src/lib/hash.ts`(仅服务端)。长文、兑换码、消息正文改为调用它。 + +## 替换清单(文件 → 共享函数) + +调用处多数保留原来的局部名字,用 import 别名接到共享函数。原因见文末「偏离」。别名不是另一份实现。 + +| 共享函数 | 文件 | +| --- | --- | +| `isRecord` | `generate-personal-report-button.tsx`、`personal-report-page.tsx`、`account-deletion.ts`、`skill-package-registry.ts`、`report-public-projection.ts`、`compose-receipt.ts` | +| `asRecord`(调用名本来就是 asRecord) | `varga-observations.ts`、`refinement-packet.ts`、`inference-adapter.ts`、`confirmation-gate.ts`、`ephemeris-view.ts`、`daily-starlanguage.ts`、`consultation-step-timing.ts` | +| `asRecord`(别名 `recordOf`) | `subject-birth.ts`、`consultation-subject-resolver.ts`、`chart-subject-write.ts` | +| `asRecord`(别名 `record`,非数组否则 null) | `birth-profile-timezone.ts`、`chart-view-mapper.ts`、`consultation-route-service.ts`、`general-daily-panchanga.ts`、`personal-report-longform-generate.ts`、`rectification-adopted-chart-tiers.ts`、`reply-quality-capture.ts`、`report-birth-binding.ts`、`v9/engine-client.ts`、`v9/segment-contract.ts`、`personal-report-route-core.ts`、`personal-report-worker.ts`、`personal-report-generation.ts` | +| `recordOrEmpty`(别名 `record`,非数组否则 `{}`) | `consultation-workflow.ts`、`chart-birth-truth.ts`、`consultation-evidence-card.ts`、`consultation-judgment-table.ts`、`admin/rectification-telemetry-summary.ts`、`report-fact-table-values.ts`、`report-fact-tables.ts` | +| `finiteNumberOrNull`(别名 `finiteNumber`) | `personal-report-route-core.ts`、`personal-report-worker.ts`、`personal-report-generation.ts`、`rectification-candidate-result.ts` | +| `sha256Hex` | `personal-report-generation.ts` 再导出;`hashLongformMarkdown`、`hashRedeemCode`、`messageContentHash` 改为调用它 | + +## 未合并及原因 + +| 留下的私有函数 | 原因 | +| --- | --- | +| `rectification-candidate-result.ts` 的 `function record` | 数组也当对象,否则 null。与 `asRecord` 不同义 | +| `geoapify-location-service.ts` 的 `function record` | 数组也当对象,否则 `{}` | +| `server-owned-birth-profile.ts` 的 `function finiteNumber` | 接受数字字符串,失败返回 undefined | +| `chart-profiles/route.ts`、`chart-profiles/[id]/route.ts`、`synastry-reports/route.ts`、`admin/admin-error-response.ts` 的 `errorMessage` | 三种错误提取方式,任务书要求不并 | +| `personal-report-service-core.ts` 的 `PERSONAL_REPORT_DEPTHS` | 名字和用途都不是 T1 的 `REPORT_DEPTHS`,没有硬合 | + +任务书 §2.3 把「含数组否则 null」记在 `rectification-adopted-chart-tiers.ts`。当前 staging 上该函数已经排除数组,所以并入 `asRecord`。真正含数组否则 null 的那份在 `rectification-candidate-result.ts`,保留。 + +## 断言 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| `workflow.indexOf("function record(")` | `workflow.indexOf("function rectificationBoundaryFromGate(")` | 非数组否则空对象的私有 `record` 已删除。切片终点改到紧随其后的既有函数。锁住的 schema 字段没改,`chart: z.record(z.unknown())` 仍在 | + +文件:`frontend/tests/consultation-workflow-contract.test.ts`。其余点名测试没有改断言。 + +## 仅 import + +这些文件只改了 import 行,esbuild 转译结果与 staging 相同: + +`rectification-choice-card.tsx`、`rectification-message-entry.tsx`、`use-rectification-case-snapshot.ts`、`answer-choice.ts`、`block-scan.ts`、`choice-action.ts`、`turn-intent-classifier.ts`、`turn-question.ts`、`window-widen.ts`、`rectification-chat-choice-run.ts`。 + +## esbuild 差分 + +对 `frontend/src` 里每个改动文件,用 esbuild `transform`(`.ts` / `.tsx`,`format=esm`,不打包)对比 `origin/staging` 与工作区。结果:相同 26,有差异 49,新文件 4,转译失败 0。 + +新文件:`contracts/personal-report-request.ts`、`contracts/rectification-entry.ts`、`guards.ts`、`hash.ts`。 + +有差异的原因,按文件: + +| 文件 | 原因 | +| --- | --- | +| `generate-personal-report-button.tsx` | 手写请求类型删除,默认主题改为从 contracts 取值 | +| `personal-report-page.tsx`、`account-deletion.ts`、`compose-receipt.ts`、`report-public-projection.ts`、`skill-package-registry.ts` | 本地 `isRecord` 删除,改为 import | +| `consultation-step-timing.ts`、`daily-starlanguage.ts`、`ephemeris-view.ts`、`confirmation-gate.ts`、`inference-adapter.ts`、`refinement-packet.ts`、`varga-observations.ts` | 本地 `asRecord` 删除,改为 import | +| `subject-birth.ts`、`consultation-subject-resolver.ts`、`chart-subject-write.ts` | 本地 `recordOf` 删除,import `asRecord` 并别名为 `recordOf` | +| `birth-profile-timezone.ts`、`chart-view-mapper.ts`、`consultation-route-service.ts`、`general-daily-panchanga.ts`、`personal-report-longform-generate.ts`、`rectification-adopted-chart-tiers.ts`、`reply-quality-capture.ts`、`report-birth-binding.ts`、`engine-client.ts`、`segment-contract.ts` | 本地「非数组否则 null」的 `record` 删除,import `asRecord` 并别名为 `record` | +| `consultation-workflow.ts`、`chart-birth-truth.ts`、`consultation-evidence-card.ts`、`consultation-judgment-table.ts`、`rectification-telemetry-summary.ts`、`report-fact-table-values.ts`、`report-fact-tables.ts` | 本地「非数组否则空对象」的 `record` 删除,import `recordOrEmpty` 并别名为 `record`。`z.record(...)` 没改 | +| `rectification-candidate-result.ts` | 只把「有限数否则 null」改为 import。含数组的 `record` 仍在文件里 | +| `personal-report-route-core.ts`、`personal-report-worker.ts` | 同上,再加上 schema / 调用处的 guard import | +| `personal-report-generation.ts` | guard 搬家,并且 `sha256Hex` 改为从 `hash.ts` 再导出 | +| `personal-report-longform-appendix.ts`、`supabase/codes.ts`、`message-origin.ts` | 哈希函数体改为调用 `sha256Hex`。输入仍按 utf8 | +| `personal-report-plan.ts`、`personal-report-contract.ts` | `REPORT_DEPTHS` 字面量搬到 contracts,本文件 re-export | +| `case-status.ts` | 状态数组搬到 contracts,本文件 re-export。数组内容相同 | +| `rectification-entry.ts` | 本地状态列表删除,Set 改用共享常量 | +| `birth-time-question-planner.ts`、`birth-time-journey-turn.ts`、`birth-time-journey-transitions.ts`、`birth-time-journey-turn-persistence.ts`、`birth-time-journey-case-loader.ts` | 运行时常量改名 `legacyGuidedEvidenceDomains`。不打包,所以导出名出现在转译结果里。数组里的字符串没变 | + +相同的 26 个文件里,上面「仅 import」10 个只动了 import。其余是类型改名或类型再导出,类型擦除后转译结果与 staging 相同(含 `open-request.ts`、`choice-card.ts`、`report-evidence-bundle-v2.ts`、`birth-time-evidence.ts` 及其余 Legacy* 引用)。 + +## 测试 + +开工没有跑全量 `npm test`(本轮不做全量,也没有新的全量基线数字)。完工数字: + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `frontend` 下 `tsc --noEmit -p .` | 0 错 | +| 规范 | `frontend` 下 `npm run lint` | 0 错,123 条警告。警告是原来就有的。本轮删过 `personal-report-worker.ts` 里不再使用的 `JsonRecord`,没有留下新警告 | +| 改到的测试与任务书点名的测试 | `node node_modules/tsx/dist/cli.mjs --test`(guards、报告入口、首页校正解析、校正契约、报告生成与长文哈希、咨询 workflow 契约、出生时间改名相关) | 209 通过,0 失败,0 取消,0 跳过 | + +没有跑前端全量 `npm test`、Python 快速门、`test:db`。`supabase/codes.ts` 只改了哈希函数体,没有改查询、表或迁移。 + +## 偏离 + +1. 调用处没有改成共享函数名,改用 import 别名。把调用处全部改名时,已跟踪 diff 为 621 增、852 删(合计 1473),加上当时的新文件 248 行,约 1721 行,超过硬红线 1500。别名之后已跟踪 diff 为 229 增、461 删。共享函数仍只有 `guards.ts` / `hash.ts` 里的那一份。 +2. 报告按钮是客户端组件,但它要取值 `DEFAULT_REPORT_THEMES`,而这份常量和 zod schema 在同一个文件。因此这个按钮会把 zod 带进自己的包。任务书允许「zod 已在该组件 bundle 内」的例外;首屏 gzip 由验收侧看。测试仍从按钮 re-export 引用该常量。 +3. `LegacyCandidateResult` 的引用比任务书点名的文件多(向导、旅程加载等)。不改的话 `tsc` 会报错,所以一并改了类型名,没有改运行时值。 +4. 含数组变体的文件名与任务书 §2.3 不一致,以当前 staging 的函数体为准,见上表。 + +完工 diff:已跟踪 229 增 + 461 删,新文件与本记录 345 行,合计 1035,低于 1500。 diff --git a/frontend/src/components/birth-time-evidence-draft-card.tsx b/frontend/src/components/birth-time-evidence-draft-card.tsx index d04a9ebc..497db8e6 100644 --- a/frontend/src/components/birth-time-evidence-draft-card.tsx +++ b/frontend/src/components/birth-time-evidence-draft-card.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { lifeEventSchema } from "@/lib/birth-time-evidence"; import type { EvidenceDraft } from "@/lib/birth-time-journey-turn"; -import type { EvidenceDatePrecision, EvidenceDomain } from "@/lib/birth-time-question-planner"; +import type { EvidenceDatePrecision, LegacyGuidedEvidenceDomain } from "@/lib/birth-time-question-planner"; const domainLabels = { education: "学业与学习环境", @@ -12,7 +12,7 @@ const domainLabels = { career: "工作与身份变化", finance: "收入、资产或资源变化", health_pressure: "健康或生活压力", -} as const satisfies Readonly>; +} as const satisfies Readonly>; type DraftCardProps = { readonly draft: EvidenceDraft; diff --git a/frontend/src/components/personal-report/generate-personal-report-button.tsx b/frontend/src/components/personal-report/generate-personal-report-button.tsx index 7cdd5157..e240e980 100644 --- a/frontend/src/components/personal-report/generate-personal-report-button.tsx +++ b/frontend/src/components/personal-report/generate-personal-report-button.tsx @@ -14,7 +14,11 @@ "use client"; import { useRef, useState } from "react"; - +import { isRecord } from "@/lib/guards"; +import { + DEFAULT_REPORT_THEMES, + type PersonalReportCreateRequestInput, +} from "@/lib/contracts/personal-report-request"; import { useRouter } from "next/navigation"; import { readCurrentSubjectId } from "@/lib/current-subject"; import { navigateAppPath } from "@/lib/app-navigation"; @@ -24,16 +28,7 @@ import { notifyError } from "@/lib/notify"; import { Button } from "@/components/ui/button"; -export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing", "health"] as const; - -export interface PersonalReportCreateRequest { - requestId: string; - reportType: "personal_full"; - presentationMode: "default"; - depth: "standard"; - themes: readonly string[]; - chartProfileId?: string; -} +export { DEFAULT_REPORT_THEMES }; /** * uuid v4 via crypto.randomUUID (secure contexts). Returns null when the @@ -52,7 +47,11 @@ export function createReportRequestId(): string | null { /** Request shape for POST /api/reports. Never includes birth data or chat text. */ export function buildPersonalReportCreateRequest( requestId: string, -): PersonalReportCreateRequest { +): PersonalReportCreateRequestInput & { + reportType: "personal_full"; + presentationMode: "default"; + depth: "standard"; +} { const subjectId = readCurrentSubjectId(); return { requestId, @@ -68,10 +67,6 @@ export type CreateReportOutcome = | { kind: "navigate"; reportId: string } | { kind: "hint"; message: string }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readReportId(json: unknown): string | null { if (!isRecord(json) || !isRecord(json.report)) { return null; diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index bafd735b..120f8c59 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -10,6 +10,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { isRecord } from "@/lib/guards"; import Link from "next/link"; @@ -195,10 +196,6 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readSubjectDisplayName(json: unknown): string | undefined { if (!isRecord(json) || typeof json.subjectDisplayName !== "string") return undefined; const trimmed = json.subjectDisplayName.trim(); diff --git a/frontend/src/components/rectification-choice-card.tsx b/frontend/src/components/rectification-choice-card.tsx index 68f95e08..32a540b5 100644 --- a/frontend/src/components/rectification-choice-card.tsx +++ b/frontend/src/components/rectification-choice-card.tsx @@ -4,9 +4,9 @@ import { useState } from "react"; import { InlineSpinner } from "@/components/inline-spinner"; import { CHOICE_STOP_LABEL, - type ChoiceKey, type RectificationChoiceCard as ChoiceCard, } from "@/lib/rectification-agentic/v9/choice-card"; +import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card"; type RectificationChoiceCardProps = Readonly<{ card: ChoiceCard; diff --git a/frontend/src/components/rectification-message-entry.tsx b/frontend/src/components/rectification-message-entry.tsx index 14f02c36..7d0f14e3 100644 --- a/frontend/src/components/rectification-message-entry.tsx +++ b/frontend/src/components/rectification-message-entry.tsx @@ -22,9 +22,9 @@ import { CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, - type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel, } from "@/lib/rectification-agentic/v9/choice-card"; +import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card"; import { questionIsAnswered, questionIsDeadUnanswered, diff --git a/frontend/src/hooks/use-rectification-case-snapshot.ts b/frontend/src/hooks/use-rectification-case-snapshot.ts index ef001ad4..126ce3ef 100644 --- a/frontend/src/hooks/use-rectification-case-snapshot.ts +++ b/frontend/src/hooks/use-rectification-case-snapshot.ts @@ -22,9 +22,9 @@ import { caseSnapshotState, currentQuestionFromSnapshot, questionSourceFromSnapshot, - type CandidateResult, type CurrentQuestionModel, } from "@/lib/rectification-chat-snapshot"; +import type { CandidateResult } from "@/lib/rectification-chat-snapshot"; /** * Case snapshot sync for the rectification chat surface: the state that the diff --git a/frontend/src/lib/account-deletion.ts b/frontend/src/lib/account-deletion.ts index d53e3d17..95271949 100644 --- a/frontend/src/lib/account-deletion.ts +++ b/frontend/src/lib/account-deletion.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@/lib/guards"; /** * Self-service account deletion (2026-09-30). Shared by the settings entry, * the frozen-account screen, the API route and the purge worker. Pure: no @@ -52,10 +53,6 @@ export type AccountDeletionStatus = | Readonly<{ status: "none" }> | Readonly<{ status: "pending"; requestedAt: string | null; scheduledFor: string }>; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isoOrNull(value: unknown): string | null { if (typeof value !== "string" && !(value instanceof Date)) return null; const date = new Date(value); diff --git a/frontend/src/lib/admin/rectification-telemetry-summary.ts b/frontend/src/lib/admin/rectification-telemetry-summary.ts index 267c2de3..f287a7c6 100644 --- a/frontend/src/lib/admin/rectification-telemetry-summary.ts +++ b/frontend/src/lib/admin/rectification-telemetry-summary.ts @@ -1,3 +1,4 @@ +import { recordOrEmpty as record } from "@/lib/guards"; /** * Admin view of the anonymous rectification telemetry * (TASK-rectification-telemetry-20260926, D3). @@ -113,12 +114,6 @@ export const TELEMETRY_GAP_BUCKET_LABEL: Readonly> = { export const TELEMETRY_PERCENT_SHOWN_BUCKETS: readonly string[] = ["5-9", "10+"]; -function record(value: unknown): Readonly> { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : {}; -} - function numberOrNull(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value); diff --git a/frontend/src/lib/birth-profile-timezone.ts b/frontend/src/lib/birth-profile-timezone.ts index 6fca0218..5ff14767 100644 --- a/frontend/src/lib/birth-profile-timezone.ts +++ b/frontend/src/lib/birth-profile-timezone.ts @@ -1,3 +1,4 @@ +import { asRecord as record } from "@/lib/guards"; type RecordValue = Record; export class BirthProfileTimezoneError extends Error { @@ -7,12 +8,6 @@ export class BirthProfileTimezoneError extends Error { } } -function record(value: unknown): RecordValue | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as RecordValue - : null; -} - function valueFor(profile: RecordValue, snakeKey: string, camelKey: string): unknown { return profile[snakeKey] ?? profile[camelKey]; } diff --git a/frontend/src/lib/birth-time-dynamic-choice-internal.ts b/frontend/src/lib/birth-time-dynamic-choice-internal.ts index e9a31b8e..ddd5c3a4 100644 --- a/frontend/src/lib/birth-time-dynamic-choice-internal.ts +++ b/frontend/src/lib/birth-time-dynamic-choice-internal.ts @@ -11,7 +11,7 @@ import { timeRangeSchema, validateOptionSet, } from "./birth-time-dynamic-choice.ts"; -import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "./birth-time-evidence.ts"; import type { DynamicActionReceipt } from "./birth-time-dynamic-action-receipt.ts"; import type { PublicChoiceKind, PublicDynamicChoiceQuestion, TimeRange } from "./birth-time-dynamic-choice.ts"; @@ -99,7 +99,7 @@ export type ServerChoiceEvidence = { }; export type DynamicChoiceScoringResult = { - readonly candidate: CandidateResult; + readonly candidate: LegacyCandidateResult; readonly evidenceMode: "dynamic_choice"; readonly effectiveAnswerCount: number; readonly dimensionCount: number; diff --git a/frontend/src/lib/birth-time-dynamic-preview.ts b/frontend/src/lib/birth-time-dynamic-preview.ts index 7fb74463..fe492410 100644 --- a/frontend/src/lib/birth-time-dynamic-preview.ts +++ b/frontend/src/lib/birth-time-dynamic-preview.ts @@ -1,7 +1,7 @@ import { parseJourneyResponse } from "./birth-time-journey-client.ts"; import type { DynamicJourneyClientResponse } from "./birth-time-journey-response-schema.ts"; import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts"; -import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "./birth-time-evidence.ts"; const caseId = "7299894c-10a8-4b45-91d1-339007282c50"; const resultId = "345087cc-7e7f-4b37-90e5-f0c0a6e5b7a7"; @@ -52,7 +52,7 @@ const candidate = { reasons: [], evidence: [], algorithmVersion: "birth-time-dynamic-scoring-v2", -} satisfies CandidateResult; +} satisfies LegacyCandidateResult; const highCandidate = { ...candidate, @@ -62,7 +62,7 @@ const highCandidate = { eventCount: 4, domainCount: 3, marginPercent: 24, -} satisfies CandidateResult; +} satisfies LegacyCandidateResult; const terminalLowCandidate = { ...candidate, @@ -75,7 +75,7 @@ const terminalLowCandidate = { }, eventCount: 4, domainCount: 3, -} satisfies CandidateResult; +} satisfies LegacyCandidateResult; function response(input: { readonly nextAction: DynamicNextAction; @@ -83,7 +83,7 @@ function response(input: { readonly turnVersion?: number; readonly answeredCount?: number; readonly effectiveAnswerCount?: number; - readonly candidateResult?: CandidateResult | null; + readonly candidateResult?: LegacyCandidateResult | null; readonly snapshotState?: "rectifying" | "candidate" | "confirming" | "ready"; readonly activeTime?: string | null; }): DynamicJourneyClientResponse { diff --git a/frontend/src/lib/birth-time-dynamic-stop-policy.ts b/frontend/src/lib/birth-time-dynamic-stop-policy.ts index d2936b6a..8a77018c 100644 --- a/frontend/src/lib/birth-time-dynamic-stop-policy.ts +++ b/frontend/src/lib/birth-time-dynamic-stop-policy.ts @@ -1,9 +1,9 @@ -import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "./birth-time-evidence.ts"; export type DynamicStopInput = { - readonly result: CandidateResult | null; + readonly result: LegacyCandidateResult | null; readonly effectiveAnswer: boolean; - readonly previousResult: CandidateResult | null; + readonly previousResult: LegacyCandidateResult | null; readonly priorPlateauCount: number; readonly usefulOpportunityCount: number; readonly repeatedOnly: boolean; @@ -25,8 +25,8 @@ export type DynamicStopDecision = | { readonly kind: "continue"; readonly plateauCount: number }; export function materiallyChanged( - previousResult: CandidateResult | null, - result: CandidateResult, + previousResult: LegacyCandidateResult | null, + result: LegacyCandidateResult, ): boolean { if (previousResult === null) return true; const previousRange = previousResult.winningSegment; diff --git a/frontend/src/lib/birth-time-dynamic-transitions.ts b/frontend/src/lib/birth-time-dynamic-transitions.ts index 7c014e7b..e5a2c297 100644 --- a/frontend/src/lib/birth-time-dynamic-transitions.ts +++ b/frontend/src/lib/birth-time-dynamic-transitions.ts @@ -1,5 +1,5 @@ import { withCandidateResult } from "./birth-time-evidence.ts"; -import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "./birth-time-evidence.ts"; import { decideDynamicStop } from "./birth-time-dynamic-stop-policy.ts"; import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts"; import type { @@ -152,7 +152,7 @@ export function answerTransition(input: { export function completeDynamicScoreTransition(input: { readonly stored: DynamicStoredRectificationCase; - readonly candidate: CandidateResult; + readonly candidate: LegacyCandidateResult; readonly usefulOpportunityCount: number; readonly repeatedOnly: boolean; readonly nextVersion: number; diff --git a/frontend/src/lib/birth-time-evidence-service.ts b/frontend/src/lib/birth-time-evidence-service.ts index 3eabcd7f..ab1146c3 100644 --- a/frontend/src/lib/birth-time-evidence-service.ts +++ b/frontend/src/lib/birth-time-evidence-service.ts @@ -2,7 +2,7 @@ import { withCandidateResult, withConfirmedCandidate, } from "./birth-time-evidence.ts"; -import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts"; +import type { LegacyCandidateResult, LifeEvent } from "./birth-time-evidence.ts"; import type { BirthTimeJourneyPorts, JourneyResponse, @@ -61,7 +61,7 @@ export function assertLegacyJourneyMutation( function response( stored: StoredRectificationCase, lifeEvents: readonly LifeEvent[], - candidateResult: CandidateResult | null, + candidateResult: LegacyCandidateResult | null, ): JourneyResponse { return { caseId: stored.id, diff --git a/frontend/src/lib/birth-time-evidence.ts b/frontend/src/lib/birth-time-evidence.ts index 21bcee00..1dfa7504 100644 --- a/frontend/src/lib/birth-time-evidence.ts +++ b/frontend/src/lib/birth-time-evidence.ts @@ -174,11 +174,11 @@ export const candidateResultSchema = z.object({ } }); -export type CandidateResult = z.infer; +export type LegacyCandidateResult = z.infer; function highCandidateMeetsSafetyGates(value: { - readonly confidence: CandidateResult["confidence"]; - readonly winningSegment: CandidateResult["winningSegment"]; + readonly confidence: LegacyCandidateResult["confidence"]; + readonly winningSegment: LegacyCandidateResult["winningSegment"]; readonly eventCount: number; readonly domainCount: number; readonly marginPercent: number; @@ -197,7 +197,7 @@ export class CandidateConfirmationError extends Error { export function withCandidateResult( snapshot: JourneySnapshot, - result: CandidateResult, + result: LegacyCandidateResult, ): JourneySnapshot { switch (result.confidence) { case "low": @@ -246,7 +246,7 @@ export function withCandidateResult( export function withConfirmedCandidate( snapshot: JourneySnapshot, - result: CandidateResult, + result: LegacyCandidateResult, confirmedTime: string, ): JourneySnapshot { if ( diff --git a/frontend/src/lib/birth-time-guide-agent.ts b/frontend/src/lib/birth-time-guide-agent.ts index 3259e0ac..01874654 100644 --- a/frontend/src/lib/birth-time-guide-agent.ts +++ b/frontend/src/lib/birth-time-guide-agent.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { lifeEventSchema } from "./birth-time-evidence.ts"; import type { EvidenceDatePrecision, - EvidenceDomain, + LegacyGuidedEvidenceDomain, QuestionSpec, } from "./birth-time-question-planner.ts"; @@ -71,7 +71,7 @@ export const guideDraftEnvelopeSchema = z.object({ export type GuideQuestionResponse = z.infer; export type ParsedEvidenceDraft = { - readonly domain: EvidenceDomain; + readonly domain: LegacyGuidedEvidenceDomain; readonly precision: EvidenceDatePrecision | null; readonly date: string | null; readonly needsReview: boolean; @@ -100,7 +100,7 @@ const subjectByDomain = { career: "一次明显的工作、职业方向或身份变化", finance: "一次收入、资产、负债或资源渠道的明显变化", health_pressure: "一次持续的健康压力或生活压力变化", -} as const satisfies Readonly>; +} as const satisfies Readonly>; function precisionQuestion(question: QuestionSpec): string { const requested = new Set(question.requestedPrecision); @@ -187,7 +187,7 @@ function dateIsGrounded(date: string, sourceMessage: string): boolean { export function parseEvidenceDraftOutput( value: unknown, context: { - readonly requiredDomain: EvidenceDomain; + readonly requiredDomain: LegacyGuidedEvidenceDomain; readonly sourceMessage?: string; }, ): ParsedEvidenceDraft { diff --git a/frontend/src/lib/birth-time-journey-adapters.ts b/frontend/src/lib/birth-time-journey-adapters.ts index 5e6872ad..8bc995c7 100644 --- a/frontend/src/lib/birth-time-journey-adapters.ts +++ b/frontend/src/lib/birth-time-journey-adapters.ts @@ -4,7 +4,7 @@ import { candidateResultSchema, type BirthTimeAssessment, } from "./birth-time-journey.ts"; -import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "./birth-time-evidence.ts"; import type { RectificationAnswer, RectificationQuestion, @@ -260,11 +260,11 @@ export function parseRectificationAnswer(value: unknown): RectificationAnswer { return z.enum(["A", "B", "C", "D"]).parse(value); } -export function parseCandidateResult(value: unknown): CandidateResult { +export function parseCandidateResult(value: unknown): LegacyCandidateResult { return adaptCandidateResult(candidateResultApiSchema.parse(value)); } -function adaptCandidateResult(parsed: z.infer): CandidateResult { +function adaptCandidateResult(parsed: z.infer): LegacyCandidateResult { return candidateResultSchema.parse({ resultId: parsed.result_id, confidence: parsed.confidence, diff --git a/frontend/src/lib/birth-time-journey-case-loader.ts b/frontend/src/lib/birth-time-journey-case-loader.ts index 9621ac50..373ade25 100644 --- a/frontend/src/lib/birth-time-journey-case-loader.ts +++ b/frontend/src/lib/birth-time-journey-case-loader.ts @@ -10,7 +10,7 @@ import { lifeEventSchema, } from "./birth-time-journey.ts"; import type { StoredRectificationCase } from "./birth-time-journey-service.ts"; -import { evidenceDomains } from "./birth-time-question-planner.ts"; +import { legacyGuidedEvidenceDomains } from "./birth-time-question-planner.ts"; import { dynamicJourneyTurnStateSchema, evidenceDraftSchema, @@ -73,7 +73,7 @@ const storedCaseSchema = z.object({ evidence_draft: z.unknown().nullable().default(null), processed_action_ids: z.array(z.string().uuid()).default([]), adaptive_round: z.number().int().min(0).max(3).default(0), - asked_domains: z.array(z.enum(evidenceDomains)).default([]), + asked_domains: z.array(z.enum(legacyGuidedEvidenceDomains)).default([]), }); const eventLocationSchema = z.object({ latitude: z.number(), diff --git a/frontend/src/lib/birth-time-journey-score-transition.ts b/frontend/src/lib/birth-time-journey-score-transition.ts index ee04af1c..a34d8244 100644 --- a/frontend/src/lib/birth-time-journey-score-transition.ts +++ b/frontend/src/lib/birth-time-journey-score-transition.ts @@ -1,6 +1,6 @@ import { withCandidateResult, - type CandidateResult, + type LegacyCandidateResult, } from "./birth-time-evidence.ts"; import { terminalJourneySnapshot } from "./birth-time-journey-response.ts"; import { @@ -11,13 +11,13 @@ import type { LegacyStoredRectificationCase as StoredRectificationCase } from ". type CompleteScoreTransitionInput = { readonly stored: StoredRectificationCase; - readonly candidateResult: CandidateResult; + readonly candidateResult: LegacyCandidateResult; readonly nextVersion: number; }; function completedTurn( stored: StoredRectificationCase, - candidateResult: CandidateResult, + candidateResult: LegacyCandidateResult, nextVersion: number, ): JourneyTurnState { return projectJourneyTurn({ diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts index 382cd9bc..bdf580b9 100644 --- a/frontend/src/lib/birth-time-journey-service.ts +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -1,7 +1,7 @@ import { assessBirthTime, withRectificationScoring, type BirthTimeAssessment, type JourneySnapshot, type RectificationScoring } from "./birth-time-journey.ts"; import { createJourneyTurnActions } from "./birth-time-journey-actions.ts"; import { assertLegacyJourneyMutation, createBirthTimeEvidenceActions } from "./birth-time-evidence-service.ts"; -import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts"; +import type { LegacyCandidateResult, LifeEvent } from "./birth-time-evidence.ts"; import type { CandidateVargaSample } from "./birth-time-question-planner.ts"; import { projectJourneyResponse, storedDynamicJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts"; import type { JourneyTurnState } from "./birth-time-journey-turn.ts"; @@ -97,7 +97,7 @@ export type DynamicChoiceScoreInput = Pick; score(input: JourneyScoreInput): Promise; - scoreEvents(input: JourneyEventScoreInput): Promise; + scoreEvents(input: JourneyEventScoreInput): Promise; buildDifferencePacket(input: DifferencePacketInput): Promise; scoreChoices(input: DynamicChoiceScoreInput): Promise; } @@ -127,7 +127,7 @@ type StoredRectificationCaseBase = { readonly ayanamsa?: AyanamsaName; }; readonly lifeEvents?: readonly LifeEvent[]; - readonly candidateResult?: CandidateResult | null; + readonly candidateResult?: LegacyCandidateResult | null; }; export type LegacyStoredRectificationCase = StoredRectificationCaseBase & LegacyStoredFields; @@ -189,7 +189,7 @@ export type JourneyResponseBase = { readonly scoring: RectificationScoringResult | null; readonly answers: Readonly>; readonly lifeEvents: readonly LifeEvent[]; - readonly candidateResult: CandidateResult | null; + readonly candidateResult: LegacyCandidateResult | null; }; export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState diff --git a/frontend/src/lib/birth-time-journey-stored-protocol.ts b/frontend/src/lib/birth-time-journey-stored-protocol.ts index 9133166e..fac8be25 100644 --- a/frontend/src/lib/birth-time-journey-stored-protocol.ts +++ b/frontend/src/lib/birth-time-journey-stored-protocol.ts @@ -6,11 +6,11 @@ import type { } from "./birth-time-dynamic-choice-internal.ts"; import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts"; import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts"; -import type { EvidenceDomain } from "./birth-time-question-planner.ts"; +import type { LegacyGuidedEvidenceDomain } from "./birth-time-question-planner.ts"; type LegacyProgress = { readonly adaptiveRound: number; - readonly askedDomains: readonly EvidenceDomain[]; + readonly askedDomains: readonly LegacyGuidedEvidenceDomain[]; }; export type LegacyStoredFields = { diff --git a/frontend/src/lib/birth-time-journey-transitions.ts b/frontend/src/lib/birth-time-journey-transitions.ts index d2a6a00d..48839161 100644 --- a/frontend/src/lib/birth-time-journey-transitions.ts +++ b/frontend/src/lib/birth-time-journey-transitions.ts @@ -7,9 +7,9 @@ import { type NextAction, } from "./birth-time-journey-turn.ts"; import { - evidenceDomains, + legacyGuidedEvidenceDomains, planEvidenceQuestion, - type EvidenceDomain, + type LegacyGuidedEvidenceDomain, type EvidenceQuestionPhase, type QuestionSpec, } from "./birth-time-question-planner.ts"; @@ -22,19 +22,19 @@ import type { LegacyStoredRectificationCase as StoredRectificationCase } from ". type PlannedTurnInput = { readonly stored: StoredRectificationCase; readonly phase: EvidenceQuestionPhase; - readonly askedDomains: readonly EvidenceDomain[]; + readonly askedDomains: readonly LegacyGuidedEvidenceDomain[]; readonly adaptiveRound: number; readonly turnVersion: number; }; export type EvidenceQuestionIdentity = Pick; -function coveredDomains(events: readonly LifeEvent[]): readonly EvidenceDomain[] { - return evidenceDomains.filter((domain) => events.some((event) => event.domain === domain)); +function coveredDomains(events: readonly LifeEvent[]): readonly LegacyGuidedEvidenceDomain[] { + return legacyGuidedEvidenceDomains.filter((domain) => events.some((event) => event.domain === domain)); } -export function canonicalAskedDomains(domains: readonly EvidenceDomain[]): readonly EvidenceDomain[] { - return evidenceDomains.filter((domain) => domains.includes(domain)); +export function canonicalAskedDomains(domains: readonly LegacyGuidedEvidenceDomain[]): readonly LegacyGuidedEvidenceDomain[] { + return legacyGuidedEvidenceDomains.filter((domain) => domains.includes(domain)); } function progress( diff --git a/frontend/src/lib/birth-time-journey-turn-persistence.ts b/frontend/src/lib/birth-time-journey-turn-persistence.ts index 588a105a..0de5a979 100644 --- a/frontend/src/lib/birth-time-journey-turn-persistence.ts +++ b/frontend/src/lib/birth-time-journey-turn-persistence.ts @@ -2,8 +2,8 @@ import type { DataClient } from "@/lib/db/data-client"; import { z } from "zod"; import type { JourneySnapshot } from "./birth-time-journey.ts"; import type { LegacyStoredRectificationCase, StoredRectificationCase } from "./birth-time-journey-service.ts"; -import { evidenceDomains } from "./birth-time-question-planner.ts"; -import type { EvidenceDomain } from "./birth-time-question-planner.ts"; +import { legacyGuidedEvidenceDomains } from "./birth-time-question-planner.ts"; +import type { LegacyGuidedEvidenceDomain } from "./birth-time-question-planner.ts"; import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts"; import { BirthTimeJourneyStoreError, @@ -17,7 +17,7 @@ export { BirthTimeJourneyStoreError, StaleJourneyTurnError } from "./birth-time- export type PersistedJourneyProgress = { readonly adaptiveRound: number; - readonly askedDomains: readonly EvidenceDomain[]; + readonly askedDomains: readonly LegacyGuidedEvidenceDomain[]; }; export type PersistedJourneyTurn = { @@ -51,8 +51,8 @@ export function profileStatus(snapshot: JourneySnapshot) { return caseStatus(snapshot); } -function canonicalAskedDomains(input: readonly EvidenceDomain[]): readonly EvidenceDomain[] { - return evidenceDomains.filter((domain) => input.includes(domain)); +function canonicalAskedDomains(input: readonly LegacyGuidedEvidenceDomain[]): readonly LegacyGuidedEvidenceDomain[] { + return legacyGuidedEvidenceDomains.filter((domain) => input.includes(domain)); } export function createJourneyTurnPersistence( diff --git a/frontend/src/lib/birth-time-journey-turn.ts b/frontend/src/lib/birth-time-journey-turn.ts index ad60a9d7..f00c4889 100644 --- a/frontend/src/lib/birth-time-journey-turn.ts +++ b/frontend/src/lib/birth-time-journey-turn.ts @@ -1,5 +1,5 @@ -import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts"; -import { evidenceDomains, planEvidenceQuestion, type CandidateVargaSample, type EvidenceDomain, type QuestionSpec } from "./birth-time-question-planner.ts"; +import type { LegacyCandidateResult, LifeEvent } from "./birth-time-evidence.ts"; +import { legacyGuidedEvidenceDomains, planEvidenceQuestion, type CandidateVargaSample, type LegacyGuidedEvidenceDomain, type QuestionSpec } from "./birth-time-question-planner.ts"; import type { JourneySnapshot } from "./birth-time-journey.ts"; import type { EvidenceDraft, JourneyPermissions, JourneyProgress, JourneyTurnState, NextAction } from "./birth-time-journey-turn-protocol.ts"; @@ -20,7 +20,7 @@ export type { export type DeriveNextActionInput = { readonly progress: JourneyProgress; - readonly candidateResult?: CandidateResult | null; + readonly candidateResult?: LegacyCandidateResult | null; readonly nextQuestion?: QuestionSpec | null; readonly confirmedTime?: string | null; readonly evidenceDraft?: EvidenceDraft | null; @@ -114,7 +114,7 @@ export function deriveNextAction(input: DeriveNextActionInput): NextAction { } export function deriveJourneyPermissions( - candidateResult: CandidateResult | null, + candidateResult: LegacyCandidateResult | null, confirmedTime: string | null, ): JourneyPermissions { return { @@ -158,14 +158,14 @@ export type JourneyTurnProjectionInput = { readonly questionnaire: { readonly samples: readonly CandidateVargaSample[] } | null; readonly persistedProgress?: { readonly adaptiveRound: number; - readonly askedDomains: readonly EvidenceDomain[]; + readonly askedDomains: readonly LegacyGuidedEvidenceDomain[]; } | null; - readonly candidateResult: CandidateResult | null; + readonly candidateResult: LegacyCandidateResult | null; readonly serverSelectedQuestion?: { readonly id: string; readonly domain?: string } | null; readonly lifeEvents: readonly LifeEvent[]; }; -const serverDomainMap: Readonly> = { +const serverDomainMap: Readonly> = { education: "education", relocation: "relocation", residence: "relocation", @@ -220,7 +220,7 @@ export function projectJourneyTurn(input: JourneyTurnProjectionInput): JourneyTu const confirmedTime = input.snapshot.state === "ready" ? input.snapshot.activeTime : null; const coveredDomains = [...new Set(input.lifeEvents.map((event) => event.domain))]; const adaptiveRound = input.persistedProgress?.adaptiveRound ?? 0; - const askedDomains = evidenceDomains.filter((domain) => coveredDomains.includes(domain) || input.persistedProgress?.askedDomains.includes(domain) === true); + const askedDomains = legacyGuidedEvidenceDomains.filter((domain) => coveredDomains.includes(domain) || input.persistedProgress?.askedDomains.includes(domain) === true); const displayedAdaptiveRound = input.candidateResult?.confidence === "low" && adaptiveRound < 3 ? adaptiveRound + 1 diff --git a/frontend/src/lib/birth-time-question-planner.ts b/frontend/src/lib/birth-time-question-planner.ts index 483afb92..5f32faf8 100644 --- a/frontend/src/lib/birth-time-question-planner.ts +++ b/frontend/src/lib/birth-time-question-planner.ts @@ -1,8 +1,8 @@ -export const evidenceDomains = [ +export const legacyGuidedEvidenceDomains = [ "education", "relocation", "relationship", "career", "finance", "health_pressure", ] as const; -export type EvidenceDomain = (typeof evidenceDomains)[number]; +export type LegacyGuidedEvidenceDomain = (typeof legacyGuidedEvidenceDomains)[number]; export type EvidenceQuestionPhase = "baseline" | "adaptive"; export type EvidenceDatePrecision = "day" | "month" | "year"; @@ -22,15 +22,15 @@ export type CandidateVargaSample = { export type QuestionPlannerInput = { readonly phase: EvidenceQuestionPhase; readonly samples: readonly CandidateVargaSample[]; - readonly askedDomains: readonly EvidenceDomain[]; - readonly coveredDomains: readonly EvidenceDomain[]; + readonly askedDomains: readonly LegacyGuidedEvidenceDomain[]; + readonly coveredDomains: readonly LegacyGuidedEvidenceDomain[]; readonly adaptiveRound: number; }; export type QuestionSpec = { readonly questionId: string; readonly phase: EvidenceQuestionPhase; - readonly domain: EvidenceDomain; + readonly domain: LegacyGuidedEvidenceDomain; readonly requestedPrecision: readonly EvidenceDatePrecision[]; readonly allowUnknown: true; readonly purposeCode: string; @@ -47,7 +47,7 @@ const layerByDomain = { } as const; function questionSpecFor( - domain: EvidenceDomain, + domain: LegacyGuidedEvidenceDomain, phase: EvidenceQuestionPhase, adaptiveRound: number, ): QuestionSpec { @@ -63,7 +63,7 @@ function questionSpecFor( } export function planEvidenceQuestion(input: QuestionPlannerInput): QuestionSpec | null { - const available = evidenceDomains.filter((domain) => !input.askedDomains.includes(domain) + const available = legacyGuidedEvidenceDomains.filter((domain) => !input.askedDomains.includes(domain) && (domain !== "finance" || (input.phase === "adaptive" && input.coveredDomains.length >= 2))); const ranked = available.map((domain) => ({ domain, @@ -71,7 +71,7 @@ export function planEvidenceQuestion(input: QuestionPlannerInput): QuestionSpec coverageBonus: input.coveredDomains.includes(domain) ? 0 : 1, })).sort((left, right) => right.split - left.split || right.coverageBonus - left.coverageBonus - || evidenceDomains.indexOf(left.domain) - evidenceDomains.indexOf(right.domain)); + || legacyGuidedEvidenceDomains.indexOf(left.domain) - legacyGuidedEvidenceDomains.indexOf(right.domain)); const winner = ranked[0]; return winner ? questionSpecFor(winner.domain, input.phase, input.adaptiveRound) : null; } diff --git a/frontend/src/lib/birth-time-scoring-service.ts b/frontend/src/lib/birth-time-scoring-service.ts index 967e0ed2..2cf6544a 100644 --- a/frontend/src/lib/birth-time-scoring-service.ts +++ b/frontend/src/lib/birth-time-scoring-service.ts @@ -10,11 +10,11 @@ import type { BirthTimeJourneyPorts, LegacyStoredRectificationCase as StoredRectificationCase, } from "./birth-time-journey-service.ts"; -import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "./birth-time-evidence.ts"; function requireMatchingEvidenceCounts( stored: StoredRectificationCase, - candidate: CandidateResult, + candidate: LegacyCandidateResult, ): void { const events = stored.lifeEvents ?? []; const domainCount = new Set(events.map((event) => event.domain)).size; @@ -80,7 +80,7 @@ export function createBirthTimeScoringService(ports: BirthTimeJourneyPorts) { if (claim.algorithmVersion !== birthTimeScoringAlgorithmVersion) { throw new BirthTimeScoringJobError("algorithm_mismatch"); } - let candidateResult: CandidateResult; + let candidateResult: LegacyCandidateResult; try { candidateResult = await ports.engine.scoreEvents(requireEventContext(stored)); if (candidateResult.algorithmVersion !== claim.algorithmVersion) { diff --git a/frontend/src/lib/chart-birth-truth.ts b/frontend/src/lib/chart-birth-truth.ts index 73f7b62e..6251e9ab 100644 --- a/frontend/src/lib/chart-birth-truth.ts +++ b/frontend/src/lib/chart-birth-truth.ts @@ -1,6 +1,7 @@ import { resolveAyanamsa } from "./ayanamsa.ts"; import { birthCalendarDate } from "./effective-birth-date.ts"; import { globalBirthProfileFromAccountRow } from "./server-owned-birth-profile.ts"; +import { recordOrEmpty as record } from "@/lib/guards"; export const CHART_BIRTH_FAILURE_REASONS = [ "profile_query_error", @@ -50,12 +51,6 @@ const ADOPTION_CLEAR_KEYS = [ "timezone_offset", ] as const; -function record(value: unknown): Record { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : {}; -} - function text(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); diff --git a/frontend/src/lib/chart-subject-write.ts b/frontend/src/lib/chart-subject-write.ts index d94c7c70..9248cbfb 100644 --- a/frontend/src/lib/chart-subject-write.ts +++ b/frontend/src/lib/chart-subject-write.ts @@ -1,6 +1,7 @@ import { isAyanamsaName, resolveAyanamsa } from "./ayanamsa.ts"; import { parseProfileGender } from "./profile-gender.ts"; import { MAX_OTHER_SUBJECTS } from "./subject-birth.ts"; +import { asRecord as recordOf } from "@/lib/guards"; const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const DATE = /^(?:19|20)\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/; @@ -13,12 +14,6 @@ export type ChartSubjectWrite = { readonly profile: Record; }; -function recordOf(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : null; -} - function text(value: unknown, max: number): string { return typeof value === "string" ? value.trim().slice(0, max) : ""; } diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index c98c2a09..f8ae6134 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -39,6 +39,7 @@ import { type ChartViewWesternOk, type NorthIndianChart, } from "./chart-view-contract.ts"; +import { asRecord as record } from "@/lib/guards"; export type ChartViewProfileInput = { name?: string; @@ -64,12 +65,6 @@ export type ChartViewEngineBundle = { asOf: string; }; -function record(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : null; -} - function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } diff --git a/frontend/src/lib/consultation-evidence-card.ts b/frontend/src/lib/consultation-evidence-card.ts index f57ea224..d13dc7e1 100644 --- a/frontend/src/lib/consultation-evidence-card.ts +++ b/frontend/src/lib/consultation-evidence-card.ts @@ -2,6 +2,7 @@ import type { ConsultationDomain } from "./consultation-domain-registry.ts"; import { consultationDomainDefinition } from "./consultation-domain-registry.ts"; import { assembleJudgmentTable, judgmentTableModelView, participationModelView } from "./consultation-judgment-table.ts"; import type { ProfileGender } from "./profile-gender.ts"; +import { recordOrEmpty as record } from "@/lib/guards"; /** * The evidence card: what the answer-writing model reads about the chart @@ -379,10 +380,6 @@ export type EvidenceCardSource = Readonly<{ context: unknown; }>; -function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -} - function isJson(value: unknown): value is Json { if (value === null || typeof value === "string" || typeof value === "boolean") return true; if (typeof value === "number") return Number.isFinite(value); diff --git a/frontend/src/lib/consultation-judgment-table.ts b/frontend/src/lib/consultation-judgment-table.ts index 0ad816b6..53c640ec 100644 --- a/frontend/src/lib/consultation-judgment-table.ts +++ b/frontend/src/lib/consultation-judgment-table.ts @@ -1,3 +1,4 @@ +import { recordOrEmpty as record } from "@/lib/guards"; /** * One judgment table per consult domain (TASK-consult-card-full-source-20261007). * @@ -34,10 +35,6 @@ const MALEFIC_BODIES = ["Saturn", "Mars", "Rahu", "Ketu"] as const; const ASPECT_MALEFICS = ["Saturn", "Mars"] as const; const DUSTHANA = new Set([6, 8, 12]); -function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -} - function isJson(value: unknown): value is Json { if (value === null || typeof value === "string" || typeof value === "boolean") return true; if (typeof value === "number") return Number.isFinite(value); diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts index 304271c8..712c38c1 100644 --- a/frontend/src/lib/consultation-route-service.ts +++ b/frontend/src/lib/consultation-route-service.ts @@ -20,6 +20,7 @@ import { type ConsultationSubjectBinding, type OwnedChartProfile, } from "./consultation-subject-resolver.ts"; +import { asRecord as record } from "@/lib/guards"; export type ConsultationProfileTruthErrorCode = | "profile_unavailable" @@ -213,12 +214,6 @@ export function declaredBirthAccuracyFromProfile(input: { return { declaredAccuracy: "15min", timeSource: "family_vague" }; } -function record(value: unknown): RecordValue | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as RecordValue - : null; -} - function requiredText(profile: RecordValue, key: string): string { const value = profile[key]; if (typeof value !== "string" || !value.trim()) { diff --git a/frontend/src/lib/consultation-step-timing.ts b/frontend/src/lib/consultation-step-timing.ts index eeb90203..71c0c8ee 100644 --- a/frontend/src/lib/consultation-step-timing.ts +++ b/frontend/src/lib/consultation-step-timing.ts @@ -1,3 +1,4 @@ +import { asRecord } from "@/lib/guards"; /** * Per-step clocks for one ordinary consultation. * @@ -49,12 +50,6 @@ function tokenOrNull(value: unknown): number | null { return truncated > 1_000_000_000 ? null : truncated; } -function asRecord(value: unknown): UsageRecord | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as UsageRecord - : null; -} - /** Read the provider's cached-input count. Absent means null, including when only a sibling field exists. */ export function cachedInputTokensFromUsage(usage: UsageRecord): number | null { if ("cachedInputTokens" in usage) return tokenOrNull(usage.cachedInputTokens); diff --git a/frontend/src/lib/consultation-subject-resolver.ts b/frontend/src/lib/consultation-subject-resolver.ts index 769155e9..ad99f91b 100644 --- a/frontend/src/lib/consultation-subject-resolver.ts +++ b/frontend/src/lib/consultation-subject-resolver.ts @@ -11,6 +11,7 @@ import { ConsultationSubjectError, resolveSubjectBirth, } from "./subject-birth.ts"; +import { asRecord as recordOf } from "@/lib/guards"; export { ConsultationSubjectError, @@ -58,12 +59,6 @@ function textOrNull(value: unknown): string | null { return trimmed ? trimmed : null; } -function recordOf(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : null; -} - function pickText(row: Record, keys: readonly string[]): string | null { for (const key of keys) { const value = textOrNull(row[key]); diff --git a/frontend/src/lib/contracts/personal-report-request.ts b/frontend/src/lib/contracts/personal-report-request.ts new file mode 100644 index 00000000..2445f558 --- /dev/null +++ b/frontend/src/lib/contracts/personal-report-request.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const; +export type ReportDepth = (typeof REPORT_DEPTHS)[number]; + +export const reportRequestThemes = z.enum([ + "career", + "marriage", + "wealth", + "health", + "timing", + "general", +]); + +/** Same strings as the schema default for `themes`. */ +export const DEFAULT_REPORT_THEMES = [ + "career", + "marriage", + "wealth", + "timing", + "health", +] as const; + +export const personalReportCreateRequestSchema = z.object({ + requestId: z.string().uuid(), + sessionId: z.string().uuid().nullable().optional(), + chartProfileId: z.string().uuid().nullable().optional(), + reportType: z.enum(["personal_full", "personal_thematic"]), + presentationMode: z.enum(["default", "research"]).default("default"), + depth: z.enum(REPORT_DEPTHS).default("standard"), + themes: z.array(reportRequestThemes).min(1).max(6).default([...DEFAULT_REPORT_THEMES]), +}).strict(); + +/** Parsed server request. Defaulted fields are present. */ +export type PersonalReportCreateRequest = z.infer; + +/** Client payload. Defaulted fields stay optional so callers can omit them. */ +export type PersonalReportCreateRequestInput = z.input; diff --git a/frontend/src/lib/contracts/rectification-entry.ts b/frontend/src/lib/contracts/rectification-entry.ts new file mode 100644 index 00000000..61ab3f4c --- /dev/null +++ b/frontend/src/lib/contracts/rectification-entry.ts @@ -0,0 +1,78 @@ +export const RECTIFICATION_CASE_STATUSES = [ + "draft", + "collecting_evidence", + "candidate_ready", + "candidate_accepted", + "needs_rebaseline", + "paused", + "confirmed", + "closed", + "abandoned", + "superseded", +] as const; + +export type RectificationCaseStatus = (typeof RECTIFICATION_CASE_STATUSES)[number]; + +/** Statuses that may be resumed by the user. */ +export const RESUMABLE_CASE_STATUSES: readonly RectificationCaseStatus[] = [ + "draft", + "collecting_evidence", + "candidate_ready", + "candidate_accepted", + "needs_rebaseline", + "paused", +]; + +/** Statuses that only allow read-only history access. */ +export const TERMINAL_CASE_STATUSES: readonly RectificationCaseStatus[] = [ + "confirmed", + "closed", + "abandoned", + "superseded", +]; + +export type OpenRectificationDisposition = "created" | "resumed" | "readonly"; + +/** Wire shape. Status stays a string so cached and older payloads still parse. */ +export type OpenRectificationCaseResponseWire = Readonly<{ + disposition: OpenRectificationDisposition; + caseId: string; + sessionId: string; + status: string; + shouldStartOpening: boolean; + skillVersion: string; +}>; + +export type OpenRectificationCaseResponse = Omit & { + readonly status: RectificationCaseStatus; +}; + +type LatestResumableWire = Readonly<{ + caseId: string; + status: string; + lastActivityAt: string; +}>; + +type LatestTerminalWire = Readonly<{ + caseId: string; + status: string; + hasUsableTime: boolean; +}>; + +export type RectificationEntrySummaryWire = Readonly<{ + hasResumableCase: boolean; + hasTerminalCaseWithTime: boolean; + latestResumable: LatestResumableWire | null; + latestTerminal: LatestTerminalWire | null; +}>; + +export type RectificationEntrySummary = Readonly<{ + hasResumableCase: boolean; + hasTerminalCaseWithTime: boolean; + latestResumable: (Omit & { + readonly status: RectificationCaseStatus; + }) | null; + latestTerminal: (Omit & { + readonly status: RectificationCaseStatus; + }) | null; +}>; diff --git a/frontend/src/lib/daily-starlanguage.ts b/frontend/src/lib/daily-starlanguage.ts index 3e2ccde1..4396f836 100644 --- a/frontend/src/lib/daily-starlanguage.ts +++ b/frontend/src/lib/daily-starlanguage.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { effectiveBirthDate } from "./effective-birth-date"; +import { asRecord } from "@/lib/guards"; export type DailyStarlanguageCard = { readonly trend: string; @@ -53,10 +54,6 @@ function normalizeLine(value: string): string { return value.replace(/\s+/g, " ").trim().slice(0, 90); } -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; -} - function asArray(value: unknown): readonly unknown[] { return Array.isArray(value) ? value : []; } diff --git a/frontend/src/lib/ephemeris-view.ts b/frontend/src/lib/ephemeris-view.ts index 437ac4f8..23977ea5 100644 --- a/frontend/src/lib/ephemeris-view.ts +++ b/frontend/src/lib/ephemeris-view.ts @@ -7,6 +7,7 @@ import type { } from "@/lib/ephemeris-contract"; import { isAyanamsaName, type AyanamsaName } from "@/lib/ayanamsa"; import { normalizePanchangaName, panchangaNameZh, splitTithiName } from "@/lib/panchanga-names-zh"; +import { asRecord } from "@/lib/guards"; export const EPHEMERIS_COPY = { title: "星历", @@ -96,12 +97,6 @@ const FACTOR_LABEL: Record = { karana: "迦那", }; -function asRecord(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : null; -} - function asText(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } diff --git a/frontend/src/lib/general-daily-panchanga.ts b/frontend/src/lib/general-daily-panchanga.ts index 09251045..b7494aeb 100644 --- a/frontend/src/lib/general-daily-panchanga.ts +++ b/frontend/src/lib/general-daily-panchanga.ts @@ -1,3 +1,4 @@ +import { asRecord as record } from "@/lib/guards"; type RecordValue = Record; export type GeneralDailyReference = Readonly<{ @@ -34,12 +35,6 @@ type LoadGeneralDailyPanchangaInput = Readonly<{ apiBase?: string; }>; -function record(value: unknown): RecordValue | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as RecordValue - : null; -} - function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } diff --git a/frontend/src/lib/guards.ts b/frontend/src/lib/guards.ts new file mode 100644 index 00000000..db53aa6f --- /dev/null +++ b/frontend/src/lib/guards.ts @@ -0,0 +1,20 @@ +/** + * Shared guards for non-array objects and finite numbers. + * Array-accepting record variants and string-to-number parsing stay private. + */ + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asRecord(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + +export function recordOrEmpty(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +export function finiteNumberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/frontend/src/lib/hash.ts b/frontend/src/lib/hash.ts new file mode 100644 index 00000000..339a0642 --- /dev/null +++ b/frontend/src/lib/hash.ts @@ -0,0 +1,9 @@ +/** + * SHA-256 hex digest. + * Imports node:crypto. Server modules only — do not import this file from client components. + */ +import { createHash } from "node:crypto"; + +export function sha256Hex(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} diff --git a/frontend/src/lib/personal-report-contract.ts b/frontend/src/lib/personal-report-contract.ts index b5e5de29..ff9f4429 100644 --- a/frontend/src/lib/personal-report-contract.ts +++ b/frontend/src/lib/personal-report-contract.ts @@ -15,6 +15,9 @@ import { z } from "zod"; import { reportFactTablesSchema } from "./report-fact-table-schema"; +import { REPORT_DEPTHS, type ReportDepth } from "./contracts/personal-report-request"; + +export { REPORT_DEPTHS, type ReportDepth }; export const LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const; export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v2" as const; @@ -39,8 +42,6 @@ export type ClaimStatus = (typeof CLAIM_STATUSES)[number]; export const REPORT_TYPES = ["personal_full", "personal_thematic"] as const; export const PRESENTATION_MODES = ["default", "research"] as const; -export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const; -export type ReportDepth = (typeof REPORT_DEPTHS)[number]; export const BIRTH_TIME_STATUSES = ["reported", "candidate", "accepted", "confirmed"] as const; export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const; export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const; diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts index 4de0bdaa..e0d2583e 100644 --- a/frontend/src/lib/personal-report-generation.ts +++ b/frontend/src/lib/personal-report-generation.ts @@ -1,5 +1,5 @@ -import { createHash } from "node:crypto"; import { assembleReportFactTables } from "./report-fact-tables"; +import { sha256Hex } from "./hash"; import { computeEvidenceHash, safeParseServerReportDocument, @@ -10,13 +10,13 @@ import { CHART_IDS, REPORT_DOCUMENT_V2_ACTION_NOTES_MAX, REQUIRED_THEME_CHARTS, - type ClaimStatus, type EvidenceAppendix, type ReportDepth, type ReportDocumentParseError, type ReportDocumentV1, type ReportDocumentV2, } from "./personal-report-contract.ts"; +import type { ClaimStatus } from "./personal-report-contract.ts"; import { PersonalReportAgentOutputError, type EvidenceRefStatus, @@ -62,8 +62,10 @@ import { } from "./personal-report-plan.ts"; import type { PersonalReportSectionRecord, PersonalReportSectionService } from "./personal-report-section-service-core.ts"; import { withReportBirthFingerprint } from "./report-birth-binding.ts"; +import { asRecord as record, finiteNumberOrNull as finiteNumber } from "@/lib/guards"; // Compatibility re-export: prefer importing from ./personal-report-codes.ts // directly (the pure, dependency-free codes module). +export { sha256Hex }; export { REPORT_STABLE_CODES } from "./personal-report-codes.ts"; /** @@ -105,10 +107,6 @@ export function canonicalSerialize(value: unknown): string { return JSON.stringify(value); } -export function sha256Hex(text: string): string { - return createHash("sha256").update(text).digest("hex"); -} - const sha256Pattern = /^[0-9a-f]{64}$/; /** @@ -183,22 +181,12 @@ export function resolveSkillSnapshot(): SkillSnapshot { type JsonRecord = Record; -function record(value: unknown): JsonRecord | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as JsonRecord - : null; -} - function text(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } -function finiteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - function booleanValue(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } diff --git a/frontend/src/lib/personal-report-longform-appendix.ts b/frontend/src/lib/personal-report-longform-appendix.ts index c9ff8c65..9d4d3f2a 100644 --- a/frontend/src/lib/personal-report-longform-appendix.ts +++ b/frontend/src/lib/personal-report-longform-appendix.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { sha256Hex } from "./hash"; export const LONGFORM_APPENDIX_TABLE = "personal_report_longform_appendices"; export const LONGFORM_APPENDIX_MAX_ATTEMPTS = 2; @@ -25,7 +25,7 @@ export type LongformAppendixWrite = Readonly<{ }>; export function hashLongformMarkdown(markdown: string): string { - return createHash("sha256").update(markdown, "utf8").digest("hex"); + return sha256Hex(markdown); } export function parseLongformAppendixRow(value: unknown): LongformAppendixRow | null { diff --git a/frontend/src/lib/personal-report-longform-generate.ts b/frontend/src/lib/personal-report-longform-generate.ts index 5fa4198d..33b0dec0 100644 --- a/frontend/src/lib/personal-report-longform-generate.ts +++ b/frontend/src/lib/personal-report-longform-generate.ts @@ -6,6 +6,7 @@ import { persistLongformSnapshot, readLongformSnapshot, validateLongformSnapshot import { acceptEnglishEdition, type EnglishOutcome } from "./personal-report-english-edition"; import { hashLongformMarkdown } from "./personal-report-longform-appendix"; import { annotateUnreliableChartSections } from "./personal-report-chart-caveats"; +import { asRecord as record } from "@/lib/guards"; import { LONGFORM_APPENDIX_TABLE, @@ -70,12 +71,6 @@ export type LongformGenerateDeps = Readonly<{ signal?: AbortSignal; }>; -function record(value: unknown): JsonRecord | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as JsonRecord - : null; -} - function text(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); diff --git a/frontend/src/lib/personal-report-plan.ts b/frontend/src/lib/personal-report-plan.ts index 95ea6648..1f3c1df3 100644 --- a/frontend/src/lib/personal-report-plan.ts +++ b/frontend/src/lib/personal-report-plan.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import type { ReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts"; +import { REPORT_DEPTHS, type ReportDepth } from "./contracts/personal-report-request.ts"; -export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const; -export type ReportDepth = (typeof REPORT_DEPTHS)[number]; +export { REPORT_DEPTHS, type ReportDepth }; export const REPORT_SECTION_KINDS = [ "executive_summary", diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index a3a0e855..0b2c662d 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -13,8 +13,12 @@ import { parseReportCandidateRange, type ReportCandidateClockRange } from "./rep * deletes, and the persistence service scopes every query by userId. */ -import { z } from "zod"; import { effectiveBirthDate } from "./effective-birth-date"; +import { asRecord as record, finiteNumberOrNull as finiteNumber } from "@/lib/guards"; +import { + personalReportCreateRequestSchema, + type PersonalReportCreateRequest, +} from "./contracts/personal-report-request"; import type { ConsultationInput } from "@/mastra"; import { resolveAyanamsa } from "@/lib/ayanamsa"; import type { @@ -49,19 +53,7 @@ import type { PersonalReportService, } from "./personal-report-service-core"; -const reportRequestThemes = z.enum(["career", "marriage", "wealth", "health", "timing", "general"]); - -export const personalReportCreateRequestSchema = z.object({ - requestId: z.string().uuid(), - sessionId: z.string().uuid().nullable().optional(), - chartProfileId: z.string().uuid().nullable().optional(), - reportType: z.enum(["personal_full", "personal_thematic"]), - presentationMode: z.enum(["default", "research"]).default("default"), - depth: z.enum(["concise", "standard", "deep", "research"]).default("standard"), - themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing", "health"]), -}).strict(); - -export type PersonalReportCreateRequest = z.infer; +export { personalReportCreateRequestSchema, type PersonalReportCreateRequest }; export type ReportRouteResponse = Readonly<{ status: number; @@ -81,22 +73,12 @@ export type ReportServicePort = Pick< type JsonRecord = Record; -function record(value: unknown): JsonRecord | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as JsonRecord - : null; -} - function text(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } -function finiteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - const CONCRETE_REPORTED_SOURCES = new Set(["hospital_record", "family_exact", "approximate"]); export type ReportSubjectBirthTimeStatus = "reported" | "candidate" | "accepted" | "confirmed"; diff --git a/frontend/src/lib/personal-report-worker.ts b/frontend/src/lib/personal-report-worker.ts index e3db4bc5..69fc0e3f 100644 --- a/frontend/src/lib/personal-report-worker.ts +++ b/frontend/src/lib/personal-report-worker.ts @@ -1,6 +1,7 @@ import type { ReportCandidateClockRange } from "./report-candidate-range"; import "server-only"; import { effectiveBirthDate } from "./effective-birth-date"; +import { asRecord as record, finiteNumberOrNull as finiteNumber } from "@/lib/guards"; import { runConsultationWorkflow } from "@/mastra"; import { createPersonalReportAgent } from "@/mastra/personal-report"; @@ -38,7 +39,6 @@ import { resolveAyanamsa } from "@/lib/ayanamsa"; import { loadReportWorkerProfile, reportWorkerUsesCandidateRange } from "@/lib/personal-report-worker-subject"; import { readReportBirthFingerprint } from "@/lib/report-birth-binding"; -type JsonRecord = Record; type WorkerGlobal = typeof globalThis & { jyotishaPersonalReportWorker?: PersonalReportWorkerHandle; }; @@ -49,22 +49,12 @@ export type PersonalReportWorkerHandle = Readonly<{ stop: () => void; }>; -function record(value: unknown): JsonRecord | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as JsonRecord - : null; -} - function text(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } -function finiteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - function parseBirthDate(value: unknown): { year: number; month: number; day: number } | null { const date = text(value); const match = date ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) : null; diff --git a/frontend/src/lib/rectification-adopted-chart-tiers.ts b/frontend/src/lib/rectification-adopted-chart-tiers.ts index e401b0f4..ee1bff17 100644 --- a/frontend/src/lib/rectification-adopted-chart-tiers.ts +++ b/frontend/src/lib/rectification-adopted-chart-tiers.ts @@ -1,4 +1,5 @@ import { parseSegmentSummary, TARGET_CHARTS, type SegmentSummary, type TargetChart } from "./rectification-agentic/core/segment-summary.ts"; +import { asRecord as record } from "@/lib/guards"; /** * Which divisional charts a segment-v1 adoption could not determine (BUG-1138/1139). @@ -28,10 +29,6 @@ export type AdoptedChartTierWarn = (payload: Readonly<{ event: "adopted_chart_ti const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -function record(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; -} - /** Only the segment-v1 adoption contract carries a result whose summary describes the saved minute. */ export function segmentProvenance(value: unknown): SegmentProvenance | null { const row = record(value); diff --git a/frontend/src/lib/rectification-agentic/core/compose-receipt.ts b/frontend/src/lib/rectification-agentic/core/compose-receipt.ts index d3ad0c9f..101c4c42 100644 --- a/frontend/src/lib/rectification-agentic/core/compose-receipt.ts +++ b/frontend/src/lib/rectification-agentic/core/compose-receipt.ts @@ -2,6 +2,7 @@ import { candidateSetId } from "./candidate-set-id.ts"; import { readCandidatePosition, parseWindowIntervals, type CandidatePosition } from "./candidate-window.ts"; import { INFERENCE_ALGORITHM_VERSION, type InferenceState } from "./types.ts"; import { buildSegmentSummary, parseAscendantSignature, parseSegmentSummary, TARGET_CHARTS, type SegmentMinute } from "./segment-summary.ts"; +import { isRecord } from "@/lib/guards"; export type InferenceTransitionSnapshot = Readonly<{ id?: string; @@ -39,10 +40,6 @@ const ANSWER_SOURCES = new Set(["choice", "evidence", "declined"]); const ROUND_KINDS = new Set(["informative", "low_information", "tie_break"]); const SCORE_DIRECTIONS = new Set(["support", "weak_support", "neutral", "weak_conflict", "conflict"]); -function isRecord(value: unknown): value is Readonly> { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isClockRange(value: unknown, dated = false): value is readonly [string, string] { return Array.isArray(value) && value.length === 2 diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index f2356da0..8f273b1a 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -78,7 +78,8 @@ import { type PersistChoiceActionInput, type V9CaseDossier, } from "./tool-service"; -import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, isTieBreakRoundSchema, type ChoiceKey } from "./choice-card"; +import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, isTieBreakRoundSchema } from "./choice-card"; +import type { ChoiceKey } from "./choice-card"; import { vargaStyleFollowupRenderable } from "./probe-question-contract"; import { clusterScoreDeltas, excludedClusterRanges } from "./probe-explain.ts"; import { diff --git a/frontend/src/lib/rectification-agentic/v9/block-scan.ts b/frontend/src/lib/rectification-agentic/v9/block-scan.ts index 1078f88e..4b036f37 100644 --- a/frontend/src/lib/rectification-agentic/v9/block-scan.ts +++ b/frontend/src/lib/rectification-agentic/v9/block-scan.ts @@ -17,10 +17,10 @@ import { CHOICE_MODE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, - type ChoiceKey, type RectificationChoiceFrame, type RectificationChoiceOption, } from "./choice-card.ts"; +import type { ChoiceKey } from "./choice-card.ts"; import { MAX_SUB_BLOCK_ROUNDS, SUB_BLOCK_LABELS, diff --git a/frontend/src/lib/rectification-agentic/v9/case-status.ts b/frontend/src/lib/rectification-agentic/v9/case-status.ts index af502bb9..9ef6a7ce 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-status.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-status.ts @@ -6,39 +6,19 @@ * ordering. */ -export const RECTIFICATION_CASE_STATUSES = [ - "draft", - "collecting_evidence", - "candidate_ready", - "candidate_accepted", - "needs_rebaseline", - "paused", - "confirmed", - "closed", - "abandoned", - "superseded", -] as const; +import { + RECTIFICATION_CASE_STATUSES, + RESUMABLE_CASE_STATUSES, + TERMINAL_CASE_STATUSES, +} from "@/lib/contracts/rectification-entry"; +import type { RectificationCaseStatus } from "@/lib/contracts/rectification-entry"; -export type RectificationCaseStatus = - (typeof RECTIFICATION_CASE_STATUSES)[number]; - -/** Statuses that may be resumed by the user. */ -export const RESUMABLE_CASE_STATUSES: readonly RectificationCaseStatus[] = [ - "draft", - "collecting_evidence", - "candidate_ready", - "candidate_accepted", - "needs_rebaseline", - "paused", -]; - -/** Statuses that only allow read-only history access. */ -export const TERMINAL_CASE_STATUSES: readonly RectificationCaseStatus[] = [ - "confirmed", - "closed", - "abandoned", - "superseded", -]; +export { + RECTIFICATION_CASE_STATUSES, + RESUMABLE_CASE_STATUSES, + TERMINAL_CASE_STATUSES, +}; +export type { RectificationCaseStatus }; const RESUMABLE_SET = new Set(RESUMABLE_CASE_STATUSES); const TERMINAL_SET = new Set(TERMINAL_CASE_STATUSES); diff --git a/frontend/src/lib/rectification-agentic/v9/choice-action.ts b/frontend/src/lib/rectification-agentic/v9/choice-action.ts index dc6a0ad6..8c1b161a 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-action.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-action.ts @@ -14,9 +14,9 @@ import { CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, HOLDOUT_MESSAGE_PREFIX, - type ChoiceKey, type RectificationChoiceCard, } from "./choice-card"; +import type { ChoiceKey } from "./choice-card"; import { explainExcludedRanges, explainRangeChange, diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index 2eacfb85..30dc0ca1 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -18,6 +18,9 @@ import { type ProbeAnswerImpact, type ProbeExplainCandidate, } from "./probe-explain.ts"; +import type { ChoiceKey } from "./probe-explain.ts"; + +export type { ChoiceKey }; export type ChoiceCardKind = EventProbeChoiceKind | "block_choice" | "widen_window"; @@ -53,8 +56,6 @@ export function isTieBreakRoundSchema(value: unknown): boolean { ); } -export type ChoiceKey = "A" | "B" | "C" | "D"; - export type RectificationChoiceOption = Readonly<{ key: ChoiceKey; label: string; diff --git a/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts b/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts index 5149f8d6..1d4b62c7 100644 --- a/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts +++ b/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts @@ -12,6 +12,7 @@ import type { WindowInterval } from "../core/candidate-window.ts"; import { RECTIFICATION_POLICY } from "../../rectification-policy.ts"; import { RECTIFICATION_TERMINATION_COPY } from "../core/rectification-decision.ts"; import { indistinguishableWidthMinutes } from "./candidate-plateau.ts"; +import { asRecord } from "@/lib/guards"; /** * The six fields of `references/rectification_sealed_holdout.v1.json` the gate * reads. That JSON stays the one source: `rectification-confirmation-gate.test.ts` @@ -182,12 +183,6 @@ export function sessionOutcomeFromGate(input: { return sessionOutcomeView("collect_evidence"); } -function asRecord(value: unknown): Readonly> | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Readonly> - : null; -} - export function readVedastroMinuteSensitiveStatus( decisionReceipt: Readonly> | null | undefined, ): string { diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index cd2812d0..96d11146 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -29,6 +29,7 @@ import type { AscendantSignature } from "../core/segment-summary.ts"; import { isDatedScoringAlgorithmVersion, requestCandidateIntervals, readCandidatePosition, type CandidatePosition, type DatedCandidateRange } from "../core/candidate-window.ts"; import { parseBlockScanPayload, type BlockScanBlock } from "./block-scan.ts"; import { recordEngineCallTiming, reportTurnProgress } from "./turn-instrumentation.ts"; +import { asRecord as record } from "@/lib/guards"; export type EngineCallFailureKind = "busy" | "http_error" | "timeout" | "bad_payload"; @@ -210,12 +211,6 @@ const EXECUTION_LEDGER_VERSION = "rectification-execution-ledger-v2"; const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -function record(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : null; -} - function engineNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index f9162e1a..b0baf3d0 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -23,13 +23,14 @@ import type { TransitionSignLookup } from "../core/sign-from-transitions.ts"; import { datedPrecision } from "./evidence-model.ts"; import { isHoldoutVerificationQuote, - type ChoiceKey, } from "./choice-card.ts"; +import type { ChoiceKey } from "./choice-card.ts"; import { parseNakshatraBoundary, type DiscriminatingEventProbe, type NakshatraBoundary, } from "./refinement-packet.ts"; +import { asRecord } from "@/lib/guards"; function yearFrom(value: string | null | undefined): number | null { if (!value || value.length < 4 || !/^\d{4}/.test(value)) return null; @@ -470,12 +471,6 @@ export function buildCaseInferenceState(input: { }); } -function asRecord(value: unknown): Readonly> | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Readonly> - : null; -} - function asText(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } diff --git a/frontend/src/lib/rectification-agentic/v9/message-origin.ts b/frontend/src/lib/rectification-agentic/v9/message-origin.ts index 345b95c0..3de278cf 100644 --- a/frontend/src/lib/rectification-agentic/v9/message-origin.ts +++ b/frontend/src/lib/rectification-agentic/v9/message-origin.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { sha256Hex } from "@/lib/hash"; export const RECTIFICATION_MESSAGE_ORIGINS = [ "typed", @@ -24,5 +24,5 @@ export function defaultMessageOrigin(action: string): RectificationMessageOrigin } export function messageContentHash(text: string | null | undefined): string { - return createHash("sha256").update(text ?? "").digest("hex"); + return sha256Hex(text ?? ""); } diff --git a/frontend/src/lib/rectification-agentic/v9/open-request.ts b/frontend/src/lib/rectification-agentic/v9/open-request.ts index f73e19ec..d984e59d 100644 --- a/frontend/src/lib/rectification-agentic/v9/open-request.ts +++ b/frontend/src/lib/rectification-agentic/v9/open-request.ts @@ -10,8 +10,18 @@ import { z } from "zod"; import { isRectificationCaseStatus, - type RectificationCaseStatus, } from "./case-status"; +import type { + OpenRectificationCaseResponse, + OpenRectificationDisposition, + RectificationEntrySummary, +} from "@/lib/contracts/rectification-entry"; + +export type { + OpenRectificationCaseResponse, + OpenRectificationDisposition, + RectificationEntrySummary, +}; export const OPEN_RECTIFICATION_INTENTS = [ "homepage", @@ -57,35 +67,7 @@ export const openRectificationDispositions = [ "created", "resumed", "readonly", -] as const; - -export type OpenRectificationDisposition = - (typeof openRectificationDispositions)[number]; - -export type OpenRectificationCaseResponse = Readonly<{ - disposition: OpenRectificationDisposition; - caseId: string; - sessionId: string; - status: RectificationCaseStatus; - shouldStartOpening: boolean; - skillVersion: string; -}>; - -/** Entry-summary contract used by the homepage card. */ -export type RectificationEntrySummary = Readonly<{ - hasResumableCase: boolean; - hasTerminalCaseWithTime: boolean; - latestResumable: Readonly<{ - caseId: string; - status: RectificationCaseStatus; - lastActivityAt: string; - }> | null; - latestTerminal: Readonly<{ - caseId: string; - status: RectificationCaseStatus; - hasUsableTime: boolean; - }> | null; -}>; +] as const satisfies readonly OpenRectificationDisposition[]; export function parseOpenRectificationCaseRequest( value: unknown, diff --git a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts index 27c2939f..88d1626a 100644 --- a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts +++ b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts @@ -11,6 +11,7 @@ import { parseCandidateIntervals } from "../core/candidate-window.ts"; import { hasDatedTransition, parseDatedTransition, validDatedTransitions, type DatedTransitionPosition } from "../core/sign-from-transitions.ts"; import { engineMeaningToDisplayCopy } from "../user-copy.ts"; import { questionContractVersionIsCompatible } from "./probe-question-contract.ts"; +import { asRecord } from "@/lib/guards"; const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -356,12 +357,6 @@ export type CandidateContrastOpportunity = Readonly<{ phase: "candidate_discriminator"; }>; -function asRecord(value: unknown): Readonly> | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Readonly> - : null; -} - function asTime(value: unknown): string | null { if (typeof value !== "string") return null; const normalized = value.slice(0, 5); diff --git a/frontend/src/lib/rectification-agentic/v9/segment-contract.ts b/frontend/src/lib/rectification-agentic/v9/segment-contract.ts index 3eef4bf6..9a813b11 100644 --- a/frontend/src/lib/rectification-agentic/v9/segment-contract.ts +++ b/frontend/src/lib/rectification-agentic/v9/segment-contract.ts @@ -1,8 +1,5 @@ import { parseAscendantSignature, type SegmentMinute, type AscendantSignature } from "../core/segment-summary.ts"; - -function record(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; -} +import { asRecord as record } from "@/lib/guards"; export function segmentCandidateFields(row: Record | null): { signature?: AscendantSignature; rawScore?: number } { const signature = parseAscendantSignature(row?.signature); diff --git a/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts b/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts index 3e84bf0e..9148dafe 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts @@ -3,7 +3,8 @@ import { z } from "zod"; import type { ResolvedLanguageModel } from "@/mastra/model"; import type { AnswerClass } from "../core/types"; -import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card"; +import { parseAgentChoiceCopy } from "./choice-card"; +import type { ChoiceKey } from "./choice-card"; import { isCollectFocusSchema } from "./server-focus"; import type { ConversationFocus } from "./tool-service"; diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts index 0cb2d182..be4974c6 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts @@ -1,7 +1,8 @@ import { evidenceRecapLine } from "./host-fallback.ts"; import { displayDateLabel, eventPhraseFromSummary } from "./evidence-model.ts"; import { stripQuestionSentences } from "./collect-prompt"; -import { parseAgentChoiceCopy, type ChoiceKey, type RectificationChoiceCard } from "./choice-card"; +import { parseAgentChoiceCopy, type RectificationChoiceCard } from "./choice-card"; +import type { ChoiceKey } from "./choice-card"; import type { ConversationFocus } from "./tool-service"; export type TurnQuestionKind = "choice" | "collect_spoken" | "reverse_verify"; diff --git a/frontend/src/lib/rectification-agentic/v9/varga-observations.ts b/frontend/src/lib/rectification-agentic/v9/varga-observations.ts index a2aa5027..1f9ab644 100644 --- a/frontend/src/lib/rectification-agentic/v9/varga-observations.ts +++ b/frontend/src/lib/rectification-agentic/v9/varga-observations.ts @@ -10,6 +10,7 @@ import { parseWindowScanTransitions, type WindowScanTransition, } from "./refinement-packet"; +import { asRecord } from "@/lib/guards"; export type WindowScan = Readonly<{ scanned: boolean; @@ -48,12 +49,6 @@ export type InternalVargaObservation = Readonly<{ ask_theme: "relationship_style" | "career_style" | "home_change" | "education_style" | "family_event" | "finance_change" | "health_pressure" | null; }>; -function asRecord(value: unknown): Readonly> | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Readonly> - : null; -} - function asCount(value: unknown): number | null { if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value; if (typeof value === "string" && value.trim()) { diff --git a/frontend/src/lib/rectification-agentic/v9/window-widen.ts b/frontend/src/lib/rectification-agentic/v9/window-widen.ts index 3848c2b4..81b84c3e 100644 --- a/frontend/src/lib/rectification-agentic/v9/window-widen.ts +++ b/frontend/src/lib/rectification-agentic/v9/window-widen.ts @@ -9,9 +9,9 @@ import { CHOICE_MODE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, - type ChoiceKey, type RectificationChoiceFrame, } from "./choice-card.ts"; +import type { ChoiceKey } from "./choice-card.ts"; import { WIDEN_WINDOW_KIND, WIDEN_WINDOW_INTENT } from "./block-scan.ts"; import { MAX_DECLARED_RADIUS_MINUTES, diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index 996b336c..08db6057 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -36,6 +36,7 @@ import { verificationMarkdownFromUnknown, type RangeDeliveryProjection, } from "./rectification-agentic/v9/divergence-panel.ts"; +import { finiteNumberOrNull as finiteNumber } from "@/lib/guards"; import { parseProductDelivery } from "./rectification-agentic/v9/product-delivery.ts"; @@ -134,10 +135,6 @@ function text(value: unknown): string | null { return typeof value === "string" ? value : null; } -function finiteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const deniedHouseTable = /lon|latitude|longitude|degree|score|fingerprint/i; diff --git a/frontend/src/lib/rectification-chat-choice-run.ts b/frontend/src/lib/rectification-chat-choice-run.ts index 59361cde..059de249 100644 --- a/frontend/src/lib/rectification-chat-choice-run.ts +++ b/frontend/src/lib/rectification-chat-choice-run.ts @@ -22,7 +22,8 @@ import { shouldContinueAfterStructuredChoice, type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; -import { isPersistedFocusId, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; +import { isPersistedFocusId, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; +import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card"; import { appendUnseenAssistantTurns, applySnapshotTurnsToMessages, diff --git a/frontend/src/lib/rectification-entry.ts b/frontend/src/lib/rectification-entry.ts index 8df0a855..885a8d42 100644 --- a/frontend/src/lib/rectification-entry.ts +++ b/frontend/src/lib/rectification-entry.ts @@ -5,54 +5,27 @@ * never guesses "resume vs create" from session lists or message counts. */ -export type RectificationEntrySummary = Readonly<{ - hasResumableCase: boolean; - hasTerminalCaseWithTime: boolean; - latestResumable: Readonly<{ - caseId: string; - status: string; - lastActivityAt: string; - }> | null; - latestTerminal: Readonly<{ - caseId: string; - status: string; - hasUsableTime: boolean; - }> | null; -}>; +import { + RESUMABLE_CASE_STATUSES, + TERMINAL_CASE_STATUSES, + type OpenRectificationCaseResponseWire, + type OpenRectificationDisposition, + type RectificationEntrySummaryWire, +} from "@/lib/contracts/rectification-entry"; -export type OpenRectificationDisposition = "created" | "resumed" | "readonly"; +export type { OpenRectificationDisposition }; +export type RectificationEntrySummary = RectificationEntrySummaryWire; +export type OpenRectificationCaseResponse = OpenRectificationCaseResponseWire; -export type OpenRectificationCaseResponse = Readonly<{ - disposition: OpenRectificationDisposition; - caseId: string; - sessionId: string; - status: string; - shouldStartOpening: boolean; - skillVersion: string; -}>; - -const RESUMABLE_STATUSES = new Set([ - "draft", - "collecting_evidence", - "candidate_ready", - "candidate_accepted", - "needs_rebaseline", - "paused", -]); - -const TERMINAL_STATUSES = new Set([ - "confirmed", - "closed", - "abandoned", - "superseded", -]); +const resumableStatuses = new Set(RESUMABLE_CASE_STATUSES); +const terminalStatuses = new Set(TERMINAL_CASE_STATUSES); export function isResumableRectificationStatus(status: string): boolean { - return RESUMABLE_STATUSES.has(status); + return resumableStatuses.has(status); } export function isTerminalRectificationStatus(status: string): boolean { - return TERMINAL_STATUSES.has(status); + return terminalStatuses.has(status); } /* `GET /api/rectification/cases/entry-summary` answers with the camelCase diff --git a/frontend/src/lib/reply-quality-capture.ts b/frontend/src/lib/reply-quality-capture.ts index 3b8114a1..c0ff90a6 100644 --- a/frontend/src/lib/reply-quality-capture.ts +++ b/frontend/src/lib/reply-quality-capture.ts @@ -1,4 +1,5 @@ import "server-only"; +import { asRecord as record } from "@/lib/guards"; import { resolveSessionLanguageModel } from "./model-catalog"; import { buildReplyQualitySnapshot } from "./reply-quality-snapshot"; @@ -15,10 +16,6 @@ export type ReplyQualityService = { rpc(name: string, args: Record): PromiseLike<{ data: unknown; error: { message?: string } | null }>; }; -function record(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; -} - export type ReplyQualityCaptureResult = "saved" | "skipped" | "failed"; /** diff --git a/frontend/src/lib/report-birth-binding.ts b/frontend/src/lib/report-birth-binding.ts index e4d29457..b59a8284 100644 --- a/frontend/src/lib/report-birth-binding.ts +++ b/frontend/src/lib/report-birth-binding.ts @@ -1,4 +1,5 @@ import { resolveServerOwnedChartBirth } from "./chart-birth-truth.ts"; +import { asRecord as record } from "@/lib/guards"; /** * Report ↔ birth-data binding for the report cover (TASK-birth-sky-followup-20260928, @@ -19,12 +20,6 @@ export const REPORT_BIRTH_FINGERPRINT_KEY = "report_birth_fingerprint"; const FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/; -function record(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : null; -} - /** Same algorithm and same input (the stored row) as the chart page's `profileFingerprint`. */ export function subjectBirthFingerprint(storedRow: unknown): string { return resolveServerOwnedChartBirth(storedRow).fingerprint; diff --git a/frontend/src/lib/report-evidence-bundle-v2.ts b/frontend/src/lib/report-evidence-bundle-v2.ts index 81375516..6bfe5d81 100644 --- a/frontend/src/lib/report-evidence-bundle-v2.ts +++ b/frontend/src/lib/report-evidence-bundle-v2.ts @@ -1,14 +1,9 @@ import { createHash } from "node:crypto"; import { z } from "zod"; import { reportFactTablesSchema } from "./report-fact-table-schema"; +import type { ClaimStatus } from "./personal-report-contract"; -export type ClaimStatus = - | "multi_system_consensus" - | "single_system_inference" - | "parameter_sensitive" - | "unclosed_divisional_chart" - | "user_history_verification_required" - | "blocked"; +export type { ClaimStatus }; export type EvidenceRefStatus = "verified" | "partial" | "blocked"; diff --git a/frontend/src/lib/report-fact-table-values.ts b/frontend/src/lib/report-fact-table-values.ts index 0064348b..aad086a1 100644 --- a/frontend/src/lib/report-fact-table-values.ts +++ b/frontend/src/lib/report-fact-table-values.ts @@ -1,7 +1,7 @@ import { findForbiddenContent } from "./personal-report-contract"; import { PLANET_ZH, SIGN_ORDER, SIGN_ZH, type PlanetName, type SignName } from "./chart-view-labels"; +import { recordOrEmpty as record } from "@/lib/guards"; -export const record = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; export const at = (value: unknown, path: string): unknown => path.split(".").reduce((next, key) => record(next)[key], value); export const entries = (value: unknown) => Object.entries(record(value)).filter(([key]) => /^[A-Za-z0-9_-]+$/.test(key)); export const list = (value: unknown): unknown[] => Array.isArray(value) ? value : []; diff --git a/frontend/src/lib/report-fact-tables.ts b/frontend/src/lib/report-fact-tables.ts index e1e45de6..0dd92df5 100644 --- a/frontend/src/lib/report-fact-tables.ts +++ b/frontend/src/lib/report-fact-tables.ts @@ -1,7 +1,8 @@ import { reportFactTablesSchema, type ReportFactTable, type ReportFactSubtable } from "./report-fact-table-schema"; import { SIGN_ORDER, SIGN_ZH } from "./chart-view-labels"; import { containsHan, type ReportLanguage } from "./report-language"; -import { at, date, decimal, entries, isBlocked, list, planet, planetEn, readable, record, sign, signEn, yesNo } from "./report-fact-table-values"; +import { recordOrEmpty as record } from "./guards"; +import { at, date, decimal, entries, isBlocked, list, planet, planetEn, readable, sign, signEn, yesNo } from "./report-fact-table-values"; const SOURCES = [ ["vimshottari", "Vimshottari 主运", "timing_and_predictive_systems.dasha", "dasha", ["主运", "起", "止", "年数", "当前"]], diff --git a/frontend/src/lib/report-public-projection.ts b/frontend/src/lib/report-public-projection.ts index 1b9a05d4..b7d6765b 100644 --- a/frontend/src/lib/report-public-projection.ts +++ b/frontend/src/lib/report-public-projection.ts @@ -9,6 +9,7 @@ */ import type { ReportLanguage } from "./report-language"; +import { isRecord } from "@/lib/guards"; export const REPORT_DOCUMENT_KINDS = [ "chat_export", @@ -995,6 +996,3 @@ function readRecord(value: unknown): Record | null { return isRecord(value) ? value : null; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/frontend/src/lib/skill-package-registry.ts b/frontend/src/lib/skill-package-registry.ts index 6039543c..1b8aeda3 100644 --- a/frontend/src/lib/skill-package-registry.ts +++ b/frontend/src/lib/skill-package-registry.ts @@ -20,6 +20,7 @@ import { win32, } from "node:path"; import { tmpdir } from "node:os"; +import { isRecord } from "@/lib/guards"; export type SkillPackageStatus = "active" | "deprecated" | "blocked"; @@ -84,10 +85,6 @@ function fail(message: string): never { throw new SkillPackageRegistryError(message); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function assertString( value: unknown, field: keyof SkillPackageIdentity, diff --git a/frontend/src/lib/subject-birth.ts b/frontend/src/lib/subject-birth.ts index c3073c30..4d4356d4 100644 --- a/frontend/src/lib/subject-birth.ts +++ b/frontend/src/lib/subject-birth.ts @@ -13,6 +13,7 @@ import { globalBirthProfileFromAccountRow, type AccountBirthRow, } from "./server-owned-birth-profile.ts"; +import { asRecord as recordOf } from "@/lib/guards"; export const consultationSubjectErrorCodes = [ "subject_not_found", @@ -73,12 +74,6 @@ export function subjectIdFromQuery(value: unknown): string { return trimmed; } -function recordOf(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : null; -} - function text(value: unknown): string { return typeof value === "string" ? value.trim().slice(0, 80) : ""; } diff --git a/frontend/src/lib/supabase/codes.ts b/frontend/src/lib/supabase/codes.ts index a0eb3cd9..9f2483e1 100644 --- a/frontend/src/lib/supabase/codes.ts +++ b/frontend/src/lib/supabase/codes.ts @@ -1,4 +1,5 @@ -import { createHash, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; +import { sha256Hex } from "../hash"; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @@ -7,7 +8,7 @@ export function normalizeRedeemCode(code: string) { } export function hashRedeemCode(code: string) { - return createHash("sha256").update(code, "utf8").digest("hex"); + return sha256Hex(code); } export function generateRedeemCode() { diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index e1cad12d..98008c05 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -3,6 +3,7 @@ import { consultationEvidenceCategoryValues, createConsultationPlan, type Consul import { consultationThemeValues, projectConsultationWorkflowRequest } from "../lib/consultation-workflow-request.ts"; import { AYANAMSA_VALUES, DEFAULT_AYANAMSA } from "../lib/ayanamsa.ts"; import { applyUnreliableVargasToWorkflowContext } from "../lib/consultation-birth-time-mode.ts"; +import { recordOrEmpty as record } from "@/lib/guards"; export const consultationInputSchema = z.object({ year: z.number().int().min(1900).max(2100), @@ -58,10 +59,6 @@ export const consultationWorkflowResponseSchema = z.object({ }).passthrough().optional(), }).passthrough(); -function record(value: unknown): JsonRecord { - return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {}; -} - function rectificationBoundaryFromGate(rectification: JsonRecord): string { const accuracy = String(rectification.effective_accuracy || ""); const lagnaSensitive = record(rectification.lagna_boundary).is_sensitive === true; diff --git a/frontend/tests/birth-time-dynamic-range-progression.test.ts b/frontend/tests/birth-time-dynamic-range-progression.test.ts index dff54f0f..31caee5c 100644 --- a/frontend/tests/birth-time-dynamic-range-progression.test.ts +++ b/frontend/tests/birth-time-dynamic-range-progression.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { completeDynamicScoreTransition } from "../src/lib/birth-time-dynamic-transitions.ts"; -import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts"; import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts"; -const narrowedLowCandidate: CandidateResult = { +const narrowedLowCandidate: LegacyCandidateResult = { resultId: "615499c9-f4da-4da0-a8bd-da26b2b8477f", confidence: "low", canApply: false, diff --git a/frontend/tests/birth-time-dynamic-scoring.test.ts b/frontend/tests/birth-time-dynamic-scoring.test.ts index 4957aaa6..8b907b74 100644 --- a/frontend/tests/birth-time-dynamic-scoring.test.ts +++ b/frontend/tests/birth-time-dynamic-scoring.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { completeDynamicScoreTransition, } from "../src/lib/birth-time-dynamic-transitions.ts"; -import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts"; import type { ServerChoiceEvidence } from "../src/lib/birth-time-dynamic-choice-internal.ts"; import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; import { @@ -15,7 +15,7 @@ import { import { memoryStore } from "./birth-time-journey-memory-store.ts"; import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts"; -const lowCandidate: CandidateResult = { +const lowCandidate: LegacyCandidateResult = { resultId: "11111111-1111-4111-8111-111111111111", confidence: "low", canApply: false, @@ -33,7 +33,7 @@ const lowCandidate: CandidateResult = { const actionId = "ab2d936b-5ce7-45d8-a0fb-33f48f960f36"; function freshDynamicCase( - candidateResult: CandidateResult | null = null, + candidateResult: LegacyCandidateResult | null = null, priorEvidence: readonly ServerChoiceEvidence[] = [], ) { const stored = dynamicCase(); @@ -67,8 +67,8 @@ function freshDynamicCase( } function scoringFlow(input: { - readonly candidate?: CandidateResult; - readonly initialCandidate?: CandidateResult | null; + readonly candidate?: LegacyCandidateResult; + readonly initialCandidate?: LegacyCandidateResult | null; readonly priorEvidence?: readonly ServerChoiceEvidence[]; readonly failOnce?: boolean; readonly emptyNarrowedRange?: boolean; diff --git a/frontend/tests/birth-time-dynamic-stop-policy.test.ts b/frontend/tests/birth-time-dynamic-stop-policy.test.ts index 9b9a4fd7..272923f5 100644 --- a/frontend/tests/birth-time-dynamic-stop-policy.test.ts +++ b/frontend/tests/birth-time-dynamic-stop-policy.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { decideDynamicStop } from "../src/lib/birth-time-dynamic-stop-policy.ts"; -import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts"; -const lowCandidate: CandidateResult = { +const lowCandidate: LegacyCandidateResult = { resultId: "11111111-1111-4111-8111-111111111111", confidence: "low", canApply: false, @@ -18,7 +18,7 @@ const lowCandidate: CandidateResult = { algorithmVersion: "birth-time-choice-scoring-v2", }; -const mediumCandidate: CandidateResult = { +const mediumCandidate: LegacyCandidateResult = { ...lowCandidate, resultId: "22222222-2222-4222-8222-222222222222", confidence: "medium", diff --git a/frontend/tests/birth-time-guide-agent.test.ts b/frontend/tests/birth-time-guide-agent.test.ts index d9905fa2..11b9e229 100644 --- a/frontend/tests/birth-time-guide-agent.test.ts +++ b/frontend/tests/birth-time-guide-agent.test.ts @@ -8,7 +8,7 @@ import { parseGuideQuestionOutput, renderQuestionVariant, } from "../src/lib/birth-time-guide-agent.ts"; -import { evidenceDomains, type QuestionSpec } from "../src/lib/birth-time-question-planner.ts"; +import { legacyGuidedEvidenceDomains, type QuestionSpec } from "../src/lib/birth-time-question-planner.ts"; import { draftEvidenceStructureTool, getBirthTimeGuideAgent } from "../src/mastra/index.ts"; import type { ResolvedLanguageModel } from "../src/mastra/model.ts"; @@ -174,14 +174,14 @@ test("server renders every approved variant from its QuestionSpec domain and pre finance: /收入|资产|负债|资源渠道/, health_pressure: /健康压力|生活压力/, } as const; - for (const domain of evidenceDomains) { + for (const domain of legacyGuidedEvidenceDomains) { for (const variant of guideQuestionVariants) { const copy = renderQuestionVariant(question(domain), variant); assert.equal((copy.match(/[??]/g) ?? []).length, 1); assert.ok(copy.length <= 120); assert.match(copy, semanticMarkers[domain]); assert.doesNotMatch(copy, /哪一天|几号|具体日期|候选|支持|更符合|更接近|出生分钟/); - for (const otherDomain of evidenceDomains.filter((item) => item !== domain)) { + for (const otherDomain of legacyGuidedEvidenceDomains.filter((item) => item !== domain)) { assert.doesNotMatch(copy, semanticMarkers[otherDomain]); } } diff --git a/frontend/tests/birth-time-journey-score-transition.test.ts b/frontend/tests/birth-time-journey-score-transition.test.ts index 5baba6e3..8a42a7f9 100644 --- a/frontend/tests/birth-time-journey-score-transition.test.ts +++ b/frontend/tests/birth-time-journey-score-transition.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { candidateResultSchema } from "../src/lib/birth-time-journey.ts"; import { completeScoreTransition } from "../src/lib/birth-time-journey-score-transition.ts"; -import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts"; import type { LegacyStoredRectificationCase as StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts"; import { existingCareerEvent, @@ -13,7 +13,7 @@ import { const scoreJobId = "c70ea014-f8b4-41f2-9305-e4ae60c0d4d1"; function candidate( - confidence: CandidateResult["confidence"], + confidence: LegacyCandidateResult["confidence"], resultId: string, ) { const winningSegment = confidence === "high" diff --git a/frontend/tests/birth-time-journey-test-support.ts b/frontend/tests/birth-time-journey-test-support.ts index 44a99327..3c2ad629 100644 --- a/frontend/tests/birth-time-journey-test-support.ts +++ b/frontend/tests/birth-time-journey-test-support.ts @@ -5,7 +5,7 @@ import type { LegacyBirthTimeJourneyEngine, LegacyStoredRectificationCase, } from "../src/lib/birth-time-journey-service.ts"; -import type { EvidenceDomain } from "../src/lib/birth-time-question-planner.ts"; +import type { LegacyGuidedEvidenceDomain } from "../src/lib/birth-time-question-planner.ts"; import { journeyCaseId, memoryStore, @@ -83,7 +83,7 @@ export function preserveLegacyResumeStore(store: BirthTimeJourneyStore): BirthTi export function evidenceQuestion( phase: "baseline" | "adaptive", - domain: EvidenceDomain, + domain: LegacyGuidedEvidenceDomain, round: number, ) { return { @@ -129,9 +129,9 @@ export const lowCandidate = candidateResultSchema.parse({ type GuidedCaseInput = { readonly version?: number; readonly phase?: "baseline" | "adaptive"; - readonly domain?: EvidenceDomain; + readonly domain?: LegacyGuidedEvidenceDomain; readonly adaptiveRound?: number; - readonly askedDomains?: readonly EvidenceDomain[]; + readonly askedDomains?: readonly LegacyGuidedEvidenceDomain[]; readonly lifeEvents?: readonly ReturnType[]; readonly candidateResult?: ReturnType | null; }; diff --git a/frontend/tests/birth-time-journey-turn.test.ts b/frontend/tests/birth-time-journey-turn.test.ts index e9e4f0a8..400ccaec 100644 --- a/frontend/tests/birth-time-journey-turn.test.ts +++ b/frontend/tests/birth-time-journey-turn.test.ts @@ -5,7 +5,7 @@ import { deriveNextAction, projectJourneyTurn, } from "../src/lib/birth-time-journey-turn.ts"; -import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts"; import type { QuestionSpec } from "../src/lib/birth-time-question-planner.ts"; function question(domain: QuestionSpec["domain"]): QuestionSpec { @@ -20,7 +20,7 @@ function question(domain: QuestionSpec["domain"]): QuestionSpec { }; } -const lowResult: CandidateResult = { +const lowResult: LegacyCandidateResult = { resultId: "1d8ee348-61a3-433d-8907-ff6d281b9992", confidence: "low", canApply: false, diff --git a/frontend/tests/birth-time-scoring-memory-store.ts b/frontend/tests/birth-time-scoring-memory-store.ts index bf144c97..5b40fe6f 100644 --- a/frontend/tests/birth-time-scoring-memory-store.ts +++ b/frontend/tests/birth-time-scoring-memory-store.ts @@ -3,7 +3,7 @@ import { scoringJobDurationMs, scoringProcessingLeaseMs, } from "../src/lib/birth-time-scoring-job.ts"; -import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts"; import type { BirthTimeJourneyStore, StoredRectificationCase, @@ -19,7 +19,7 @@ type MemoryJob = { readonly expiresAt: string; readonly updatedAt: string; readonly status: "pending" | "processing" | "completed" | "failed"; - readonly result: CandidateResult | null; + readonly result: LegacyCandidateResult | null; }; type MemoryContext = { diff --git a/frontend/tests/birth-time-scoring-test-support.ts b/frontend/tests/birth-time-scoring-test-support.ts index 6677ae1e..26539470 100644 --- a/frontend/tests/birth-time-scoring-test-support.ts +++ b/frontend/tests/birth-time-scoring-test-support.ts @@ -1,4 +1,4 @@ -import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts"; import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; import type { BirthTimeJourneyStore } from "../src/lib/birth-time-journey-service.ts"; import { @@ -15,7 +15,7 @@ import { export const scoringTestUserId = "user-1"; export async function pendingScoringFlow(input: { - readonly score: () => Promise; + readonly score: () => Promise; readonly now?: () => Date; readonly transformStore?: (store: BirthTimeJourneyStore) => BirthTimeJourneyStore; }) { diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index 39d2999e..45c7a8fc 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -360,7 +360,10 @@ test("validates and emits non-sensitive workflow and execution receipts", () => test("consultationWorkflowResponseSchema still parses without western_evidence_packet", () => { const schema = workflow.slice( workflow.indexOf("export const consultationWorkflowResponseSchema"), - workflow.indexOf("function record("), + // 原值: function record( + // 新值: function rectificationBoundaryFromGate( + // 原因: 非数组否则空对象的 record 已改为 import recordOrEmpty,切片终点改到紧随其后的既有函数。 + workflow.indexOf("function rectificationBoundaryFromGate("), ); assert.match(schema, /success: z\.boolean\(\)/); assert.match(schema, /chart: z\.record\(z\.unknown\(\)\)/); diff --git a/frontend/tests/guards-and-hash.test.ts b/frontend/tests/guards-and-hash.test.ts new file mode 100644 index 00000000..9029e2cc --- /dev/null +++ b/frontend/tests/guards-and-hash.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import type { PersonalReportCreateRequestInput } from "../src/lib/contracts/personal-report-request.ts"; +import { asRecord, finiteNumberOrNull, isRecord, recordOrEmpty } from "../src/lib/guards.ts"; +import { sha256Hex } from "../src/lib/hash.ts"; +import { hashLongformMarkdown } from "../src/lib/personal-report-longform-appendix.ts"; +import { hashRedeemCode } from "../src/lib/supabase/codes.ts"; + +const samples = [null, undefined, 0, "", [], {}, new Date()] as const; + +function legacyIsRecord(value: unknown): boolean { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function legacyIsRecordBoolean(value: unknown): boolean { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function legacyAsRecordTruthy(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function legacyAsRecordNull(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function legacyRecordOrEmptyTruthy(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function legacyRecordOrEmptyNull(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function legacyFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +test("guards match the replaced non-array helpers on the shared sample table", () => { + for (const sample of samples) { + assert.equal(isRecord(sample), legacyIsRecord(sample)); + assert.equal(isRecord(sample), legacyIsRecordBoolean(sample)); + assert.equal(asRecord(sample), legacyAsRecordTruthy(sample)); + assert.equal(asRecord(sample), legacyAsRecordNull(sample)); + const emptyTruthy = legacyRecordOrEmptyTruthy(sample); + const emptyNull = legacyRecordOrEmptyNull(sample); + const empty = recordOrEmpty(sample); + if (isRecord(sample)) { + assert.equal(empty, sample); + assert.equal(emptyTruthy, sample); + assert.equal(emptyNull, sample); + } else { + assert.deepEqual(empty, {}); + assert.deepEqual(emptyTruthy, {}); + assert.deepEqual(emptyNull, {}); + } + assert.equal(finiteNumberOrNull(sample), legacyFiniteNumber(sample)); + } + assert.equal(finiteNumberOrNull(0), 0); + assert.equal(isRecord([]), false); + assert.equal(asRecord([]), null); + assert.deepEqual(recordOrEmpty([]), {}); +}); + +test("sha256 helpers match createHash for ascii, chinese, and emoji", () => { + for (const sample of ["ascii", "中文", "😀"]) { + const direct = createHash("sha256").update(sample).digest("hex"); + assert.equal(sha256Hex(sample), direct); + assert.equal(hashLongformMarkdown(sample), direct); + assert.equal(hashRedeemCode(sample), direct); + } +}); + +test("contracts and guards do not import server modules", () => { + const forbidden = /node:|server-only|@\/mastra|@\/lib\/supabase|@\/lib\/db/; + const root = fileURLToPath(new URL("../src/lib/", import.meta.url)); + const files = [ + join(root, "guards.ts"), + ...readdirSync(join(root, "contracts")).map((name) => join(root, "contracts", name)), + ]; + for (const file of files) { + assert.equal(forbidden.test(readFileSync(file, "utf8")), false, file); + } +}); + +const okReportThemes: PersonalReportCreateRequestInput["themes"] = ["career"]; +void okReportThemes; + +// @ts-expect-error bogus is not a report request theme +const bogusReportThemes: PersonalReportCreateRequestInput["themes"] = ["bogus"]; +void bogusReportThemes; diff --git a/frontend/tests/rectification-adaptive-selector.test.ts b/frontend/tests/rectification-adaptive-selector.test.ts index f2215257..b2a814ab 100644 --- a/frontend/tests/rectification-adaptive-selector.test.ts +++ b/frontend/tests/rectification-adaptive-selector.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { parseRectificationScoring } from "../src/lib/birth-time-journey-adapters.ts"; import { projectJourneyTurn } from "../src/lib/birth-time-journey-turn.ts"; -import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { LegacyCandidateResult } from "../src/lib/birth-time-evidence.ts"; const snapshot = { state: "rectifying", @@ -15,7 +15,7 @@ const snapshot = { reportedRange: { label: "10:00—11:00", startTime: "10:00", endTime: "11:00" }, } as const; -const lowResult: CandidateResult = { +const lowResult: LegacyCandidateResult = { resultId: "1d8ee348-61a3-433d-8907-ff6d281b9992", confidence: "low", canApply: false,