diff --git a/docs/superpowers/plans/2026-07-17-birth-time-journey.md b/docs/superpowers/plans/2026-07-17-birth-time-journey.md new file mode 100644 index 00000000..97ecdb05 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-birth-time-journey.md @@ -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. diff --git a/docs/superpowers/specs/2026-07-17-birth-time-journey-design.md b/docs/superpowers/specs/2026-07-17-birth-time-journey-design.md new file mode 100644 index 00000000..29332064 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-birth-time-journey-design.md @@ -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. diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index e2b0f530..f73c1cc4 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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. diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts new file mode 100644 index 00000000..d87f1f85 --- /dev/null +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -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 { + try { + return await request.json(); + } catch (error) { + if (error instanceof SyntaxError) return null; + throw error; + } +} + +export async function POST(request: Request) { + let supabase: Awaited>; + let journeyStoreClient: ReturnType; + 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; + } +} diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index 8a1dc487..12fe2a9a 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -44,7 +44,8 @@ function hasCompleteBirthProfile(profile: Record) { 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(); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1a6a5f2f..1046f45f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index d779ead4..fa6e0911 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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; 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 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 & { 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 ( -
- - -
- ); -} - 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 如何称呼你 onChange({ ...value, name: event.target.value })} /> - + onChange({ ...value, ...patch })} /> ); @@ -470,6 +543,10 @@ export default function Home() { const [onboardingError, setOnboardingError] = useState(""); const [onboardingStep, setOnboardingStep] = useState("name"); const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false); + const [birthTimeJourney, setBirthTimeJourney] = useState(null); + const [birthTimeAnswers, setBirthTimeAnswers] = useState>>({}); + const [birthTimeQuestionPending, setBirthTimeQuestionPending] = useState(""); + const [birthTimeError, setBirthTimeError] = useState(""); const [startGreeting, setStartGreeting] = useState(""); const [presetMessageLength, setPresetMessageLength] = useState(0); const conversationEnd = useRef(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) { 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) { 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) { 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() {
{activeSession?.title || "新对话"} - {isLoading ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") : "基于星盘证据回答"} + {isLoading + ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") + : !profileComplete && onboardingStep === "rectification" + ? "正在校正出生时间" + : "基于星盘证据回答"}
+
@@ -1655,6 +1834,28 @@ export default function Home() { )} + {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && ( +
+
+ void saveBirthTimeAnswer(questionId, answer)} + /> +
+
+ )} + + {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && ( +
+ 出生时间尚未完成评估 +

{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}

+ +
+ )} + {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} {profileComplete && presetMessageFinished && (onboardingPending ? ( @@ -1748,7 +1949,7 @@ export default function Home() {