Merge branch 'codex/birth-time-journey'

# Conflicts:
#	frontend/src/app/globals.css
This commit is contained in:
Jesse_Chen
2026-07-17 16:34:31 +08:00
24 changed files with 2949 additions and 41 deletions
@@ -0,0 +1,115 @@
# Birth Time Journey Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a deterministic first-use birth-time journey that separates reported and active times, routes uncertain data into free rectification, and connects the web UI to the existing candidate scanner.
**Architecture:** A pure TypeScript state machine owns route and application decisions. An authenticated Next.js route adapts Supabase persistence and the existing Python scan/score API to that state machine. A focused React component renders the input contract, while `page.tsx` only coordinates the established onboarding shell.
**Tech Stack:** Next.js 16 App Router, React 19, TypeScript, Zod, Supabase/PostgreSQL, Node test runner, Python Jyotish API.
## Global Constraints
- Agent copy may guide the user but may not determine route, confidence, or application eligibility.
- `reported_birth_time` is immutable historical input; `birth_time` mirrors only `active_birth_time` for compatibility.
- Rectification intake and questions never call the consultation billing endpoint.
- Questionnaire scoring cannot apply an exact minute because the current engine only ranks coarse clusters.
- Scanner failure must fail closed into rectification.
- Do not modify or import files from `.workbuddy` mirrors.
---
### Task 1: Deterministic Journey Domain
**Files:**
- Create: `frontend/src/lib/birth-time-journey.ts`
- Test: `frontend/tests/birth-time-journey.test.ts`
**Interfaces:**
- Produces: `assessBirthTime(input: BirthTimeAssessmentInput, scan?: CandidateScan): JourneySnapshot`
- Produces: `scoreJourneyAnswers(snapshot: JourneySnapshot, scoring: RectificationScoring): JourneySnapshot`
- Produces: source, period, status, route, input, snapshot, scan, and scoring types used by later tasks.
- [ ] Write table-driven failing tests for all five sources, invalid source-specific input, stable hospital scan, sensitive hospital scan, scanner failure, and `canApply=false` after questionnaire scoring.
- [ ] Run `npm test -- --test-name-pattern='birth time journey'` and confirm the module is missing.
- [ ] Implement exhaustive source routing and scan stability comparison without persistence or prose generation.
- [ ] Run the focused test and confirm every route and gate passes.
### Task 2: Birth-Time Persistence Contract
**Files:**
- Create: `frontend/supabase/migrations/20260717020000_birth_time_journey.sql`
- Create: `tests/test_birth_time_journey_contract.py`
**Interfaces:**
- Produces: profile columns and `public.birth_time_rectification_cases` expected by the route.
- [ ] Write a failing SQL contract test for columns, checks, backfill, foreign key, RLS policies, and column-level grants.
- [ ] Run `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py` and confirm the migration is missing.
- [ ] Add an idempotent migration that backfills old `birth_time` values, constrains enums and uncertainty ranges, creates the cases table, and grants only owner-scoped operations.
- [ ] Run the SQL contract test and the existing Supabase contract tests.
### Task 3: Authenticated Journey Service and Route
**Files:**
- Create: `frontend/src/lib/birth-time-journey-service.ts`
- Create: `frontend/src/app/api/birth-time-journey/route.ts`
- Test: `frontend/tests/birth-time-journey-service.test.ts`
**Interfaces:**
- Consumes: domain types and `assessBirthTime`/`scoreJourneyAnswers` from Task 1.
- Produces: `POST /api/birth-time-journey` events `assess` and `answer_question`.
- [ ] Write failing service tests with fake persistence and scanner ports for stable assessment, scanner failure, and answer accumulation.
- [ ] Implement a typed service port so tests never require live Supabase or Python.
- [ ] Implement the route's Zod boundary, authenticated profile read, free scanner calls, case persistence, and sanitized JSON response.
- [ ] Run focused service/domain tests and lint.
### Task 4: First-Use Birth Intake UI
**Files:**
- Create: `frontend/src/components/birth-time-intake.tsx`
- Create: `frontend/src/components/birth-time-rectification.tsx`
- Modify: `frontend/src/app/page.tsx`
- Modify: `frontend/src/app/globals.css`
- Test: `frontend/tests/birth-time-intake.test.ts`
**Interfaces:**
- Consumes: the journey source/status types and `JourneySnapshot`.
- Produces: source-specific profile draft updates, assessment requests after location, and answer events.
- [ ] Write failing tests for source-specific required fields, summary labels, and payload construction.
- [ ] Implement the source cards, conditional fields, accessible labels, and uncertainty/period copy.
- [ ] Implement the rectification status/question card with progress and explicit non-application language.
- [ ] Replace the old exact-time-only fields in `page.tsx`, extend profile parsing/persistence, add the `rectification` onboarding step, and block consultation until an active time exists.
- [ ] Add scoped responsive styles and run the focused UI helper tests plus lint.
### Task 5: Compatibility and End-to-End Verification
**Files:**
- Modify: `frontend/src/app/api/onboarding/route.ts`
- Modify: `frontend/src/mastra/index.ts`
- Modify: `tests/test_frontend_productization.py`
**Interfaces:**
- Consumes: active time and birth-time status persisted by earlier tasks.
- Produces: existing onboarding and consultation behavior with deterministic entry mode.
- [ ] Update onboarding completeness to require an active/confirmed time while accepting backfilled legacy profiles.
- [ ] Add `entryMode` to the consultation input and pass the deterministic value to the Python workflow instead of hard-coding `direct_chart`.
- [ ] Add regression assertions that the web path exposes five time-confidence choices, keeps rectification free, and contains no client-controlled application gate.
- [ ] Run frontend tests, relevant Python tests, lint, and `npm run build`.
- [ ] Start Next.js from the worktree and manually verify the first-use UI, source-dependent fields, rectification card, `/api/birth-time-journey` authentication behavior, and absence of consultation credit requests.
### Task 6: Review and Commit
**Files:**
- Review every path changed by Tasks 1-5.
**Interfaces:**
- Produces: a review-clean commit on `codex/birth-time-journey`.
- [ ] Run the TypeScript no-excuse checks and measure pure LOC for every changed source file.
- [ ] Review boundary parsing, exhaustive variants, RLS, billing isolation, and legacy compatibility.
- [ ] Re-run the full frontend test/lint/build gate and relevant Python contract tests on the final diff.
- [ ] Commit the implementation with a focused message and record the worktree path and commit SHA.
@@ -0,0 +1,150 @@
# Birth Time Journey Design
## Goal
Turn the first-use birth-time question into one continuous journey: the assistant explains and guides, while deterministic code owns state transitions, time-quality assessment, candidate scanning, routing, and whether a time may become the active chart time.
## Delivery Scope
This delivery connects the web onboarding flow to the repository's existing candidate-time scanner. It includes:
- five explicit birth-time knowledge levels from the first time question;
- separate reported and active birth times;
- deterministic direct-chart versus rectification routing;
- a free rectification intake path that does not use `/api/consult` billing;
- candidate scanning with latitude, longitude, timezone, Lahiri ayanamsa, and the local domain engine;
- high-information choice questions and persisted answers;
- a hard application gate that refuses unsupported minute-level certainty.
The existing Python scorer only ranks coarse candidate clusters. It does not prove an exact minute against dated life events. Therefore this delivery may save rectification evidence and candidate ranges, but it must keep `can_apply=false` for scored questionnaire results. A hospital-record time may become active only when the deterministic ±2-minute sensitivity scan is stable.
## Responsibilities
### BirthTimeJourney
`advanceBirthTimeJourney(event, context) -> JourneySnapshot` is the only module allowed to choose the next state or route.
It owns:
- validation of source-specific inputs;
- uncertainty ranges;
- state transitions;
- stable-scan interpretation;
- `direct_chart`, `rectification`, or `pending` routing;
- `can_apply` decisions.
It does not generate prose, call a language model, persist data, or calculate a chart.
### Journey API
`POST /api/birth-time-journey` authenticates the user, parses the event, loads the user's profile/case, calls the deterministic module, calls the existing Python scan/score endpoints when required, and persists the returned snapshot.
The route is free. It must never reserve consultation credits.
### Birth Intake UI
The UI renders the input contract returned by the journey and uses fixed, user-facing Chinese copy for `assistant_intent`. It never chooses a route from chat text.
The existing `onboardingAgent` remains responsible only for the welcome message and starter questions after birth intake is complete. The normal `jyotishAgent` remains unavailable until an active birth time exists.
## State Model
States are:
- `collect_date`
- `collect_time_confidence`
- `collect_reported_time`
- `collect_location`
- `assessing`
- `rectifying`
- `candidate`
- `ready`
Routes are `pending`, `direct_chart`, and `rectification`.
Birth-time sources are:
- `hospital_record`
- `family_exact`
- `approximate`
- `period_only`
- `unknown`
Source rules:
| Source | Required input | Deterministic uncertainty | Route |
| --- | --- | --- | --- |
| Hospital record | exact time | ±2 minutes | stable scan → direct; sensitive/error → rectification |
| Family exact | exact time and 5/10/15-minute uncertainty | selected range | rectification |
| Approximate | center time and 15/30/60-minute uncertainty | selected range | rectification |
| Period only | morning/forenoon/afternoon/evening/late night | predefined range | rectification, no exact-time application |
| Unknown | optional family clue | whole-day unresolved | rectification, no exact-time application |
## Persistence
`profiles.birth_time` remains as a compatibility mirror of `active_birth_time` for existing calculation code.
New profile fields:
- `reported_birth_time`
- `active_birth_time`
- `birth_time_source`
- `birth_time_period`
- `uncertainty_before_minutes`
- `uncertainty_after_minutes`
- `birth_time_status`
- `rectification_confidence`
- `rectification_case_id`
`birth_time_rectification_cases` stores the questionnaire, answers, candidate scan, scoring result, algorithm settings, status, and confirmation metadata. Row-level security restricts every operation to the owning user. Raw reported time is never overwritten when active time changes.
Existing profiles with `birth_time` are backfilled as reported and active times with `birth_time_status='confirmed'` and `birth_time_source='legacy_import'`, so existing users are not forced through onboarding again.
## API Events
The first version accepts two events:
- `assess`: evaluate the stored birth-time declaration after location is known;
- `answer_question`: add or replace one A/B/C/D answer and recompute deterministic cluster scoring.
The response is a `JourneySnapshot` containing:
- `state`
- `assistantIntent`
- `input`
- `route`
- `confidence`
- `canApply`
- `reportedRange`
- `questionnaire`
- `scoring`
Unknown fields, invalid choices, missing authentication, and missing profile inputs are rejected at the HTTP boundary. Scanner failure safely routes to rectification and never silently activates the reported time.
## UI Flow
1. Ask the user's name.
2. Ask the birth date and show the five time-confidence choices.
3. Reveal only the time, uncertainty, period, or clue fields required by that choice.
4. Ask for birth location.
5. Show an assessment status card while the deterministic route runs.
6. If stable hospital data is accepted, continue to the existing starter questions.
7. Otherwise show the first three rectification questions, progress, current range, and the explicit note that no exact minute has been applied.
The account sheet uses the same birth-time fields, so later edits preserve the same contract.
## Error Handling
- Scanner unavailable: persist `rectifying`, show a retry-safe explanation, keep `can_apply=false`.
- Invalid source-specific input: keep the current collection state and show a field-level message.
- Persistence failure: return an error and do not advance the visible journey.
- Score endpoint failure: keep prior answers and prior snapshot; do not fabricate a result.
- Old profile: use the migration backfill and compatibility read path.
## Verification
- Unit tests cover every source route, stable/sensitive hospital scans, and the application gate.
- Route/service tests cover scanner payloads, failure fallback, and answer accumulation.
- SQL contract tests cover constraints, RLS, grants, and immutable reported-time semantics.
- Existing frontend, lint, TypeScript, build, and relevant Python rectification tests remain green.
- Manual QA runs the new first-use journey in the real Next.js page and observes both direct and rectification presentations without charging credits.
+9
View File
@@ -93,6 +93,15 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- **States:** default, hover, focus with deep-brown ring, disabled, invalid, loading.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
### Birth time intake
- **Structure:** birth date, five radio choice rows for time knowledge, then only the time, uncertainty, period, or clue field required by the selected source.
- **Surface:** choice rows use the warm canvas and hairline system; the selected row uses `--color-action-soft` with a deep-brown border, never a dark promotional card.
- **States:** no source selected, source selected, source-specific details incomplete, ready to continue, assessing, rectifying, candidate saved, confirmed.
- **Copy:** labels describe what the user actually knows. Candidate results explicitly distinguish a reported time, a candidate range, and an active chart time.
- **Accessibility:** native radio inputs remain focusable, every conditional field has a persistent label, status text uses live regions, and the complete flow is keyboard operable.
- **Motion:** source-dependent fields enter with the existing 180ms opacity/vertical reveal; reduced-motion removes the translation.
### Model selector
- **Structure:** a compact text trigger sits below the composer and opens an upward popover aligned to its left edge. The trigger shows only the active model name; each option shows only its model name and radio selection state.
@@ -0,0 +1,140 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { parseBirthTimeProfile } from "@/lib/birth-time-journey-adapters";
import {
createJyotishBirthTimeJourneyEngine,
BirthTimeJourneyEngineError,
} from "@/lib/birth-time-journey-engine";
import {
createBirthTimeJourneyService,
RectificationCaseNotFoundError,
RectificationQuestionsUnavailableError,
} from "@/lib/birth-time-journey-service";
import {
createSupabaseBirthTimeJourneyStore,
BirthTimeJourneyStoreError,
} from "@/lib/birth-time-journey-store";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const maxDuration = 60;
const eventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("assess") }).strict(),
z.object({ type: z.literal("resume"), caseId: z.string().uuid() }).strict(),
z.object({
type: z.literal("answer_question"),
caseId: z.string().uuid(),
questionId: z.string().trim().min(1).max(120),
answer: z.enum(["A", "B", "C", "D"]),
}).strict(),
]);
async function requestPayload(request: Request): Promise<unknown> {
try {
return await request.json();
} catch (error) {
if (error instanceof SyntaxError) return null;
throw error;
}
}
export async function POST(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let journeyStoreClient: ReturnType<typeof createAdminSupabaseClient>;
try {
supabase = await createServerSupabaseClient();
journeyStoreClient = createAdminSupabaseClient();
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
{ status: 503 },
);
}
throw error;
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能继续出生时间评估。" },
{ status: 401 },
);
}
const parsed = eventSchema.safeParse(await requestPayload(request));
if (!parsed.success) {
return NextResponse.json(
{ error: "生时评估请求格式不正确", details: parsed.error.flatten() },
{ status: 400 },
);
}
const service = createBirthTimeJourneyService({
store: createSupabaseBirthTimeJourneyStore(journeyStoreClient),
engine: createJyotishBirthTimeJourneyEngine(),
});
try {
switch (parsed.data.type) {
case "assess": {
const { data: profile, error } = await supabase
.from("profiles")
.select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset")
.eq("id", user.id)
.maybeSingle();
if (error) throw new BirthTimeJourneyStoreError("load_case");
if (!profile) {
return NextResponse.json(
{ error: "出生资料尚未完成", message: "请先填写出生日期、时间情况和地点。" },
{ status: 409 },
);
}
const assessment = parseBirthTimeProfile(profile);
return NextResponse.json(await service.assess(user.id, assessment));
}
case "answer_question":
return NextResponse.json(await service.answerQuestion(
user.id,
parsed.data.caseId,
parsed.data.questionId,
parsed.data.answer,
));
case "resume":
return NextResponse.json(await service.resume(user.id, parsed.data.caseId));
default: {
const exhaustive: never = parsed.data;
return exhaustive;
}
}
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "出生资料尚未完成", message: "请检查出生时间情况和地点后重试。" },
{ status: 409 },
);
}
if (error instanceof RectificationCaseNotFoundError) {
return NextResponse.json(
{ error: "校正记录不存在", message: "请重新开始出生时间评估。" },
{ status: 404 },
);
}
if (error instanceof RectificationQuestionsUnavailableError) {
return NextResponse.json(
{ error: "校正问题暂不可用", message: "当前资料已安全保留,请稍后重新评估。" },
{ status: 409 },
);
}
if (error instanceof BirthTimeJourneyStoreError || error instanceof BirthTimeJourneyEngineError) {
return NextResponse.json(
{ error: "生时评估暂时不可用", message: "已保留当前资料,请稍后重试。" },
{ status: 503 },
);
}
throw error;
}
}
+3 -2
View File
@@ -44,7 +44,8 @@ function hasCompleteBirthProfile(profile: Record<string, unknown>) {
return Boolean(
profile.name
&& profile.birth_date
&& profile.birth_time
&& (profile.active_birth_time || profile.birth_time)
&& (profile.birth_time_status === "confirmed" || (!profile.birth_time_status && profile.birth_time))
&& profile.country_code
&& profile.province_code
&& profile.city_code,
@@ -74,7 +75,7 @@ export async function POST() {
const { data: profile, error: profileError } = await admin
.from("profiles")
.select("name,birth_date,birth_time,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
.select("name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
.eq("id", user.id)
.maybeSingle();
+39
View File
@@ -295,6 +295,43 @@ button:disabled { cursor: default; opacity: .45; }
.onboarding-card { border: 1px solid var(--color-border); max-width: 680px; margin: var(--space-2) 0 var(--space-5); padding: var(--space-6); border-color: var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); }
.onboarding-card-heading b { font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
.onboarding-card-heading small { color: var(--color-ink-secondary); line-height: 1.5; font-size: var(--type-caption); }
.birth-time-intake { display: grid; gap: var(--space-4); }
.birth-time-source-fieldset { margin: 0; padding: 0; border: 0; }
.birth-time-source-fieldset legend { margin-bottom: var(--space-2); color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; }
.birth-time-source-list { display: grid; gap: var(--space-2); }
.birth-time-source-option { min-height: 64px; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
.birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); }
.birth-time-source-option input { width: 16px; height: 16px; margin: 0; accent-color: var(--color-action); }
.birth-time-source-option > span { display: grid; gap: 3px; }
.birth-time-source-option b { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; }
.birth-time-source-option small, .birth-time-detail-note, .birth-time-legacy-note { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; }
.birth-time-detail-grid { display: grid; grid-template-columns: minmax(0, 180px) minmax(0, 1fr); align-items: end; gap: var(--space-3); }
.birth-time-detail-grid > label { min-width: 0; }
.birth-time-detail-note { padding-bottom: 12px; }
.birth-time-legacy-note { padding: var(--space-3); border-left: 2px solid var(--color-warning); background: var(--color-canvas-muted); }
.birth-time-rectification { max-width: 680px; display: grid; gap: var(--space-5); }
.birth-time-assessment-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.birth-time-assessment-heading > div { display: grid; gap: var(--space-1); }
.birth-time-assessment-heading span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: .08em; }
.birth-time-assessment-heading h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; }
.birth-time-status-badge { min-height: 30px; display: inline-flex; align-items: center; padding: 0 var(--space-3); border: 1px solid color-mix(in srgb, var(--color-warning) 46%, var(--color-border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas)); color: var(--color-ink-secondary) !important; font-size: 11px !important; letter-spacing: .04em !important; }
.birth-time-range-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); }
.birth-time-range-summary > div { display: grid; gap: var(--space-1); padding: var(--space-3) var(--space-4); background: var(--color-canvas); }
.birth-time-range-summary dt { color: var(--color-ink-tertiary); font-size: 11px; }
.birth-time-range-summary dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); font-variant-numeric: tabular-nums; }
.birth-time-assistant-intent, .birth-time-assessment-unavailable { margin: 0; padding: var(--space-4); border-left: 2px solid var(--color-action); background: var(--color-action-soft); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.6; }
.birth-time-question-list { display: grid; gap: var(--space-4); }
.birth-time-question-progress { display: flex; justify-content: space-between; color: var(--color-ink-secondary); font-size: var(--type-caption); }
.birth-time-question { display: grid; gap: var(--space-3); margin: 0; padding: var(--space-4) 0 0; border: 0; border-top: 1px solid var(--color-border); }
.birth-time-question legend { display: flex; align-items: flex-start; gap: var(--space-2); color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; line-height: 1.55; }
.birth-time-question legend span { width: 24px; height: 24px; display: inline-grid; flex: 0 0 24px; place-items: center; border: 1px solid var(--color-border-strong); border-radius: var(--radius-xs); color: var(--color-ink-secondary); font-size: 11px; }
.birth-time-answer-list { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); }
.birth-time-answer-list button { min-height: 52px; display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; text-align: left; font-size: var(--type-caption); line-height: 1.4; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
.birth-time-answer-list button > span { color: var(--color-action); font-family: var(--font-mono); font-weight: 600; }
.birth-time-answer-list button.is-selected { border-color: var(--color-action); background: var(--color-action-soft); }
.birth-time-question > small { color: var(--color-ink-secondary); font-size: 11px; }
.birth-time-retry-card { display: grid; justify-items: start; gap: var(--space-3); }
.birth-time-retry-card p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; }
.starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: 1.08fr .92fr; grid-template-rows: 1fr 1fr; gap: var(--space-3); border: 0; }
.starter-list button { width: 100%; display: grid; align-items: center; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 92px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 0; border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
@@ -448,6 +485,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.composer-suggestions button:not(:disabled):hover { border-color: var(--color-action); background: var(--color-canvas); color: var(--color-action-hover); }
.button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); }
.danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); }
.birth-time-source-option:hover, .birth-time-answer-list button:not(:disabled):hover { border-color: var(--color-action); }
}
@media (min-width: 768px) and (max-width: 900px) {
@@ -488,6 +526,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
}
@media (max-width: 480px) {
.birth-time-detail-grid, .birth-time-range-summary, .birth-time-answer-list { grid-template-columns: 1fr; }
.welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-title-lg); }
.starter-content span { font-size: var(--type-title-sm); }
.account-modal h2 { font-size: var(--type-title-lg); }
+242 -37
View File
@@ -4,12 +4,30 @@ import Link from "next/link";
import { ArrowUp, ArrowUpRight, ChevronRight, Gift, KeyRound, LogOut, Menu, Plus, Sparkles, Square, UserRound, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { BirthTimeRectification } from "@/components/birth-time-rectification";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ModelSelector } from "@/components/model-selector";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply";
import {
assistantIntentCopy,
birthTimePersistenceValues,
describeBirthTimeDraft,
isBirthTimeDraftReady,
type BirthTimeDraft,
type BirthTimeSource,
} from "@/lib/birth-time-intake-model";
import {
answerBirthTimeQuestion,
parseJourneyResponse,
requestBirthTimeAssessment,
resumeBirthTimeJourney,
type JourneyAnswer,
type JourneyClientResponse,
} from "@/lib/birth-time-journey-client";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
SessionModelPersistenceQueue,
@@ -24,14 +42,13 @@ import { createBrowserSupabaseClient } from "@/lib/supabase/client";
type Theme = ReplyTheme;
type Message = { role: "user" | "assistant"; text: string; suggestions?: string[] };
type Profile = {
type Profile = BirthTimeDraft & {
name: string;
date: string;
time: string;
countryCode: "CN";
provinceCode: string;
cityCode: string;
districtCode: string;
rectificationCaseId: string;
};
type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number };
type RequestError = { sessionId: string; message: string };
@@ -40,7 +57,7 @@ type BirthPlace = { label: string; lat: number; lon: number; tz: number };
type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean };
type OnboardingSuggestion = { theme: Exclude<Theme, "general">; text: string };
type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] };
type OnboardingStep = "name" | "birth" | "place";
type OnboardingStep = "name" | "birth" | "place" | "rectification";
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
type AccountDialog = "profile" | "redeem" | "logout";
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
@@ -86,6 +103,30 @@ const previewModelCatalog = parsePublicModelCatalog({
],
});
const previewRectificationJourney = parseJourneyResponse({
caseId: "7299894c-10a8-4b45-91d1-339007282c50",
snapshot: {
state: "rectifying",
assistantIntent: "start_standard_rectification",
input: "rectification_questions",
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange: { label: "14:00—15:00", startTime: "14:00", endTime: "15:00" },
},
questionnaire: {
questions: [
{ id: "education_shift", prompt: "求学阶段是否发生过一次明显的环境或方向变化?" },
{ id: "career_shift", prompt: "工作早期是否经历过一次清晰的行业、岗位或城市切换?" },
{ id: "relationship_milestone", prompt: "重要关系或婚姻节点是否集中在某个明确年份?" },
],
samples: [],
raw: {},
},
scoring: null,
});
const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?";
const greetingVariants: Record<GreetingPeriod, Array<(name: string) => string>> = {
@@ -134,6 +175,14 @@ const emptyProfile: Profile = {
name: "",
date: "",
time: "",
reportedTime: "",
birthTimeSource: "",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "",
rectificationCaseId: "",
countryCode: "CN",
provinceCode: "",
cityCode: "",
@@ -181,18 +230,18 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null {
function missingProfileStep(profile: Profile): OnboardingStep | null {
if (!profile.name.trim()) return "name";
if (!profile.date || !profile.time) return "birth";
if (!isBirthTimeDraftReady(profile)) return "birth";
if (!selectedBirthPlace(profile)) return "place";
if (!profile.time || profile.birthTimeStatus !== "confirmed") return "rectification";
return null;
}
function birthQuestion(name: string) {
return `${name},你好。接下来请告诉我你的出生日期和时间。时间越准确,后面的判断越可靠。`;
return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`;
}
function formatBirthMoment(profile: Profile) {
const [year, month, day] = profile.date.split("-").map(Number);
return `${year}年${month}月${day}日 ${profile.time}`;
return describeBirthTimeDraft(profile);
}
function placeQuestion(profile: Profile) {
@@ -206,7 +255,7 @@ function completedOnboardingMessage(name: string) {
function completedOnboardingTranscript(profile: Profile, greeting: string): Message[] {
const name = profile.name.trim();
const birthPlace = selectedBirthPlace(profile);
if (!name || !profile.date || !profile.time || !birthPlace) return [];
if (!name || !profile.date || !profile.time || profile.birthTimeStatus !== "confirmed" || !birthPlace) return [];
return [
{ role: "assistant", text: presetOnboardingMessage },
@@ -251,13 +300,38 @@ function readProfile(value: unknown): Profile {
const profile = value as Partial<Profile> & {
birth_date?: unknown;
birth_time?: unknown;
reported_birth_time?: unknown;
active_birth_time?: unknown;
birth_time_source?: unknown;
birth_time_period?: unknown;
birth_time_clue?: unknown;
uncertainty_before_minutes?: unknown;
uncertainty_after_minutes?: unknown;
birth_time_status?: unknown;
rectification_case_id?: unknown;
country_code?: unknown;
province_code?: unknown;
city_code?: unknown;
district_code?: unknown;
};
const date = typeof profile.birth_date === "string" ? profile.birth_date : profile.date;
const time = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time;
const legacyTime = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time;
const time = typeof profile.active_birth_time === "string"
? profile.active_birth_time.slice(0, 5)
: legacyTime;
const reportedTime = typeof profile.reported_birth_time === "string"
? profile.reported_birth_time.slice(0, 5)
: time;
const knownSources: readonly BirthTimeSource[] = [
"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import",
];
const source = knownSources.find((item) => item === profile.birth_time_source)
?? (time ? "legacy_import" : "");
const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? "";
const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "confirmed"] as const;
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (time ? "confirmed" : "");
const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode;
const cityCode = typeof profile.city_code === "string" ? profile.city_code : profile.cityCode;
const districtCode = typeof profile.district_code === "string" ? profile.district_code : profile.districtCode;
@@ -266,6 +340,14 @@ function readProfile(value: unknown): Profile {
name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "",
date: typeof date === "string" ? date : "",
time: typeof time === "string" ? time : "",
reportedTime: typeof reportedTime === "string" ? reportedTime : "",
birthTimeSource: source,
birthTimePeriod: period,
birthTimeClue: typeof profile.birth_time_clue === "string" ? profile.birth_time_clue.slice(0, 240) : "",
uncertaintyBeforeMinutes: typeof profile.uncertainty_before_minutes === "number" ? profile.uncertainty_before_minutes : null,
uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null,
birthTimeStatus: status,
rectificationCaseId: typeof profile.rectification_case_id === "string" ? profile.rectification_case_id : "",
countryCode: "CN",
provinceCode: typeof provinceCode === "string" ? provinceCode : "",
cityCode: typeof cityCode === "string" ? cityCode : "",
@@ -315,15 +397,6 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
return { sessions, fallbackSessionIds };
}
function BirthMomentFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
return (
<div className="profile-grid">
<label><span>出生日期</span><input required type="date" value={value.date} onChange={(event) => onChange({ ...value, date: event.target.value })} /></label>
<label><span>出生时间</span><input required type="time" value={value.time} onChange={(event) => onChange({ ...value, time: event.target.value })} /></label>
</div>
);
}
function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
const province = findProvince(value.provinceCode);
const cities = province?.cities ?? [];
@@ -350,7 +423,7 @@ function ProfileFields({ value, onChange }: { value: Profile; onChange: (profile
<span>如何称呼你</span>
<input id="profile-name" required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
</label>
<BirthMomentFields value={value} onChange={onChange} />
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange({ ...value, ...patch })} />
<BirthLocationFields value={value} onChange={onChange} />
</>
);
@@ -470,6 +543,10 @@ export default function Home() {
const [onboardingError, setOnboardingError] = useState("");
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>("name");
const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false);
const [birthTimeJourney, setBirthTimeJourney] = useState<JourneyClientResponse | null>(null);
const [birthTimeAnswers, setBirthTimeAnswers] = useState<Readonly<Record<string, JourneyAnswer>>>({});
const [birthTimeQuestionPending, setBirthTimeQuestionPending] = useState("");
const [birthTimeError, setBirthTimeError] = useState("");
const [startGreeting, setStartGreeting] = useState("");
const [presetMessageLength, setPresetMessageLength] = useState(0);
const conversationEnd = useRef<HTMLDivElement>(null);
@@ -515,6 +592,8 @@ export default function Home() {
? birthQuestion(profileDraft.name.trim())
: onboardingStep === "place"
? placeQuestion(profileDraft)
: onboardingStep === "rectification" && birthTimeJourney
? assistantIntentCopy(birthTimeJourney.snapshot.assistantIntent)
: presetOnboardingMessage;
const shouldStreamOnboarding = !profileComplete || onboardingJustCompleted;
const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length;
@@ -546,7 +625,15 @@ export default function Home() {
: {
name: "林遥",
date: "1990-06-15",
time: "12:30",
time: previewMode === "birth-time-rectification" ? "" : "12:30",
reportedTime: previewMode === "birth-time-rectification" ? "14:30" : "12:30",
birthTimeSource: previewMode === "birth-time-rectification" ? "approximate" : "legacy_import",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: previewMode === "birth-time-rectification" ? 30 : null,
uncertaintyAfterMinutes: previewMode === "birth-time-rectification" ? 30 : null,
birthTimeStatus: previewMode === "birth-time-rectification" ? "rectifying" : "confirmed",
rectificationCaseId: previewMode === "birth-time-rectification" ? previewRectificationJourney.caseId : "",
countryCode: "CN",
provinceCode: "110000",
cityCode: "110000-city",
@@ -570,6 +657,7 @@ export default function Home() {
setModelCatalog(previewModelCatalog);
setProfile(previewProfile);
setProfileDraft(previewProfile);
if (previewMode === "birth-time-rectification") setBirthTimeJourney(previewRectificationJourney);
setOnboardingStep(missingProfileStep(previewProfile) ?? "name");
setSessions([previewSession]);
setActiveSessionId(previewSession.id);
@@ -604,7 +692,7 @@ export default function Home() {
const [profileResult, sessionsResult] = await Promise.all([
supabase
.from("profiles")
.select("name,birth_date,birth_time,country_code,province_code,city_code,district_code")
.select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
.eq("id", nextAccount.user.id)
.abortSignal(controller.signal)
.maybeSingle(),
@@ -649,6 +737,20 @@ export default function Home() {
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
setSessions(nextSessions);
setActiveSessionId(nextSessions[0].id);
if ((nextProfile.birthTimeStatus === "rectifying" || nextProfile.birthTimeStatus === "candidate")
&& nextProfile.rectificationCaseId) {
try {
const resumed = await resumeBirthTimeJourney(nextProfile.rectificationCaseId);
if (!controller.signal.aborted) {
setBirthTimeJourney(resumed);
setBirthTimeAnswers(resumed.answers);
}
} catch (caught) {
if (!controller.signal.aborted) {
setBirthTimeError(caught instanceof Error ? caught.message : "暂时无法继续上次的时间校正。");
}
}
}
if (modelCatalogResult.unavailable) {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
} else if (parsedSessions.fallbackSessionIds.length > 0) {
@@ -976,7 +1078,7 @@ export default function Home() {
.update({
name: nextProfile.name.trim() || null,
birth_date: nextProfile.date || null,
birth_time: nextProfile.time || null,
...birthTimePersistenceValues(nextProfile),
country_code: nextProfile.countryCode,
province_code: nextProfile.provinceCode || null,
city_code: nextProfile.cityCode || null,
@@ -993,16 +1095,45 @@ export default function Home() {
if (!data) throw new Error("账户档案不存在,请重新登录后再试。");
}
async function assessSavedBirthTime(nextProfile: Profile) {
const result = process.env.NODE_ENV === "development" && uiPreview.current
? previewRectificationJourney
: await requestBirthTimeAssessment();
const nextStatus = result.snapshot.state === "ready"
? "confirmed"
: result.snapshot.state === "candidate"
? "candidate"
: "rectifying";
const assessedProfile: Profile = {
...nextProfile,
time: result.snapshot.activeTime ?? "",
birthTimeStatus: nextStatus,
rectificationCaseId: result.caseId,
};
setBirthTimeJourney(result);
setBirthTimeAnswers({});
setBirthTimeError("");
setProfile(assessedProfile);
setProfileDraft(assessedProfile);
return assessedProfile;
}
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!isProfileComplete(profileDraft) || !account || profileSaving) return;
if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return;
setProfileSaving(true);
setProfileNotice("");
setAccountError("");
try {
await persistProfile(profileDraft);
setProfile(profileDraft);
setProfileNotice("出生资料已保存到云端,可在同一账号的其他设备使用。");
const nextProfile = profileDraft.birthTimeStatus === "confirmed"
? profileDraft
: await assessSavedBirthTime(profileDraft);
setProfile(nextProfile);
setProfileDraft(nextProfile);
setProfileNotice(nextProfile.birthTimeStatus === "confirmed"
? "出生资料已保存到云端,可在同一账号的其他设备使用。"
: "资料已保存,当前时间仍在校正中,不会用于正式排盘。");
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败"));
} finally {
@@ -1035,7 +1166,7 @@ export default function Home() {
async function saveOnboardingBirth(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!profileDraft.date || !profileDraft.time || !account || profileSaving) return;
if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return;
setProfileSaving(true);
setAccountError("");
try {
@@ -1059,9 +1190,13 @@ export default function Home() {
setAccountError("");
try {
await persistProfile(profileDraft);
setProfile(profileDraft);
const assessedProfile = await assessSavedBirthTime(profileDraft);
setPresetMessageLength(0);
setOnboardingJustCompleted(true);
if (assessedProfile.birthTimeStatus === "confirmed") {
setOnboardingJustCompleted(true);
} else {
setOnboardingStep("rectification");
}
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
} finally {
@@ -1069,6 +1204,43 @@ export default function Home() {
}
}
async function saveBirthTimeAnswer(questionId: string, answer: JourneyAnswer) {
if (!birthTimeJourney || birthTimeQuestionPending) return;
setBirthTimeQuestionPending(questionId);
setBirthTimeError("");
try {
const result = process.env.NODE_ENV === "development" && uiPreview.current
? birthTimeJourney
: await answerBirthTimeQuestion(birthTimeJourney.caseId, questionId, answer);
setBirthTimeJourney(result);
setBirthTimeAnswers(process.env.NODE_ENV === "development" && uiPreview.current
? (current) => ({ ...current, [questionId]: answer })
: result.answers);
const birthTimeStatus = result.snapshot.state === "candidate" ? "candidate" : "rectifying";
setProfile((current) => ({ ...current, birthTimeStatus }));
setProfileDraft((current) => ({ ...current, birthTimeStatus }));
} catch (caught) {
setBirthTimeError(caught instanceof Error ? caught.message : "这条回答暂时无法保存,请重试。");
} finally {
setBirthTimeQuestionPending("");
}
}
async function retryBirthTimeAssessment() {
if (!account || profileSaving) return;
setProfileSaving(true);
setBirthTimeError("");
try {
const assessedProfile = await assessSavedBirthTime(profileDraft);
setPresetMessageLength(0);
if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(true);
} catch (caught) {
setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。");
} finally {
setProfileSaving(false);
}
}
async function redeem(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = redeemCode.trim();
@@ -1371,6 +1543,7 @@ export default function Home() {
lon: birthPlace.lon,
tz: birthPlace.tz,
theme,
entryMode: profile.birthTimeStatus === "confirmed" ? "direct_chart" : "rectification",
question,
history: currentSession.messages.slice(-12).map((message) => ({
role: message.role,
@@ -1601,7 +1774,11 @@ export default function Home() {
<button className="mobile-menu" ref={mobileMenuTrigger} aria-label="打开聊天记录" aria-controls="chat-sidebar" aria-expanded={mobileSidebarOpen} type="button" onClick={() => setMobileSidebarOpen(true)}><Menu aria-hidden="true" /></button>
<div>
<strong>{activeSession?.title || "新对话"}</strong>
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") : "基于星盘证据回答"}</span>
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading
? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息")
: !profileComplete && onboardingStep === "rectification"
? "正在校正出生时间"
: "基于星盘证据回答"}</span>
</div>
<button className="credit-button" ref={creditTrigger} type="button" onClick={() => openAccountDialog("redeem", creditTrigger.current)} aria-label={account ? `余额 ${account.credits} 点,兑换点数` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
@@ -1617,8 +1794,10 @@ export default function Home() {
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={profileDraft.name.trim()} />}
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
{(onboardingStep === "place" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
{(onboardingStep === "place" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
{onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
</>
@@ -1633,10 +1812,10 @@ export default function Home() {
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<form className="profile-form onboarding-card onboarding-step-card" onSubmit={saveOnboardingBirth}>
<div className="onboarding-card-heading"><b>出生时间</b><small>请选择日期和尽量准确的时间</small></div>
<BirthMomentFields value={profileDraft} onChange={setProfileDraft} />
<div className="onboarding-card-heading"><b>出生时间</b><small>按你实际知道的程度填写,不需要猜测</small></div>
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => ({ ...current, ...patch }))} />
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !profileDraft.date || !profileDraft.time}>{profileSaving ? "保存中" : "确定"}</button></div>
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button></div>
</form>
</div>
</div>
@@ -1655,6 +1834,28 @@ export default function Home() {
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<BirthTimeRectification
journey={birthTimeJourney}
answers={birthTimeAnswers}
pendingQuestionId={birthTimeQuestionPending}
error={birthTimeError}
onAnswer={(questionId, answer) => void saveBirthTimeAnswer(questionId, answer)}
/>
</div>
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
<div className="onboarding-card birth-time-retry-card" role="status">
<b>出生时间尚未完成评估</b>
<p>{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}</p>
<button className="button-primary" type="button" disabled={profileSaving} onClick={() => void retryBirthTimeAssessment()}>{profileSaving ? "评估中" : "重新评估"}</button>
</div>
)}
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
{profileComplete && presetMessageFinished && (onboardingPending ? (
@@ -1748,7 +1949,7 @@ export default function Home() {
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={!profileComplete ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<Button aria-label={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
@@ -1760,7 +1961,11 @@ export default function Home() {
disabled={!activeSession || isLoading || cancellationPending || creatingSession}
onSelect={(modelId) => void selectSessionModel(modelId)}
/>
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo" ? "已加入发送队列,2.5 秒内可免费撤回。" : !profileComplete && onboardingStep === "name" ? "Enter 确认称呼" : "Enter 发送 · Shift + Enter 换行")}</p>
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo"
? "已加入发送队列,2.5 秒内可免费撤回。"
: !profileComplete
? onboardingStep === "name" ? "Enter 确认称呼" : onboardingStep === "rectification" ? "完成上方生时校正后可提问" : "请先完成上方资料"
: "Enter 发送 · Shift + Enter 换行")}</p>
</div>
</div>
</section>
@@ -0,0 +1,174 @@
"use client";
import { useId } from "react";
import {
birthTimePeriodOptions,
birthTimeSourceOptions,
type BirthTimeDraft,
type BirthTimeDraftPatch,
type BirthTimeSource,
} from "@/lib/birth-time-intake-model";
type BirthTimeIntakeProps = {
readonly value: BirthTimeDraft;
readonly onPatch: (patch: BirthTimeDraftPatch) => void;
};
const sourceDefaults = {
hospital_record: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
},
family_exact: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
},
approximate: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
},
period_only: {
reportedTime: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
unknown: {
reportedTime: "",
birthTimePeriod: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
} as const satisfies Record<Exclude<BirthTimeSource, "" | "legacy_import">, BirthTimeDraftPatch>;
export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) {
const groupId = useId();
const source = value.birthTimeSource;
const isConfirmed = value.birthTimeStatus === "confirmed";
const usesClockTime = source === "hospital_record"
|| source === "family_exact"
|| source === "approximate"
|| source === "legacy_import";
return (
<div className="birth-time-intake">
<label>
<span>出生日期</span>
<input
required
disabled={isConfirmed}
type="date"
value={value.date}
onChange={(event) => onPatch({ date: event.target.value })}
/>
</label>
{source === "legacy_import" && (
<p className="birth-time-legacy-note">
这是账号里已有的确认时间。本阶段保留原始记录,不在资料表内直接覆盖。
</p>
)}
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
<legend>你知道的出生时间属于哪一种?</legend>
<div className="birth-time-source-list">
{birthTimeSourceOptions.map((option) => (
<label
className={`birth-time-source-option ${source === option.value ? "is-selected" : ""}`}
key={option.value}
>
<input
checked={source === option.value}
name={`birth-time-source-${groupId}`}
type="radio"
value={option.value}
onChange={() => onPatch({
birthTimeSource: option.value,
birthTimeStatus: "reported",
time: "",
...sourceDefaults[option.value],
})}
/>
<span>
<b>{option.label}</b>
<small>{option.hint}</small>
</span>
</label>
))}
</div>
</fieldset>}
{usesClockTime && (
<div className="birth-time-detail-grid onboarding-card-reveal">
<label>
<span>{isConfirmed ? "当前排盘时间" : source === "approximate" ? "大概时间" : "记录时间"}</span>
<input
required
disabled={isConfirmed}
type="time"
value={value.reportedTime || value.time}
onChange={(event) => onPatch({ reportedTime: event.target.value })}
/>
</label>
{source === "hospital_record" && (
<p className="birth-time-detail-note">系统会无感检查前后 2 分钟的上升与 D9 / D10 稳定性。</p>
)}
{(source === "family_exact" || source === "approximate") && (
<label>
<span>可能误差</span>
<select
value={value.uncertaintyBeforeMinutes ?? ""}
onChange={(event) => {
const minutes = Number(event.target.value);
onPatch({ uncertaintyBeforeMinutes: minutes, uncertaintyAfterMinutes: minutes });
}}
>
{(source === "family_exact" ? [5, 10, 15] : [15, 30, 60]).map((minutes) => (
<option key={minutes} value={minutes}>前后 {minutes} 分钟</option>
))}
</select>
</label>
)}
</div>
)}
{source === "period_only" && (
<label className="onboarding-card-reveal">
<span>大致时段</span>
<select
required
value={value.birthTimePeriod}
onChange={(event) => {
const period = birthTimePeriodOptions.find((option) => option.value === event.target.value);
if (period) onPatch({ birthTimePeriod: period.value });
}}
>
<option value="" disabled>请选择你知道的时段</option>
{birthTimePeriodOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
)}
{source === "unknown" && (
<label className="onboarding-card-reveal">
<span>目前知道的线索(可选)</span>
<textarea
maxLength={240}
placeholder="例如:家人只记得天黑以后,或可以再询问长辈"
rows={3}
value={value.birthTimeClue}
onChange={(event) => onPatch({ birthTimeClue: event.target.value })}
/>
</label>
)}
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
import type {
JourneyAnswer,
JourneyClientResponse,
} from "@/lib/birth-time-journey-client";
type BirthTimeRectificationProps = {
readonly journey: JourneyClientResponse;
readonly answers: Readonly<Record<string, JourneyAnswer>>;
readonly pendingQuestionId: string;
readonly error: string;
readonly onAnswer: (questionId: string, answer: JourneyAnswer) => void;
};
const fallbackOptions = [
{ key: "A", label: "明确有,而且时间大致吻合" },
{ key: "B", label: "有类似经历,但时间或程度不完全确定" },
{ key: "C", label: "没有明显发生" },
{ key: "D", label: "不确定 / 不记得" },
] as const;
export function BirthTimeRectification({
journey,
answers,
pendingQuestionId,
error,
onAnswer,
}: BirthTimeRectificationProps) {
const questions = journey.questionnaire?.questions.slice(0, 3) ?? [];
const answeredCount = Object.keys(answers).length;
return (
<section className="birth-time-rectification onboarding-card" aria-labelledby="birth-time-assessment-title">
<div className="birth-time-assessment-heading">
<div>
<span>出生时间评估</span>
<h2 id="birth-time-assessment-title">
{journey.snapshot.state === "candidate" ? "候选范围已保存" : "需要先缩小时间范围"}
</h2>
</div>
<span className="birth-time-status-badge">
{journey.snapshot.state === "candidate" ? "候选" : "校正中"}
</span>
</div>
<dl className="birth-time-range-summary">
<div><dt>当前范围</dt><dd>{journey.snapshot.reportedRange.label}</dd></div>
<div><dt>应用状态</dt><dd>尚未设为排盘时间</dd></div>
</dl>
<p className="birth-time-assistant-intent" role="status">
{assistantIntentCopy(journey.snapshot.assistantIntent)}
</p>
{questions.length > 0 ? (
<div className="birth-time-question-list">
<div className="birth-time-question-progress">
<b>首轮问题</b>
<span>{Math.min(answeredCount, questions.length)} / {questions.length}</span>
</div>
{questions.map((question, index) => (
<fieldset className="birth-time-question" key={question.id}>
<legend><span>{index + 1}</span>{question.prompt}</legend>
<div className="birth-time-answer-list">
{(question.options?.length ? question.options : fallbackOptions).map((option) => (
<button
aria-pressed={answers[question.id] === option.key}
className={answers[question.id] === option.key ? "is-selected" : ""}
disabled={Boolean(pendingQuestionId)}
key={option.key}
type="button"
onClick={() => onAnswer(question.id, option.key)}
>
<span>{option.key}</span>{option.label}
</button>
))}
</div>
{pendingQuestionId === question.id && <small role="status">正在更新候选范围…</small>}
</fieldset>
))}
</div>
) : (
<p className="birth-time-assessment-unavailable">
当前资料已经保留,但候选扫描暂时不可用。系统不会把未经验证的分钟用于正式排盘。
</p>
)}
{error && <p className="form-error" role="alert">{error}</p>}
</section>
);
}
+150
View File
@@ -0,0 +1,150 @@
import type { JourneySnapshot } from "./birth-time-journey.ts";
export type BirthTimeSource =
| ""
| "hospital_record"
| "family_exact"
| "approximate"
| "period_only"
| "unknown"
| "legacy_import";
export type BirthTimePeriod =
| ""
| "early_morning"
| "morning"
| "afternoon"
| "evening"
| "late_night";
export type BirthTimeStatus =
| ""
| "reported"
| "assessing"
| "rectifying"
| "candidate"
| "confirmed";
export type BirthTimeDraft = {
readonly date: string;
readonly time: string;
readonly reportedTime: string;
readonly birthTimeSource: BirthTimeSource;
readonly birthTimePeriod: BirthTimePeriod;
readonly birthTimeClue: string;
readonly uncertaintyBeforeMinutes: number | null;
readonly uncertaintyAfterMinutes: number | null;
readonly birthTimeStatus: BirthTimeStatus;
};
export type BirthTimeDraftPatch = Partial<BirthTimeDraft>;
export const birthTimeSourceOptions = [
{ value: "hospital_record", label: "出生证明或医院记录", hint: "先检查前后两分钟是否稳定" },
{ value: "family_exact", label: "家人明确记得具体时间", hint: "进行 5—15 分钟轻量校正" },
{ value: "approximate", label: "只记得大概几点", hint: "按你选择的误差范围扫描" },
{ value: "period_only", label: "只知道早晨、上午、下午或晚上", hint: "先从时段范围做粗筛" },
{ value: "unknown", label: "完全不知道", hint: "不要求你随便填写具体时间" },
] as const;
export const birthTimePeriodOptions = [
{ value: "early_morning", label: "凌晨 / 清晨(04:00—07:59)" },
{ value: "morning", label: "上午(08:00—11:59)" },
{ value: "afternoon", label: "下午(12:00—17:59)" },
{ value: "evening", label: "晚上(18:00—22:59)" },
{ value: "late_night", label: "深夜(23:00—03:59)" },
] as const;
const periodLabels = {
"": "未选择时段",
early_morning: "凌晨或清晨",
morning: "上午",
afternoon: "下午",
evening: "晚上",
late_night: "深夜",
} as const satisfies Record<BirthTimePeriod, string>;
const intentCopy = {
confirm_stable_record: "医院记录前后两分钟内结构稳定,可以直接作为当前排盘时间。",
explain_sensitive_boundary: "这个时间靠近敏感边界,需要先做轻量校正,暂不直接排盘。",
explain_assessment_unavailable: "暂时无法完成稳定性检查,系统不会冒险应用这个具体时间。",
start_light_rectification: "先用几个高区分度问题检查家人记忆范围。",
start_standard_rectification: "这个误差范围内存在多个候选,需要先回答几个生活经历问题。",
start_period_rectification: "已保留你知道的时段,接下来先做粗粒度候选筛选。",
collect_time_clues: "不会要求你猜一个具体时间,先从家人线索和大致时段开始。",
continue_rectification_questions: "已记录这条线索,还需要更多证据才能形成候选范围。",
present_saved_candidate_range: "目前只能保存候选范围,还没有足够证据应用到具体分钟。",
} as const satisfies Record<JourneySnapshot["assistantIntent"], string>;
export function assistantIntentCopy(intent: JourneySnapshot["assistantIntent"]) {
return intentCopy[intent];
}
export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
if (!draft.date) return false;
switch (draft.birthTimeSource) {
case "hospital_record":
case "legacy_import":
return Boolean(draft.reportedTime || draft.time);
case "family_exact":
return Boolean(draft.reportedTime)
&& [5, 10, 15].includes(draft.uncertaintyBeforeMinutes ?? -1)
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
case "approximate":
return Boolean(draft.reportedTime)
&& [15, 30, 60].includes(draft.uncertaintyBeforeMinutes ?? -1)
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
case "period_only":
return Boolean(draft.birthTimePeriod);
case "unknown":
return true;
case "":
return false;
default: {
const exhaustive: never = draft.birthTimeSource;
return exhaustive;
}
}
}
export function birthTimePersistenceValues(draft: BirthTimeDraft) {
const reportedTime = draft.reportedTime || draft.time || null;
const uncertainty = draft.birthTimeSource === "hospital_record"
? 2
: draft.birthTimeSource === "family_exact" || draft.birthTimeSource === "approximate"
? draft.uncertaintyBeforeMinutes
: null;
return {
reported_birth_time: reportedTime,
birth_time_source: draft.birthTimeSource || null,
birth_time_period: draft.birthTimePeriod || null,
birth_time_clue: draft.birthTimeClue.trim() || null,
uncertainty_before_minutes: uncertainty,
uncertainty_after_minutes: uncertainty,
};
}
export function describeBirthTimeDraft(draft: BirthTimeDraft) {
const [year, month, day] = draft.date.split("-").map(Number);
const date = `${year}年${month}月${day}日`;
switch (draft.birthTimeSource) {
case "hospital_record":
return `${date} ${draft.reportedTime}(医院记录)`;
case "family_exact":
return `${date} ${draft.reportedTime}(家人明确记得,前后 ${draft.uncertaintyBeforeMinutes} 分钟)`;
case "approximate":
return `${date},约 ${draft.reportedTime}(前后 ${draft.uncertaintyBeforeMinutes} 分钟)`;
case "period_only":
return `${date},${periodLabels[draft.birthTimePeriod]}`;
case "unknown":
return `${date},具体时间未知`;
case "legacy_import":
return `${date} ${draft.reportedTime || draft.time}(既有已确认资料)`;
case "":
return date;
default: {
const exhaustive: never = draft.birthTimeSource;
return exhaustive;
}
}
}
@@ -0,0 +1,143 @@
import { z } from "zod";
import {
birthTimeAssessmentSchema,
type BirthTimeAssessment,
} from "./birth-time-journey.ts";
import type {
RectificationAnswer,
RectificationQuestionnaire,
} from "./birth-time-journey-service.ts";
const profileSchema = z.object({
birth_date: z.string(),
reported_birth_time: z.string().nullable().optional(),
birth_time_source: z.enum([
"hospital_record",
"family_exact",
"approximate",
"period_only",
"unknown",
]),
birth_time_period: z.enum([
"early_morning",
"morning",
"afternoon",
"evening",
"late_night",
]).nullable().optional(),
birth_time_clue: z.string().nullable().optional(),
uncertainty_before_minutes: z.number().int().nullable().optional(),
uncertainty_after_minutes: z.number().int().nullable().optional(),
latitude: z.number(),
longitude: z.number(),
timezone_offset: z.number(),
});
const optionSchema = z.object({
key: z.enum(["A", "B", "C", "D"]),
label: z.string().trim().min(1),
});
const questionSchema = z.object({
id: z.string().trim().min(1),
prompt: z.string().trim().min(1),
options: z.array(optionSchema).optional(),
});
const signSchema = z.object({ sign: z.string().trim().min(1) }).nullable().optional();
const sampleSchema = z.object({
ascendant: signSchema,
varga_lagna: z.object({
D9: signSchema,
D10: signSchema,
}).optional(),
});
const questionnaireSchema = z.object({
questions: z.array(questionSchema),
candidate_scan: z.object({ samples: z.array(sampleSchema) }),
}).passthrough();
const scoringSchema = z.object({
answered_count: z.number().int().min(0),
candidate_cluster_rankings: z.array(z.object({
cluster: z.string().trim().min(1),
score: z.number(),
})),
}).passthrough();
class UnexpectedProfileSourceError extends Error {
readonly name = "UnexpectedProfileSourceError";
constructor(source: never) {
super(`Unexpected profile birth-time source: ${JSON.stringify(source)}`);
}
}
export function parseBirthTimeProfile(value: unknown): BirthTimeAssessment {
const profile = profileSchema.parse(value);
const location = {
lat: profile.latitude,
lon: profile.longitude,
tz: profile.timezone_offset,
};
switch (profile.birth_time_source) {
case "hospital_record":
case "family_exact":
case "approximate":
return birthTimeAssessmentSchema.parse({
date: profile.birth_date,
source: profile.birth_time_source,
reportedTime: profile.reported_birth_time?.slice(0, 5),
uncertaintyBeforeMinutes: profile.uncertainty_before_minutes,
uncertaintyAfterMinutes: profile.uncertainty_after_minutes,
location,
});
case "period_only":
return birthTimeAssessmentSchema.parse({
date: profile.birth_date,
source: profile.birth_time_source,
period: profile.birth_time_period,
location,
});
case "unknown":
return birthTimeAssessmentSchema.parse({
date: profile.birth_date,
source: profile.birth_time_source,
clue: profile.birth_time_clue ?? "",
location,
});
default:
throw new UnexpectedProfileSourceError(profile.birth_time_source);
}
}
export function parseRectificationQuestionnaire(value: unknown): RectificationQuestionnaire {
const parsed = questionnaireSchema.parse(value);
return {
questions: parsed.questions.map((question) => ({
id: question.id,
prompt: question.prompt,
...(question.options ? { options: question.options } : {}),
})),
samples: parsed.candidate_scan.samples.map((sample) => ({
ascendantSign: sample.ascendant?.sign ?? null,
d9Sign: sample.varga_lagna?.D9?.sign ?? null,
d10Sign: sample.varga_lagna?.D10?.sign ?? null,
})),
raw: parsed,
};
}
export function parseRectificationScoring(value: unknown) {
const parsed = scoringSchema.parse(value);
return {
answeredCount: parsed.answered_count,
candidateClusterRankings: parsed.candidate_cluster_rankings,
raw: parsed,
};
}
export function parseRectificationAnswer(value: unknown): RectificationAnswer {
return z.enum(["A", "B", "C", "D"]).parse(value);
}
@@ -0,0 +1,116 @@
import { z } from "zod";
import { journeySnapshotSchema } from "./birth-time-journey.ts";
const answerSchema = z.enum(["A", "B", "C", "D"]);
const questionnaireSchema = z.object({
questions: z.array(z.object({
id: z.string(),
prompt: z.string(),
options: z.array(z.object({
key: answerSchema,
label: z.string(),
})).optional(),
})),
samples: z.array(z.object({
ascendantSign: z.string().nullable(),
d9Sign: z.string().nullable(),
d10Sign: z.string().nullable(),
})),
raw: z.record(z.unknown()),
});
const scoringSchema = z.object({
answeredCount: z.number().int().min(0),
candidateClusterRankings: z.array(z.object({
cluster: z.string(),
score: z.number(),
})),
raw: z.record(z.unknown()),
});
const journeyResponseSchema = z.object({
caseId: z.string().uuid(),
snapshot: journeySnapshotSchema,
questionnaire: questionnaireSchema.nullable(),
scoring: scoringSchema.nullable(),
answers: z.record(answerSchema).default({}),
}).superRefine((value, context) => {
if (value.snapshot.route === "rectification" && value.snapshot.canApply) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["snapshot", "canApply"],
message: "rectification results cannot apply an exact time",
});
}
if (value.snapshot.route === "direct_chart" && !value.snapshot.activeTime) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["snapshot", "activeTime"],
message: "direct chart requires an active time",
});
}
});
const errorPayloadSchema = z.object({
message: z.string().optional(),
error: z.string().optional(),
});
export type JourneyClientResponse = z.infer<typeof journeyResponseSchema>;
export type JourneyAnswer = z.infer<typeof answerSchema>;
export class BirthTimeJourneyRequestError extends Error {
readonly name = "BirthTimeJourneyRequestError";
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export function parseJourneyResponse(value: unknown): JourneyClientResponse {
return journeyResponseSchema.parse(value);
}
async function responsePayload(response: Response): Promise<unknown> {
try {
return await response.json();
} catch (error) {
if (error instanceof SyntaxError) return null;
throw error;
}
}
async function sendJourneyEvent(event: Readonly<Record<string, unknown>>) {
const response = await fetch("/api/birth-time-journey", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(event),
});
const payload = await responsePayload(response);
if (!response.ok) {
const parsedError = errorPayloadSchema.safeParse(payload);
const message = parsedError.success
? parsedError.data.message ?? parsedError.data.error ?? "生时评估暂时不可用"
: "生时评估暂时不可用";
throw new BirthTimeJourneyRequestError(response.status, message);
}
return parseJourneyResponse(payload);
}
export function requestBirthTimeAssessment() {
return sendJourneyEvent({ type: "assess" });
}
export function answerBirthTimeQuestion(
caseId: string,
questionId: string,
answer: JourneyAnswer,
) {
return sendJourneyEvent({ type: "answer_question", caseId, questionId, answer });
}
export function resumeBirthTimeJourney(caseId: string) {
return sendJourneyEvent({ type: "resume", caseId });
}
@@ -0,0 +1,56 @@
import "server-only";
import {
parseRectificationQuestionnaire,
parseRectificationScoring,
} from "./birth-time-journey-adapters.ts";
import type { BirthTimeJourneyEngine } from "./birth-time-journey-service.ts";
export class BirthTimeJourneyEngineError extends Error {
readonly name = "BirthTimeJourneyEngineError";
readonly status: number;
constructor(status: number) {
super(`Jyotish birth-time engine returned ${status}`);
this.status = status;
}
}
async function postJson(apiBase: string, path: string, body: unknown): Promise<unknown> {
const response = await fetch(`${apiBase}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(45_000),
});
const payload: unknown = await response.json();
if (!response.ok) throw new BirthTimeJourneyEngineError(response.status);
return payload;
}
export function createJyotishBirthTimeJourneyEngine(
apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200",
): BirthTimeJourneyEngine {
return {
async scan(input) {
const payload = await postJson(apiBase, "/api/active_rectification_questions", {
birth_time: input.birthTime,
uncertainty_minutes: input.uncertaintyMinutes,
step_minutes: 1,
lat: input.lat,
lon: input.lon,
tz: input.tz,
ayanamsa: input.ayanamsa,
});
return { questionnaire: parseRectificationQuestionnaire(payload) };
},
async score(input) {
const payload = await postJson(apiBase, "/api/active_rectification_score", {
questionnaire: input.questionnaire.raw,
answers: input.answers,
});
return parseRectificationScoring(payload);
},
};
}
@@ -0,0 +1,206 @@
import {
assessBirthTime,
withRectificationScoring,
type BirthTimeAssessment,
type JourneySnapshot,
type RectificationScoring,
type ScanStability,
} from "./birth-time-journey.ts";
export type RectificationAnswer = "A" | "B" | "C" | "D";
export type RectificationQuestionnaire = {
readonly questions: readonly {
readonly id: string;
readonly prompt: string;
readonly options?: readonly {
readonly key: RectificationAnswer;
readonly label: string;
}[];
}[];
readonly samples: readonly {
readonly ascendantSign: string | null;
readonly d9Sign: string | null;
readonly d10Sign: string | null;
}[];
readonly raw: Readonly<Record<string, unknown>>;
};
export type JourneyScanInput = {
readonly birthTime: string;
readonly uncertaintyMinutes: number;
readonly lat: number;
readonly lon: number;
readonly tz: number;
readonly ayanamsa: "lahiri";
};
export type JourneyScoreInput = {
readonly questionnaire: RectificationQuestionnaire;
readonly answers: Readonly<Record<string, RectificationAnswer>>;
};
export interface BirthTimeJourneyEngine {
scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>;
score(input: JourneyScoreInput): Promise<RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> }>;
}
export type PersistedJourneyAssessment = {
readonly userId: string;
readonly assessment: BirthTimeAssessment;
readonly snapshot: JourneySnapshot;
readonly questionnaire: RectificationQuestionnaire | null;
readonly candidateScan: RectificationQuestionnaire | null;
};
export type StoredRectificationCase = {
readonly id: string;
readonly userId: string;
readonly snapshot: JourneySnapshot;
readonly questionnaire: RectificationQuestionnaire | null;
readonly answers: Readonly<Record<string, RectificationAnswer>>;
readonly scoring?: RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> };
};
export interface BirthTimeJourneyStore {
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
loadCase(userId: string, caseId: string): Promise<StoredRectificationCase | null>;
saveScoring(value: StoredRectificationCase): Promise<void>;
}
type BirthTimeJourneyPorts = {
readonly store: BirthTimeJourneyStore;
readonly engine: BirthTimeJourneyEngine;
};
type JourneyResponse = {
readonly caseId: string;
readonly snapshot: JourneySnapshot;
readonly questionnaire: RectificationQuestionnaire | null;
readonly scoring: (RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> }) | null;
readonly answers: Readonly<Record<string, RectificationAnswer>>;
};
export class RectificationCaseNotFoundError extends Error {
readonly name = "RectificationCaseNotFoundError";
readonly caseId: string;
constructor(caseId: string) {
super(`Rectification case ${caseId} was not found`);
this.caseId = caseId;
}
}
export class RectificationQuestionsUnavailableError extends Error {
readonly name = "RectificationQuestionsUnavailableError";
}
function scanInput(assessment: BirthTimeAssessment): JourneyScanInput | null {
if (assessment.source === "unknown") return null;
if (assessment.source === "period_only") {
const periodScan = {
early_morning: { time: "06:00", uncertainty: 120 },
morning: { time: "10:00", uncertainty: 120 },
afternoon: { time: "15:00", uncertainty: 180 },
evening: { time: "20:30", uncertainty: 150 },
late_night: { time: "01:30", uncertainty: 150 },
} as const;
const scan = periodScan[assessment.period];
return {
birthTime: `${assessment.date} ${scan.time}`,
uncertaintyMinutes: scan.uncertainty,
lat: assessment.location.lat,
lon: assessment.location.lon,
tz: assessment.location.tz,
ayanamsa: "lahiri",
};
}
return {
birthTime: `${assessment.date} ${assessment.reportedTime}`,
uncertaintyMinutes: Math.max(
assessment.uncertaintyBeforeMinutes,
assessment.uncertaintyAfterMinutes,
),
lat: assessment.location.lat,
lon: assessment.location.lon,
tz: assessment.location.tz,
ayanamsa: "lahiri",
};
}
function questionnaireStability(questionnaire: RectificationQuestionnaire): ScanStability {
if (questionnaire.samples.length < 2) return { kind: "unavailable" };
const signatures = questionnaire.samples.map((sample) => {
if (!sample.ascendantSign || !sample.d9Sign || !sample.d10Sign) return null;
return `${sample.ascendantSign}|${sample.d9Sign}|${sample.d10Sign}`;
});
if (signatures.some((signature) => signature === null)) return { kind: "unavailable" };
return new Set(signatures).size === 1 ? { kind: "stable" } : { kind: "sensitive" };
}
async function scanAssessment(
engine: BirthTimeJourneyEngine,
assessment: BirthTimeAssessment,
): Promise<{ readonly stability: ScanStability; readonly questionnaire: RectificationQuestionnaire | null }> {
const input = scanInput(assessment);
if (!input) return { stability: { kind: "not_required" }, questionnaire: null };
try {
const result = await engine.scan(input);
return {
stability: questionnaireStability(result.questionnaire),
questionnaire: result.questionnaire,
};
} catch (error) {
if (error instanceof Error) {
return { stability: { kind: "unavailable" }, questionnaire: null };
}
throw error;
}
}
export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
return {
async assess(userId: string, assessment: BirthTimeAssessment): Promise<JourneyResponse> {
const scan = await scanAssessment(ports.engine, assessment);
const snapshot = assessBirthTime(assessment, scan.stability);
const persisted = {
userId,
assessment,
snapshot,
questionnaire: scan.questionnaire,
candidateScan: scan.questionnaire,
} satisfies PersistedJourneyAssessment;
const caseId = await ports.store.saveAssessment(persisted);
return { caseId, snapshot, questionnaire: scan.questionnaire, scoring: null, answers: {} };
},
async resume(userId: string, caseId: string): Promise<JourneyResponse> {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new RectificationCaseNotFoundError(caseId);
return {
caseId,
snapshot: stored.snapshot,
questionnaire: stored.questionnaire,
scoring: stored.scoring ?? null,
answers: stored.answers,
};
},
async answerQuestion(
userId: string,
caseId: string,
questionId: string,
answer: RectificationAnswer,
): Promise<JourneyResponse> {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new RectificationCaseNotFoundError(caseId);
if (!stored.questionnaire) throw new RectificationQuestionsUnavailableError();
const answers = { ...stored.answers, [questionId]: answer };
const scoring = await ports.engine.score({ questionnaire: stored.questionnaire, answers });
const snapshot = withRectificationScoring(stored.snapshot, scoring);
const updated = { ...stored, answers, scoring, snapshot } satisfies StoredRectificationCase;
await ports.store.saveScoring(updated);
return { caseId, snapshot, questionnaire: stored.questionnaire, scoring, answers };
},
};
}
@@ -0,0 +1,170 @@
import "server-only";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { parseRectificationQuestionnaire } from "./birth-time-journey-adapters.ts";
import type {
BirthTimeJourneyStore,
PersistedJourneyAssessment,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import {
journeySnapshotSchema,
type JourneySnapshot,
} from "./birth-time-journey.ts";
const answerSchema = z.enum(["A", "B", "C", "D"]);
const storedCaseSchema = z.object({
id: z.string().uuid(),
user_id: z.string().uuid(),
journey_snapshot: journeySnapshotSchema,
questionnaire: z.record(z.unknown()),
answers: z.record(answerSchema),
scoring_result: z.record(z.unknown()),
});
export class BirthTimeJourneyStoreError extends Error {
readonly name = "BirthTimeJourneyStoreError";
constructor(readonly operation: "insert_case" | "update_profile" | "load_case" | "update_case") {
super(`Birth-time journey persistence failed during ${operation}`);
}
}
function caseStatus(snapshot: JourneySnapshot) {
switch (snapshot.state) {
case "ready":
return "confirmed";
case "candidate":
return "candidate";
case "rectifying":
return "rectifying";
default: {
const exhaustive: never = snapshot.state;
return exhaustive;
}
}
}
function profileStatus(snapshot: JourneySnapshot) {
return snapshot.state === "ready" ? "confirmed" : caseStatus(snapshot);
}
function assessmentValues(value: PersistedJourneyAssessment) {
const assessment = value.assessment;
return {
reportedTime: "reportedTime" in assessment ? assessment.reportedTime : null,
period: assessment.source === "period_only" ? assessment.period : null,
clue: assessment.source === "unknown" ? assessment.clue : null,
before: "uncertaintyBeforeMinutes" in assessment
? assessment.uncertaintyBeforeMinutes
: null,
after: "uncertaintyAfterMinutes" in assessment
? assessment.uncertaintyAfterMinutes
: null,
};
}
export function createSupabaseBirthTimeJourneyStore(
supabase: SupabaseClient,
): BirthTimeJourneyStore {
return {
async saveAssessment(value) {
const details = assessmentValues(value);
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.insert({
user_id: value.userId,
status: caseStatus(value.snapshot),
reported_date: value.assessment.date,
reported_time: details.reportedTime,
reported_period: details.period,
source: value.assessment.source,
uncertainty_before_minutes: details.before,
uncertainty_after_minutes: details.after,
questionnaire: value.questionnaire?.raw ?? {},
journey_snapshot: value.snapshot,
candidate_scan: value.candidateScan?.raw ?? {},
candidate_start: value.snapshot.reportedRange.startTime,
candidate_end: value.snapshot.reportedRange.endTime,
confirmed_time: value.snapshot.activeTime,
confirmed_at: value.snapshot.state === "ready" ? new Date().toISOString() : null,
})
.select("id")
.single();
if (error) throw new BirthTimeJourneyStoreError("insert_case");
const caseId = z.string().uuid().parse(data.id);
const { error: profileError } = await supabase
.from("profiles")
.update({
reported_birth_time: details.reportedTime,
active_birth_time: value.snapshot.activeTime,
birth_time: value.snapshot.activeTime,
birth_time_source: value.assessment.source,
birth_time_period: details.period,
birth_time_clue: details.clue,
uncertainty_before_minutes: details.before,
uncertainty_after_minutes: details.after,
birth_time_status: profileStatus(value.snapshot),
rectification_confidence: null,
rectification_case_id: caseId,
})
.eq("id", value.userId);
if (profileError) throw new BirthTimeJourneyStoreError("update_profile");
return caseId;
},
async loadCase(userId, caseId) {
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.select("id,user_id,journey_snapshot,questionnaire,answers,scoring_result")
.eq("id", caseId)
.eq("user_id", userId)
.maybeSingle();
if (error) throw new BirthTimeJourneyStoreError("load_case");
if (!data) return null;
const parsed = storedCaseSchema.parse(data);
const scoring = Object.keys(parsed.scoring_result).length > 0
? {
answeredCount: 0,
candidateClusterRankings: [],
raw: parsed.scoring_result,
}
: undefined;
const questionnaire = Object.keys(parsed.questionnaire).length > 0
? parseRectificationQuestionnaire(parsed.questionnaire)
: null;
return {
id: parsed.id,
userId: parsed.user_id,
snapshot: parsed.journey_snapshot,
questionnaire,
answers: parsed.answers,
...(scoring ? { scoring } : {}),
} satisfies StoredRectificationCase;
},
async saveScoring(value) {
const { error } = await supabase
.from("birth_time_rectification_cases")
.update({
status: caseStatus(value.snapshot),
journey_snapshot: value.snapshot,
answers: value.answers,
scoring_result: value.scoring?.raw ?? {},
updated_at: new Date().toISOString(),
})
.eq("id", value.id)
.eq("user_id", value.userId);
if (error) throw new BirthTimeJourneyStoreError("update_case");
const { error: profileError } = await supabase
.from("profiles")
.update({ birth_time_status: profileStatus(value.snapshot) })
.eq("id", value.userId)
.eq("rectification_case_id", value.id);
if (profileError) throw new BirthTimeJourneyStoreError("update_profile");
},
};
}
+209
View File
@@ -0,0 +1,209 @@
import { z } from "zod";
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const locationSchema = z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
tz: z.number().min(-12).max(14),
}).readonly();
const exactFields = {
date: dateSchema,
reportedTime: timeSchema,
location: locationSchema,
} as const;
export const birthTimeAssessmentSchema = z.union([
z.object({
...exactFields,
source: z.literal("hospital_record"),
uncertaintyBeforeMinutes: z.literal(2),
uncertaintyAfterMinutes: z.literal(2),
}).readonly(),
z.object({
...exactFields,
source: z.literal("family_exact"),
uncertaintyBeforeMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]),
uncertaintyAfterMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]),
}).readonly().refine(
(value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes,
{ message: "family uncertainty must be symmetric" },
),
z.object({
...exactFields,
source: z.literal("approximate"),
uncertaintyBeforeMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]),
uncertaintyAfterMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]),
}).readonly().refine(
(value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes,
{ message: "approximate uncertainty must be symmetric" },
),
z.object({
date: dateSchema,
source: z.literal("period_only"),
period: z.enum(["early_morning", "morning", "afternoon", "evening", "late_night"]),
location: locationSchema,
}).readonly(),
z.object({
date: dateSchema,
source: z.literal("unknown"),
clue: z.string().trim().max(240).default(""),
location: locationSchema,
}).readonly(),
]);
export type BirthTimeAssessment = z.infer<typeof birthTimeAssessmentSchema>;
export type ScanStability =
| { readonly kind: "stable" }
| { readonly kind: "sensitive" }
| { readonly kind: "unavailable" }
| { readonly kind: "not_required" };
export const journeySnapshotSchema = z.object({
state: z.enum(["rectifying", "candidate", "ready"]),
assistantIntent: z.enum([
"confirm_stable_record",
"explain_sensitive_boundary",
"explain_assessment_unavailable",
"start_light_rectification",
"start_standard_rectification",
"start_period_rectification",
"collect_time_clues",
"continue_rectification_questions",
"present_saved_candidate_range",
]),
input: z.enum(["none", "rectification_questions", "time_clue"]),
route: z.enum(["direct_chart", "rectification"]),
confidence: z.literal("high").nullable(),
canApply: z.boolean(),
activeTime: z.string().nullable(),
reportedRange: z.object({
label: z.string(),
startTime: z.string().nullable(),
endTime: z.string().nullable(),
}).readonly(),
}).readonly();
export type JourneySnapshot = z.infer<typeof journeySnapshotSchema>;
export type RectificationScoring = {
readonly answeredCount: number;
readonly candidateClusterRankings: readonly {
readonly cluster: string;
readonly score: number;
}[];
};
class UnexpectedJourneyVariantError extends Error {
readonly name = "UnexpectedJourneyVariantError";
constructor(value: never) {
super(`Unexpected birth-time journey variant: ${JSON.stringify(value)}`);
}
}
const periodRanges = {
early_morning: { label: "04:00—07:59", startTime: "04:00", endTime: "07:59" },
morning: { label: "08:00—11:59", startTime: "08:00", endTime: "11:59" },
afternoon: { label: "12:00—17:59", startTime: "12:00", endTime: "17:59" },
evening: { label: "18:00—22:59", startTime: "18:00", endTime: "22:59" },
late_night: { label: "23:00—03:59", startTime: "23:00", endTime: "03:59" },
} as const;
function shiftedTime(time: string, offsetMinutes: number): string {
const [hourText, minuteText] = time.split(":");
const minutes = Number(hourText) * 60 + Number(minuteText) + offsetMinutes;
const normalized = (minutes + 24 * 60) % (24 * 60);
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
}
function exactRange(time: string, before: number, after: number): JourneySnapshot["reportedRange"] {
const startTime = shiftedTime(time, -before);
const endTime = shiftedTime(time, after);
return { label: `${startTime}—${endTime}`, startTime, endTime };
}
function rectificationSnapshot(
assistantIntent: JourneySnapshot["assistantIntent"],
reportedRange: JourneySnapshot["reportedRange"],
input: JourneySnapshot["input"] = "rectification_questions",
): JourneySnapshot {
return {
state: "rectifying",
assistantIntent,
input,
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange,
};
}
export function assessBirthTime(
assessment: BirthTimeAssessment,
scanStability: ScanStability,
): JourneySnapshot {
switch (assessment.source) {
case "hospital_record": {
const reportedRange = exactRange(assessment.reportedTime, 2, 2);
switch (scanStability.kind) {
case "stable":
return {
state: "ready",
assistantIntent: "confirm_stable_record",
input: "none",
route: "direct_chart",
confidence: "high",
canApply: true,
activeTime: assessment.reportedTime,
reportedRange,
};
case "sensitive":
return rectificationSnapshot("explain_sensitive_boundary", reportedRange);
case "unavailable":
case "not_required":
return rectificationSnapshot("explain_assessment_unavailable", reportedRange);
default:
throw new UnexpectedJourneyVariantError(scanStability);
}
}
case "family_exact":
return rectificationSnapshot(
"start_light_rectification",
exactRange(assessment.reportedTime, assessment.uncertaintyBeforeMinutes, assessment.uncertaintyAfterMinutes),
);
case "approximate":
return rectificationSnapshot(
"start_standard_rectification",
exactRange(assessment.reportedTime, assessment.uncertaintyBeforeMinutes, assessment.uncertaintyAfterMinutes),
);
case "period_only":
return rectificationSnapshot("start_period_rectification", periodRanges[assessment.period]);
case "unknown":
return rectificationSnapshot(
"collect_time_clues",
{ label: "全天待确认", startTime: null, endTime: null },
"time_clue",
);
default:
throw new UnexpectedJourneyVariantError(assessment);
}
}
export function withRectificationScoring(
snapshot: JourneySnapshot,
scoring: RectificationScoring,
): JourneySnapshot {
if (snapshot.route === "direct_chart") return snapshot;
const hasCandidate = scoring.answeredCount >= 3 && scoring.candidateClusterRankings.length > 0;
return {
...snapshot,
state: hasCandidate ? "candidate" : "rectifying",
assistantIntent: hasCandidate ? "present_saved_candidate_range" : "continue_rectification_questions",
canApply: false,
activeTime: null,
};
}
+4 -2
View File
@@ -16,6 +16,7 @@ export const consultationInputSchema = z.object({
city: z.string().trim().min(1).max(120),
question: z.string().trim().min(1).max(500),
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
entryMode: z.enum(["direct_chart", "rectification"]).default("direct_chart"),
});
export type ConsultationInput = z.infer<typeof consultationInputSchema>;
@@ -33,12 +34,13 @@ const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim()
|| path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology");
export async function runConsultationWorkflow(input: ConsultationInput) {
const { entryMode, ...workflowInput } = input;
const response = await fetch(`${apiBase}/api/consultation_workflow`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...input,
entry_mode: "direct_chart",
...workflowInput,
entry_mode: entryMode,
question_text: input.question,
theme: input.theme === "general" ? ["career", "marriage", "wealth"] : [input.theme],
}),
@@ -0,0 +1,208 @@
begin;
alter table public.profiles
add column if not exists reported_birth_time time without time zone,
add column if not exists active_birth_time time without time zone,
add column if not exists birth_time_source text,
add column if not exists birth_time_period text,
add column if not exists birth_time_clue text,
add column if not exists uncertainty_before_minutes integer,
add column if not exists uncertainty_after_minutes integer,
add column if not exists birth_time_status text,
add column if not exists rectification_confidence numeric(5, 2),
add column if not exists rectification_case_id uuid;
update public.profiles
set
reported_birth_time = coalesce(reported_birth_time, birth_time),
active_birth_time = coalesce(active_birth_time, birth_time),
birth_time_source = coalesce(birth_time_source, 'legacy_import'),
birth_time_status = coalesce(birth_time_status, 'confirmed')
where birth_time is not null;
alter table public.profiles
drop constraint if exists profiles_birth_time_source_check,
drop constraint if exists profiles_birth_time_period_check,
drop constraint if exists profiles_birth_time_status_check,
drop constraint if exists profiles_uncertainty_before_range,
drop constraint if exists profiles_uncertainty_after_range,
drop constraint if exists profiles_rectification_confidence_range;
alter table public.profiles
add constraint profiles_birth_time_source_check check (
birth_time_source is null or birth_time_source in (
'hospital_record',
'family_exact',
'approximate',
'period_only',
'unknown',
'legacy_import'
)
),
add constraint profiles_birth_time_period_check check (
birth_time_period is null or birth_time_period in (
'early_morning',
'morning',
'afternoon',
'evening',
'late_night'
)
),
add constraint profiles_birth_time_status_check check (
birth_time_status is null or birth_time_status in (
'reported',
'assessing',
'rectifying',
'candidate',
'confirmed'
)
),
add constraint profiles_uncertainty_before_range check (
uncertainty_before_minutes is null or uncertainty_before_minutes between 0 and 720
),
add constraint profiles_uncertainty_after_range check (
uncertainty_after_minutes is null or uncertainty_after_minutes between 0 and 720
),
add constraint profiles_rectification_confidence_range check (
rectification_confidence is null or rectification_confidence between 0 and 100
);
create table if not exists public.birth_time_rectification_cases (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
status text not null default 'assessing' check (
status in ('assessing', 'rectifying', 'candidate', 'confirmed')
),
reported_date date not null,
reported_time time without time zone,
reported_period text,
source text not null,
uncertainty_before_minutes integer,
uncertainty_after_minutes integer,
questionnaire jsonb not null default '{}'::jsonb check (jsonb_typeof(questionnaire) = 'object'),
journey_snapshot jsonb not null default '{}'::jsonb check (jsonb_typeof(journey_snapshot) = 'object'),
answers jsonb not null default '{}'::jsonb check (jsonb_typeof(answers) = 'object'),
candidate_scan jsonb not null default '{}'::jsonb check (jsonb_typeof(candidate_scan) = 'object'),
scoring_result jsonb not null default '{}'::jsonb check (jsonb_typeof(scoring_result) = 'object'),
candidate_start time without time zone,
candidate_end time without time zone,
algorithm_version text not null default 'birth-time-journey-v1',
ayanamsa text not null default 'lahiri',
node_mode text not null default 'true',
confirmed_time time without time zone,
confirmed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.profiles
drop constraint if exists profiles_rectification_case_id_fkey,
add constraint profiles_rectification_case_id_fkey
foreign key (rectification_case_id)
references public.birth_time_rectification_cases(id)
on delete set null;
create or replace function public.guard_birth_time_journey()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
if old.reported_birth_time is not null
and new.reported_birth_time is distinct from old.reported_birth_time then
raise exception 'reported_birth_time_is_immutable';
end if;
if new.active_birth_time is distinct from old.active_birth_time then
new.birth_time := new.active_birth_time;
elsif new.birth_time is distinct from old.birth_time then
new.active_birth_time := new.birth_time;
end if;
if new.reported_birth_time is null and new.birth_time is not null then
new.reported_birth_time := new.birth_time;
end if;
if new.birth_time_source is null and new.birth_time is not null then
new.birth_time_source := 'legacy_import';
end if;
if new.birth_time_status is null and new.active_birth_time is not null then
new.birth_time_status := 'confirmed';
end if;
return new;
end;
$$;
drop trigger if exists profiles_guard_birth_time_journey on public.profiles;
create trigger profiles_guard_birth_time_journey
before update of birth_time, reported_birth_time, active_birth_time on public.profiles
for each row execute function public.guard_birth_time_journey();
alter table public.birth_time_rectification_cases enable row level security;
drop policy if exists birth_time_cases_select_own on public.birth_time_rectification_cases;
create policy birth_time_cases_select_own
on public.birth_time_rectification_cases
for select to authenticated
using ((select auth.uid()) = user_id);
drop policy if exists birth_time_cases_insert_own on public.birth_time_rectification_cases;
create policy birth_time_cases_insert_own
on public.birth_time_rectification_cases
for insert to authenticated
with check ((select auth.uid()) = user_id);
drop policy if exists birth_time_cases_update_own on public.birth_time_rectification_cases;
create policy birth_time_cases_update_own
on public.birth_time_rectification_cases
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
revoke all on table public.birth_time_rectification_cases from anon, authenticated, service_role;
grant all on table public.birth_time_rectification_cases to service_role;
grant select on table public.birth_time_rectification_cases to authenticated;
grant insert (
id,
user_id,
status,
reported_date,
reported_time,
reported_period,
source,
uncertainty_before_minutes,
uncertainty_after_minutes,
questionnaire,
journey_snapshot,
answers,
candidate_scan,
scoring_result,
candidate_start,
candidate_end,
updated_at
) on table public.birth_time_rectification_cases to authenticated;
grant update (
status,
questionnaire,
journey_snapshot,
answers,
candidate_scan,
scoring_result,
candidate_start,
candidate_end,
confirmed_time,
confirmed_at,
updated_at
) on table public.birth_time_rectification_cases to authenticated;
revoke update (birth_time) on table public.profiles from authenticated;
grant update (
reported_birth_time,
birth_time_source,
birth_time_period,
birth_time_clue,
uncertainty_before_minutes,
uncertainty_after_minutes
) on table public.profiles to authenticated;
commit;
+81
View File
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assistantIntentCopy,
birthTimePersistenceValues,
describeBirthTimeDraft,
isBirthTimeDraftReady,
type BirthTimeDraft,
} from "../src/lib/birth-time-intake-model.ts";
const emptyDraft: BirthTimeDraft = {
date: "1993-04-17",
time: "",
reportedTime: "",
birthTimeSource: "",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "",
};
test("birth time intake requires only the fields selected by the source", () => {
const hospital = {
...emptyDraft,
birthTimeSource: "hospital_record",
reportedTime: "08:16",
} satisfies BirthTimeDraft;
const period = {
...emptyDraft,
birthTimeSource: "period_only",
birthTimePeriod: "evening",
} satisfies BirthTimeDraft;
const incompleteApproximate = {
...emptyDraft,
birthTimeSource: "approximate",
reportedTime: "14:30",
} satisfies BirthTimeDraft;
assert.equal(isBirthTimeDraftReady(hospital), true);
assert.equal(isBirthTimeDraftReady(period), true);
assert.equal(isBirthTimeDraftReady(incompleteApproximate), false);
assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true);
});
test("birth time declaration payload cannot write deterministic application fields", () => {
const draft = {
...emptyDraft,
time: "14:24",
reportedTime: "14:30",
birthTimeSource: "approximate",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthTimeStatus: "confirmed",
} satisfies BirthTimeDraft;
assert.deepEqual(birthTimePersistenceValues(draft), {
reported_birth_time: "14:30",
birth_time_source: "approximate",
birth_time_period: null,
birth_time_clue: null,
uncertainty_before_minutes: 30,
uncertainty_after_minutes: 30,
});
});
test("birth time intake describes uncertainty without claiming false precision", () => {
const approximate = {
...emptyDraft,
birthTimeSource: "approximate",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
} satisfies BirthTimeDraft;
assert.equal(describeBirthTimeDraft(approximate), "1993年4月17日,约 14:30(前后 30 分钟)");
assert.equal(
assistantIntentCopy("present_saved_candidate_range"),
"目前只能保存候选范围,还没有足够证据应用到具体分钟。",
);
});
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseBirthTimeProfile,
parseRectificationQuestionnaire,
parseRectificationScoring,
} from "../src/lib/birth-time-journey-adapters.ts";
const coordinates = {
latitude: 31.2304,
longitude: 121.4737,
timezone_offset: 8,
} as const;
test("birth time profile adapter parses an exact hospital declaration", () => {
const assessment = parseBirthTimeProfile({
birth_date: "1993-04-17",
reported_birth_time: "08:16:00",
birth_time_source: "hospital_record",
uncertainty_before_minutes: 2,
uncertainty_after_minutes: 2,
...coordinates,
});
assert.equal(assessment.source, "hospital_record");
if (assessment.source === "hospital_record") {
assert.equal(assessment.reportedTime, "08:16");
assert.equal(assessment.location.lon, 121.4737);
}
});
test("birth time profile adapter parses a period without inventing a time", () => {
const assessment = parseBirthTimeProfile({
birth_date: "1993-04-17",
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "evening",
...coordinates,
});
assert.equal(assessment.source, "period_only");
assert.equal("reportedTime" in assessment, false);
});
test("birth time profile adapter rejects missing location coordinates", () => {
assert.throws(() => parseBirthTimeProfile({
birth_date: "1993-04-17",
reported_birth_time: "08:16:00",
birth_time_source: "hospital_record",
uncertainty_before_minutes: 2,
uncertainty_after_minutes: 2,
}));
});
test("rectification adapter normalizes Python questionnaire samples and options", () => {
const questionnaire = parseRectificationQuestionnaire({
questions: [{
id: "education_environment_shift",
prompt: "是否有明显学业变化?",
options: [
{ key: "A", label: "明确有" },
{ key: "D", label: "不记得" },
],
}],
candidate_scan: {
samples: [{
ascendant: { sign: "Cancer" },
varga_lagna: {
D9: { sign: "Leo" },
D10: { sign: "Virgo" },
},
}],
},
});
assert.deepEqual(questionnaire.questions[0]?.options, [
{ key: "A", label: "明确有" },
{ key: "D", label: "不记得" },
]);
assert.deepEqual(questionnaire.samples[0], {
ascendantSign: "Cancer",
d9Sign: "Leo",
d10Sign: "Virgo",
});
});
test("rectification adapter rejects a malformed Python questionnaire", () => {
assert.throws(() => parseRectificationQuestionnaire({
questions: [{ id: "missing_prompt" }],
candidate_scan: { samples: [] },
}));
});
test("rectification adapter normalizes scoring without elevating confidence", () => {
const scoring = parseRectificationScoring({
answered_count: 3,
candidate_cluster_rankings: [
{ cluster: "middle_candidate_cluster", score: 5 },
],
next_round: 2,
});
assert.equal(scoring.answeredCount, 3);
assert.deepEqual(scoring.candidateClusterRankings, [
{ cluster: "middle_candidate_cluster", score: 5 },
]);
assert.equal(scoring.raw.next_round, 2);
});
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts";
const snapshot = {
state: "rectifying",
assistantIntent: "start_standard_rectification",
input: "rectification_questions",
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange: { label: "14:00—15:00", startTime: "14:00", endTime: "15:00" },
} as const;
test("journey client parses the sanitized API response", () => {
const parsed = parseJourneyResponse({
caseId: "7299894c-10a8-4b45-91d1-339007282c50",
snapshot,
questionnaire: {
questions: [{
id: "education_environment_shift",
prompt: "是否有明显学业变化?",
options: [{ key: "A", label: "明确有" }],
}],
samples: [],
raw: {},
},
scoring: null,
});
assert.equal(parsed.snapshot.route, "rectification");
assert.equal(parsed.questionnaire?.questions[0]?.id, "education_environment_shift");
});
test("journey client rejects an API response that tries to apply a rectification result", () => {
assert.throws(() => parseJourneyResponse({
caseId: "7299894c-10a8-4b45-91d1-339007282c50",
snapshot: { ...snapshot, canApply: true, activeTime: "14:24" },
questionnaire: null,
scoring: null,
}));
});
@@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import test from "node:test";
import { birthTimeAssessmentSchema } from "../src/lib/birth-time-journey.ts";
import {
createBirthTimeJourneyService,
type BirthTimeJourneyEngine,
type BirthTimeJourneyStore,
type PersistedJourneyAssessment,
type StoredRectificationCase,
} from "../src/lib/birth-time-journey-service.ts";
const hospitalAssessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "hospital_record",
reportedTime: "08:16",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location: { lat: 31.2304, lon: 121.4737, tz: 8 },
});
function scanWithSigns(signs: readonly string[]) {
const samples = signs.map((sign) => ({
ascendantSign: sign,
d9Sign: sign,
d10Sign: sign,
}));
return {
questionnaire: {
questions: [{ id: "education_environment_shift", prompt: "是否有明显学业变化?" }],
samples,
raw: { candidate_scan: { samples } },
},
};
}
function memoryStore(initialCase?: StoredRectificationCase) {
let savedAssessment: PersistedJourneyAssessment | null = null;
let savedCase = initialCase ?? null;
const store: BirthTimeJourneyStore = {
async saveAssessment(value) {
savedAssessment = value;
return "case-1";
},
async loadCase() {
return savedCase;
},
async saveScoring(value) {
savedCase = value;
},
};
return {
store,
savedAssessment: () => savedAssessment,
savedCase: () => savedCase,
};
}
test("journey service activates a stable hospital record and persists its scan", async () => {
const memory = memoryStore();
let receivedUncertainty = 0;
const engine: BirthTimeJourneyEngine = {
async scan(input) {
receivedUncertainty = input.uncertaintyMinutes;
return scanWithSigns(["Cancer", "Cancer", "Cancer"]);
},
async score() {
throw new Error("not used");
},
};
const service = createBirthTimeJourneyService({ store: memory.store, engine });
const result = await service.assess("user-1", hospitalAssessment);
assert.equal(receivedUncertainty, 2);
assert.equal(result.snapshot.route, "direct_chart");
assert.equal(result.snapshot.activeTime, "08:16");
assert.equal(result.caseId, "case-1");
assert.deepEqual(memory.savedAssessment()?.candidateScan, result.questionnaire);
});
test("journey service fails a scanner error closed without activating the time", async () => {
const memory = memoryStore();
const engine: BirthTimeJourneyEngine = {
async scan() {
throw new TypeError("scanner offline");
},
async score() {
throw new Error("not used");
},
};
const service = createBirthTimeJourneyService({ store: memory.store, engine });
const result = await service.assess("user-1", hospitalAssessment);
assert.equal(result.snapshot.route, "rectification");
assert.equal(result.snapshot.canApply, false);
assert.equal(result.questionnaire, null);
assert.equal(memory.savedAssessment()?.snapshot.activeTime, null);
});
test("journey service accumulates answers while preserving the application gate", async () => {
const approximate = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "approximate",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
location: { lat: 31.2304, lon: 121.4737, tz: 8 },
});
const questionnaire = scanWithSigns(["Cancer", "Leo", "Leo"]).questionnaire;
const initialSnapshot = createBirthTimeJourneyService({
store: memoryStore().store,
engine: {
async scan() { return { questionnaire }; },
async score() { throw new Error("not used"); },
},
});
const assessed = await initialSnapshot.assess("user-1", approximate);
const storedCase: StoredRectificationCase = {
id: "case-1",
userId: "user-1",
snapshot: assessed.snapshot,
questionnaire,
answers: { education_environment_shift: "A" },
};
const memory = memoryStore(storedCase);
let scoredAnswers: Readonly<Record<string, "A" | "B" | "C" | "D">> = {};
const engine: BirthTimeJourneyEngine = {
async scan() {
return { questionnaire };
},
async score(input) {
scoredAnswers = input.answers;
return {
answeredCount: 3,
candidateClusterRankings: [{ cluster: "middle_candidate_cluster", score: 5 }],
raw: { next_round: 2 },
};
},
};
const service = createBirthTimeJourneyService({ store: memory.store, engine });
const result = await service.answerQuestion("user-1", "case-1", "career_responsibility_pressure", "B");
assert.deepEqual(scoredAnswers, {
education_environment_shift: "A",
career_responsibility_pressure: "B",
});
assert.equal(result.snapshot.state, "candidate");
assert.equal(result.snapshot.canApply, false);
assert.deepEqual(memory.savedCase()?.answers, scoredAnswers);
});
test("journey service resumes an owner-scoped unfinished case", async () => {
const questionnaire = scanWithSigns(["Cancer", "Leo"]).questionnaire;
const storedCase: StoredRectificationCase = {
id: "case-1",
userId: "user-1",
snapshot: {
state: "rectifying",
assistantIntent: "continue_rectification_questions",
input: "rectification_questions",
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange: { label: "14:00—15:00", startTime: "14:00", endTime: "15:00" },
},
questionnaire,
answers: { education_environment_shift: "A" },
};
const service = createBirthTimeJourneyService({
store: memoryStore(storedCase).store,
engine: {
async scan() { throw new Error("not used"); },
async score() { throw new Error("not used"); },
},
});
const result = await service.resume("user-1", "case-1");
assert.equal(result.caseId, "case-1");
assert.deepEqual(result.answers, { education_environment_shift: "A" });
assert.equal(result.snapshot.canApply, false);
});
test("journey service resumes a fail-closed case without a questionnaire", async () => {
const storedCase: StoredRectificationCase = {
id: "case-2",
userId: "user-1",
snapshot: {
state: "rectifying",
assistantIntent: "explain_assessment_unavailable",
input: "rectification_questions",
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange: { label: "08:14—08:18", startTime: "08:14", endTime: "08:18" },
},
questionnaire: null,
answers: {},
};
const service = createBirthTimeJourneyService({
store: memoryStore(storedCase).store,
engine: {
async scan() { throw new Error("not used"); },
async score() { throw new Error("not used"); },
},
});
const result = await service.resume("user-1", "case-2");
assert.equal(result.questionnaire, null);
assert.equal(result.snapshot.canApply, false);
});
+155
View File
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assessBirthTime,
birthTimeAssessmentSchema,
journeySnapshotSchema,
withRectificationScoring,
} from "../src/lib/birth-time-journey.ts";
const location = { lat: 31.2304, lon: 121.4737, tz: 8 } as const;
test("birth time journey sends a stable hospital record directly to charting", () => {
const assessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "hospital_record",
reportedTime: "08:16",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location,
});
const snapshot = assessBirthTime(assessment, { kind: "stable" });
assert.equal(snapshot.state, "ready");
assert.equal(snapshot.route, "direct_chart");
assert.equal(snapshot.canApply, true);
assert.equal(snapshot.activeTime, "08:16");
assert.equal(snapshot.assistantIntent, "confirm_stable_record");
assert.equal(journeySnapshotSchema.safeParse(snapshot).success, true);
});
test("birth time journey fails a sensitive hospital record closed into rectification", () => {
const assessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "hospital_record",
reportedTime: "08:16",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location,
});
const snapshot = assessBirthTime(assessment, { kind: "sensitive" });
assert.equal(snapshot.state, "rectifying");
assert.equal(snapshot.route, "rectification");
assert.equal(snapshot.canApply, false);
assert.equal(snapshot.activeTime, null);
assert.equal(snapshot.assistantIntent, "explain_sensitive_boundary");
});
test("birth time journey fails a scanner outage closed into rectification", () => {
const assessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "hospital_record",
reportedTime: "08:16",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location,
});
const snapshot = assessBirthTime(assessment, { kind: "unavailable" });
assert.equal(snapshot.route, "rectification");
assert.equal(snapshot.canApply, false);
assert.equal(snapshot.assistantIntent, "explain_assessment_unavailable");
});
test("birth time journey routes family and approximate declarations to rectification", () => {
const family = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "family_exact",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
location,
});
const approximate = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "approximate",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
location,
});
assert.equal(assessBirthTime(family, { kind: "sensitive" }).assistantIntent, "start_light_rectification");
assert.equal(assessBirthTime(approximate, { kind: "sensitive" }).assistantIntent, "start_standard_rectification");
});
test("birth time journey accepts period-only and unknown declarations without inventing a clock time", () => {
const period = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "period_only",
period: "evening",
location,
});
const unknown = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "unknown",
clue: "家人只记得天黑以后",
location,
});
const periodSnapshot = assessBirthTime(period, { kind: "not_required" });
const unknownSnapshot = assessBirthTime(unknown, { kind: "not_required" });
assert.equal(periodSnapshot.reportedRange.label, "18:00—22:59");
assert.equal(periodSnapshot.activeTime, null);
assert.equal(periodSnapshot.assistantIntent, "start_period_rectification");
assert.equal(unknownSnapshot.reportedRange.label, "全天待确认");
assert.equal(unknownSnapshot.assistantIntent, "collect_time_clues");
});
test("birth time assessment rejects source-specific missing or invalid fields", () => {
const missingTime = birthTimeAssessmentSchema.safeParse({
date: "1993-04-17",
source: "hospital_record",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location,
});
const invalidFamilyRange = birthTimeAssessmentSchema.safeParse({
date: "1993-04-17",
source: "family_exact",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
location,
});
assert.equal(missingTime.success, false);
assert.equal(invalidFamilyRange.success, false);
});
test("rectification scoring can save a candidate but never apply an exact minute", () => {
const assessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "approximate",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
location,
});
const initial = assessBirthTime(assessment, { kind: "sensitive" });
const scored = withRectificationScoring(initial, {
answeredCount: 3,
candidateClusterRankings: [{ cluster: "middle_candidate_cluster", score: 5 }],
});
assert.equal(scored.state, "candidate");
assert.equal(scored.canApply, false);
assert.equal(scored.activeTime, null);
assert.equal(scored.assistantIntent, "present_saved_candidate_range");
});
+119
View File
@@ -0,0 +1,119 @@
import re
from pathlib import Path
MIGRATION = (
Path(__file__).resolve().parents[1]
/ "frontend"
/ "supabase"
/ "migrations"
/ "20260717020000_birth_time_journey.sql"
)
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
def _sql() -> str:
return re.sub(r"\s+", " ", MIGRATION.read_text(encoding="utf-8").lower()).strip()
def test_birth_time_profile_contract_separates_reported_and_active_times() -> None:
sql = _sql()
for definition in (
"reported_birth_time time without time zone",
"active_birth_time time without time zone",
"birth_time_source text",
"birth_time_period text",
"birth_time_clue text",
"uncertainty_before_minutes integer",
"uncertainty_after_minutes integer",
"birth_time_status text",
"rectification_confidence numeric(5, 2)",
"rectification_case_id uuid",
):
assert f"add column if not exists {definition}" in sql
assert "reported_birth_time = coalesce(reported_birth_time, birth_time)" in sql
assert "active_birth_time = coalesce(active_birth_time, birth_time)" in sql
assert "birth_time_source = coalesce(birth_time_source, 'legacy_import')" in sql
assert "birth_time_status = coalesce(birth_time_status, 'confirmed')" in sql
assert "old.reported_birth_time is not null" in sql
assert "new.reported_birth_time is distinct from old.reported_birth_time" in sql
assert "raise exception 'reported_birth_time_is_immutable'" in sql
assert "new.birth_time := new.active_birth_time" in sql
assert "revoke update (birth_time) on table public.profiles from authenticated" in sql
client_grant = sql.split("grant update ( reported_birth_time", 1)[1]
client_grant = client_grant.split(") on table public.profiles to authenticated", 1)[0]
assert "active_birth_time" not in client_grant
assert "birth_time_status" not in client_grant
assert "rectification_case_id" not in client_grant
def test_birth_time_profile_contract_constrains_deterministic_states() -> None:
sql = _sql()
for value in (
"hospital_record",
"family_exact",
"approximate",
"period_only",
"unknown",
"legacy_import",
):
assert f"'{value}'" in sql
for value in ("reported", "assessing", "rectifying", "candidate", "confirmed"):
assert f"'{value}'" in sql
assert "uncertainty_before_minutes between 0 and 720" in sql
assert "uncertainty_after_minutes between 0 and 720" in sql
assert "rectification_confidence between 0 and 100" in sql
def test_rectification_cases_are_owner_scoped_and_auditable() -> None:
sql = _sql()
assert "create table if not exists public.birth_time_rectification_cases" in sql
for definition in (
"id uuid primary key default gen_random_uuid()",
"user_id uuid not null references auth.users(id) on delete cascade",
"questionnaire jsonb not null default '{}'::jsonb",
"journey_snapshot jsonb not null default '{}'::jsonb",
"answers jsonb not null default '{}'::jsonb",
"candidate_scan jsonb not null default '{}'::jsonb",
"scoring_result jsonb not null default '{}'::jsonb",
"algorithm_version text not null default 'birth-time-journey-v1'",
"ayanamsa text not null default 'lahiri'",
"node_mode text not null default 'true'",
"confirmed_at timestamptz",
):
assert definition in sql
assert "alter table public.birth_time_rectification_cases enable row level security" in sql
assert "birth_time_cases_select_own" in sql
assert "birth_time_cases_insert_own" in sql
assert "birth_time_cases_update_own" in sql
assert "using ((select auth.uid()) = user_id)" in sql
assert "with check ((select auth.uid()) = user_id)" in sql
assert "grant select on table public.birth_time_rectification_cases to authenticated" in sql
assert "grant insert" in sql
assert "grant update" in sql
assert "grant delete" not in sql
def test_web_onboarding_uses_the_deterministic_free_journey() -> None:
page = (FRONTEND / "src" / "app" / "page.tsx").read_text(encoding="utf-8")
route = (
FRONTEND / "src" / "app" / "api" / "birth-time-journey" / "route.ts"
).read_text(encoding="utf-8")
mastra = (FRONTEND / "src" / "mastra" / "index.ts").read_text(encoding="utf-8")
assert "<BirthTimeIntakeFields" in page
assert "requestBirthTimeAssessment" in page
assert "<BirthTimeRectification" in page
assert "reported_birth_time" in page
assert "active_birth_time" in page
assert "birthTimeStatus" in page
assert "consultation-billing" not in route
assert 'entry_mode: entryMode' in mastra
assert 'entry_mode: "direct_chart"' not in mastra