把前后端共用的请求契约和小工具收成一份,避免再各抄一份。

报告创建请求、校正入口和同名类型改从 contracts 推导;同义的对象判断与 sha256 改走共享函数。调用处用 import 别名保留原名,使 diff 低于 1500 行。行为、哈希和状态字符串不变。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-15 22:22:44 +08:00
co-authored by Grok (agent loop)
parent cd7e0a3493
commit d2296c2654
92 changed files with 575 additions and 461 deletions
@@ -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。
@@ -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<Record<EvidenceDomain, string>>;
} as const satisfies Readonly<Record<LegacyGuidedEvidenceDomain, string>>;
type DraftCardProps = {
readonly draft: EvidenceDraft;
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readReportId(json: unknown): string | null {
if (!isRecord(json) || !isRecord(json.report)) {
return null;
@@ -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<string, unknown> {
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();
@@ -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;
@@ -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,
@@ -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
+1 -4
View File
@@ -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<string, unknown> {
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);
@@ -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<Record<string, string>> = {
export const TELEMETRY_PERCENT_SHOWN_BUCKETS: readonly string[] = ["5-9", "10+"];
function record(value: unknown): Readonly<Record<string, unknown>> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
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);
+1 -6
View File
@@ -1,3 +1,4 @@
import { asRecord as record } from "@/lib/guards";
type RecordValue = Record<string, unknown>;
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];
}
@@ -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;
@@ -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 {
@@ -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;
@@ -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;
@@ -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,
+5 -5
View File
@@ -174,11 +174,11 @@ export const candidateResultSchema = z.object({
}
});
export type CandidateResult = z.infer<typeof candidateResultSchema>;
export type LegacyCandidateResult = z.infer<typeof candidateResultSchema>;
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 (
+4 -4
View File
@@ -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<typeof guideQuestionResponseSchema>;
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<Record<EvidenceDomain, string>>;
} as const satisfies Readonly<Record<LegacyGuidedEvidenceDomain, string>>;
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 {
@@ -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<typeof candidateResultApiSchema>): CandidateResult {
function adaptCandidateResult(parsed: z.infer<typeof candidateResultApiSchema>): LegacyCandidateResult {
return candidateResultSchema.parse({
resultId: parsed.result_id,
confidence: parsed.confidence,
@@ -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(),
@@ -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({
@@ -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<DifferencePacketInput, "birthDate" |
export interface BirthTimeJourneyEngine {
scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>;
score(input: JourneyScoreInput): Promise<RectificationScoringResult>;
scoreEvents(input: JourneyEventScoreInput): Promise<CandidateResult>;
scoreEvents(input: JourneyEventScoreInput): Promise<LegacyCandidateResult>;
buildDifferencePacket(input: DifferencePacketInput): Promise<CandidateDifferenceBuild>;
scoreChoices(input: DynamicChoiceScoreInput): Promise<DynamicChoiceScoringResult>;
}
@@ -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<Record<string, RectificationAnswer>>;
readonly lifeEvents: readonly LifeEvent[];
readonly candidateResult: CandidateResult | null;
readonly candidateResult: LegacyCandidateResult | null;
};
export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState
@@ -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 = {
@@ -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<QuestionSpec, "questionId" | "phase" | "domain">;
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(
@@ -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(
+8 -8
View File
@@ -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<Record<string, EvidenceDomain>> = {
const serverDomainMap: Readonly<Record<string, LegacyGuidedEvidenceDomain>> = {
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
@@ -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;
}
@@ -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) {
+1 -6
View File
@@ -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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
+1 -6
View File
@@ -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<string, unknown>;
};
function recordOf(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown, max: number): string {
return typeof value === "string" ? value.trim().slice(0, max) : "";
}
+1 -6
View File
@@ -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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
@@ -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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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);
@@ -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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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);
@@ -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()) {
+1 -6
View File
@@ -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);
@@ -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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function pickText(row: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const value = textOrNull(row[key]);
@@ -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<typeof personalReportCreateRequestSchema>;
/** Client payload. Defaulted fields stay optional so callers can omit them. */
export type PersonalReportCreateRequestInput = z.input<typeof personalReportCreateRequestSchema>;
@@ -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<OpenRectificationCaseResponseWire, "status"> & {
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<LatestResumableWire, "status"> & {
readonly status: RectificationCaseStatus;
}) | null;
latestTerminal: (Omit<LatestTerminalWire, "status"> & {
readonly status: RectificationCaseStatus;
}) | null;
}>;
+1 -4
View File
@@ -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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function asArray(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
+1 -6
View File
@@ -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<EphemerisPanchangaFactor["key"], string> = {
karana: "迦那",
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
+1 -6
View File
@@ -1,3 +1,4 @@
import { asRecord as record } from "@/lib/guards";
type RecordValue = Record<string, unknown>;
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;
}
+20
View File
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
export function recordOrEmpty(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
export function finiteNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
+9
View File
@@ -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");
}
+3 -2
View File
@@ -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;
+4 -16
View File
@@ -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<string, unknown>;
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;
}
@@ -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 {
@@ -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();
+2 -2
View File
@@ -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",
+6 -24
View File
@@ -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<typeof personalReportCreateRequestSchema>;
export { personalReportCreateRequestSchema, type PersonalReportCreateRequest };
export type ReportRouteResponse = Readonly<{
status: number;
@@ -81,22 +73,12 @@ export type ReportServicePort = Pick<
type JsonRecord = Record<string, unknown>;
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";
+1 -11
View File
@@ -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<string, unknown>;
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;
@@ -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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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);
@@ -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<Record<string, unknown>> {
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
@@ -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 {
@@ -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,
@@ -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<string>(RESUMABLE_CASE_STATUSES);
const TERMINAL_SET = new Set<string>(TERMINAL_CASE_STATUSES);
@@ -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,
@@ -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;
@@ -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<Record<string, unknown>> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Readonly<Record<string, unknown>>
: null;
}
export function readVedastroMinuteSensitiveStatus(
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined,
): string {
@@ -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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function engineNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
@@ -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<Record<string, unknown>> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Readonly<Record<string, unknown>>
: null;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
@@ -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 ?? "");
}
@@ -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,
@@ -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<Record<string, unknown>> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Readonly<Record<string, unknown>>
: null;
}
function asTime(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.slice(0, 5);
@@ -1,8 +1,5 @@
import { parseAscendantSignature, type SegmentMinute, type AscendantSignature } from "../core/segment-summary.ts";
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
import { asRecord as record } from "@/lib/guards";
export function segmentCandidateFields(row: Record<string, unknown> | null): { signature?: AscendantSignature; rawScore?: number } {
const signature = parseAscendantSignature(row?.signature);
@@ -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";
@@ -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";
@@ -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<Record<string, unknown>> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Readonly<Record<string, unknown>>
: null;
}
function asCount(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
if (typeof value === "string" && value.trim()) {
@@ -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,
@@ -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;
@@ -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,
+14 -41
View File
@@ -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<string>(RESUMABLE_CASE_STATUSES);
const terminalStatuses = new Set<string>(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
+1 -4
View File
@@ -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<string, unknown>): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
};
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export type ReplyQualityCaptureResult = "saved" | "skipped" | "failed";
/**
+1 -6
View File
@@ -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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: 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;
@@ -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";
+1 -1
View File
@@ -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<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
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 : [];
+2 -1
View File
@@ -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", ["主运", "起", "止", "年数", "当前"]],
+1 -3
View File
@@ -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<string, unknown> | null {
return isRecord(value) ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+1 -4
View File
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertString(
value: unknown,
field: keyof SkillPackageIdentity,
+1 -6
View File
@@ -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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string {
return typeof value === "string" ? value.trim().slice(0, 80) : "";
}
+3 -2
View File
@@ -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() {
+1 -4
View File
@@ -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;
@@ -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,
@@ -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;
@@ -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",
@@ -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]);
}
}
@@ -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"
@@ -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<typeof lifeEventSchema.parse>[];
readonly candidateResult?: ReturnType<typeof candidateResultSchema.parse> | null;
};
@@ -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,
@@ -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 = {
@@ -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<CandidateResult>;
readonly score: () => Promise<LegacyCandidateResult>;
readonly now?: () => Date;
readonly transformStore?: (store: BirthTimeJourneyStore) => BirthTimeJourneyStore;
}) {
@@ -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\(\)\)/);
+103
View File
@@ -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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function legacyAsRecordNull(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function legacyRecordOrEmptyTruthy(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function legacyRecordOrEmptyNull(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
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;
@@ -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,