diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 8345ffc8..16d275d9 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -71,7 +71,7 @@ jobs: - name: Verify production if: steps.revision.outputs.deploy == 'true' env: - DEPLOY_GIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} + DEPLOY_GIT_SHA: ${{ github.sha }} run: | curl --fail --silent --show-error --retry 12 --retry-delay 5 https://jyotisha.chat/login >/dev/null test "$(curl --silent --output /dev/null --write-out '%{http_code}' https://jyotisha.chat/api/account)" = "401" diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index f4f99f45..3d11bde0 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -1,75 +1,34 @@ -# Task 1 — Dynamic Choice Contracts and Stop Policy +# Task 1 Report: Chat deletion and browser transport errors ## Implementation -- Added browser-safe dynamic choice and time-range Zod schemas. Public question parsing is strict and rejects hidden partition fields. -- Added internal-only dynamic choice contracts, persisted/private question schemas, candidate-difference packet schemas, and an explicit public projection helper. -- Added pure deterministic stop policy with the specified precedence and a material-change calculation for candidate range, representative time, and two-point margin changes. -- Added separate `DynamicNextAction` and `DynamicJourneyProgress` schemas, preserving the legacy guided-v1 `NextAction` and `JourneyProgress` parser path. -- Kept the internal contract module dependency-free as resolved by the user. A source-contract test scans components, hooks, client transports, and response schemas to prohibit imports of the private module. -- Dynamic IDs are opaque nonempty server-issued strings, rather than being overconstrained to UUIDs. +- Added the owner-only `chat_sessions` DELETE policy and authenticated DELETE grant. +- Classified both native `SyntaxError` and WebKit `DOMException` values named `SyntaxError` as JSON parse/lost-response errors. +- Non-OK responses with malformed JSON now return `payload: null`; retry logic recognizes the WebKit error form. -## Files changed +## Files -- `frontend/src/lib/birth-time-dynamic-choice.ts` -- `frontend/src/lib/birth-time-dynamic-choice-internal.ts` -- `frontend/src/lib/birth-time-dynamic-stop-policy.ts` -- `frontend/src/lib/birth-time-journey-turn-protocol.ts` -- `frontend/src/lib/birth-time-journey-turn.ts` -- `frontend/tests/birth-time-dynamic-choice.test.ts` -- `frontend/tests/birth-time-dynamic-stop-policy.test.ts` +- `frontend/supabase/migrations/20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql` +- `frontend/tests/chat-session-delete-contract.test.ts` +- `frontend/tests/birth-time-client-transport.test.ts` +- `frontend/src/lib/birth-time-client-transport.ts` -## RED +## TDD evidence -1. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-choice.test.ts` - - Failed as expected before the public contract existed: `ERR_MODULE_NOT_FOUND` for `birth-time-dynamic-choice.ts`. -2. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-stop-policy.test.ts` - - Failed as expected before the policy existed: `ERR_MODULE_NOT_FOUND` for `birth-time-dynamic-stop-policy.ts`. -3. After the boundary resolution, the dynamic choice test failed as expected while the obsolete `server-only` marker remained: `ERR_MODULE_NOT_FOUND: Cannot find package 'server-only'`. -4. The opaque-ID regression initially failed because the first implementation required UUIDs. +- RED: `node --import ./frontend/node_modules/tsx/dist/loader.mjs --test frontend/tests/chat-session-delete-contract.test.ts frontend/tests/birth-time-client-transport.test.ts` failed as expected: the migration file was absent and `DOMException("SyntaxError")` escaped; the native non-JSON case already passed. +- GREEN: the same command passed all 3 tests after the minimal implementation. -## GREEN +## Verification -1. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-choice.test.ts tests/birth-time-dynamic-stop-policy.test.ts tests/birth-time-journey-turn.test.ts` - - `14` passed, `0` failed. -2. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-*.test.ts` - - `194` passed, `0` failed, duration `1449ms`. -3. `git diff --check` - - Passed with no whitespace errors. +- Focused suite: 3 passed, 0 failed. +- Full frontend suite: `node --import ./frontend/node_modules/tsx/dist/loader.mjs --test frontend/tests/*.test.ts` completed with 461 passed and 1 failed (462 total). +- The sole failure is the known baseline in `frontend/tests/health-deployment.test.ts`: its expected `DEPLOY_GIT_SHA` expression differs from the existing deployment workflow. It is outside Task 1 scope. +- Self-review: inspected the migration against existing owner-scoped RLS patterns, reviewed the four-file diff, and ran `git diff --check` successfully. -## Self-review +## Commit -- Public choices are strict, require 2–4 primary options plus exactly one unknown and one unmatched option, reject duplicate IDs, cap labels at 80 characters, and reject private fields. -- Persisted primary choices require nonempty partitions and finite score maps. Unknown/unmatched choices require both private fields to be `null`. -- The public projection parses through the public schema, so partition IDs and candidate scores cannot cross the browser boundary. -- Stop ordering is high confidence, effective-answer safety cap, plateau, no information gain, repeated partition, then continue. Non-effective answers retain the prior plateau count. -- Legacy schemas and turn behavior remain unchanged; v2 schemas use distinct dynamic names and are re-exported from the turn module. -- All created/modified source files are within the 250 pure-LOC threshold (largest: `birth-time-journey-turn.ts`, 229 lines; new internal contract, 208 lines). +- Implementation: `4ffebdd fix: close chat deletion and transport errors` ## Concerns -- Full `tsc --noEmit --incremental false` remains blocked by an unrelated existing error in `frontend/tests/profile-persistence.test.ts:7`: the project targets ES2017 while that test uses an ES2018 regular-expression flag. None of the Task 1 files produced a TypeScript error. -- The supplied no-excuse checker could not run because it is outside the frontend dependency tree and cannot resolve its own `typescript` package. The focused runtime suite, full birth-time suite, diff check, and manual forbidden-pattern scan completed successfully. - -## Review fixes - -- `DynamicStopInput.result` is now nullable, so a dynamic flow can finish before its first score. It also carries the explicit `forcedReason` union: `user_finished`, `generation_unavailable`, or `null`. -- Forced terminal reasons now win over every score-derived condition. A null result preserves the current plateau count instead of attempting score comparison. -- Added and re-exported `dynamicJourneyTurnStateSchema` / `DynamicJourneyTurnState`. The schema is strict and explicitly requires `journeyProtocol: "dynamic-choice-v2"`, a nonnegative turn version, a dynamic action, dynamic progress, and the existing permissions shape. The legacy `journeyTurnStateSchema` is unchanged. -- Added regressions for both forced terminal reasons, their high-confidence precedence, the dynamic discriminator, and rejection of a valid legacy action under the v2 schema. - -### Review RED - -`/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-choice.test.ts tests/birth-time-dynamic-stop-policy.test.ts` - -- Failed before implementation because `dynamicJourneyTurnStateSchema` was not exported. -- Existing stop policy threw on `result: null` and returned `high_confidence` instead of the forced `user_finished` reason. - -### Review GREEN - -1. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-choice.test.ts tests/birth-time-dynamic-stop-policy.test.ts tests/birth-time-journey-turn.test.ts` - - `16` passed, `0` failed. -2. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-*.test.ts` - - `196` passed, `0` failed, duration `1472ms`. -3. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node ./node_modules/typescript/bin/tsc --noEmit --incremental false` - - Still reports only the existing `tests/profile-persistence.test.ts:7` ES2018-regexp/ES2017-target incompatibility; no Task 1 diagnostic was emitted. +- The repository pre-work gate remains blocked by its documented host Python 3.9/fragment-scan baseline; it did not affect this frontend-only task. diff --git a/deploy/Caddyfile b/deploy/Caddyfile index a686b6fe..baca242f 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,6 +1,9 @@ {$SITE_ADDRESS:https://jyotisha.chat} { encode zstd gzip - reverse_proxy web:3000 + reverse_proxy web:3000 { + lb_try_duration 10s + lb_try_interval 250ms + } } www.jyotisha.chat { diff --git a/deploy/README.md b/deploy/README.md index 704e59a5..49a40b74 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -72,6 +72,16 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=... SUPABASE_SERVICE_ROLE_KEY=... ADMIN_EMAILS=... +# Conversational birth-time rectification rollout controls. +# Keep migrations false until the ordered database gate below has passed. +RECTIFICATION_PRICE_CREDITS=3 +RECTIFICATION_V3_CREATE_ENABLED=true +RECTIFICATION_V3_MIGRATIONS_READY=false +# Set only after the authenticated synthetic smoke passes on this exact image. +RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA= +# During canary only: one canonical synthetic account UUID. Never print or log it. +RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS= + # Recommended multi-model catalog. The JSON references server-only keys. LLM_DEFAULT_MODEL_ID=deepseek-pro LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]' @@ -349,6 +359,98 @@ Editor shows `You do not have access to this project`, use the correct Supabase organization account or invite the current GitHub user to project `vtvnfqmonbfuxmqkqdlc` before retrying. +## Conversational birth-time rectification v3 rollout + +`conversational-evidence-v3` is an account-level workflow. A web-image rollout +does not prove its database contract is present. Apply migrations before the +web image, in this order: + +1. `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql` +2. `20260720010000_conversational_rectification_schema.sql` +3. `20260720020000_conversational_rectification_billing.sql` +4. `20260720030000_conversational_rectification_transitions.sql` +5. `20260720040000_rectification_question_handoff.sql` +6. `20260721010000_conversational_legacy_import_projection.sql` + +Run `cd frontend && npx supabase db push --linked` with the authorized project +account. Verify the linked migration ledger contains all six versions. Do not +print the database URL or any service-role credential. Then set +`RECTIFICATION_V3_MIGRATIONS_READY=true`, keep +`RECTIFICATION_V3_CREATE_ENABLED=true`, set +`RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS` to exactly one canonical UUID for +the synthetic account, leave `RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` empty, and +deploy the tested Git revision. Never print, log, copy into a ticket, or return +that UUID from health or telemetry. Creation is available only for the +allowlisted smoke account; ordinary authenticated users can still resume and +finish existing cases but cannot start a paid or legacy-imported case. + +Before the smoke, fetch `https://jyotisha.chat/api/health` and verify the full +deployment SHA, healthy dependencies, enabled creation, ready migrations, +`creationAudience: smoke_only`, `syntheticSmoke: pending`, and +`readyForNewCases: false`. A missing, abbreviated, malformed, or +previous-revision smoke SHA must remain pending. If the create flag, migration +flag, deployment SHA, or strict UUID allowlist is invalid, creation audience +must be `paused`, including for the smoke account. + +After the smoke sequence below passes, set +`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` to the exact deployed 40-character +lowercase Git SHA, remove `RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, and +restart the web container. Then fetch health again and +verify all of the following against the revision that passed validation: + +- `deployment.gitCommit` exactly equals the tested 40-character Git SHA; +- `rollout.conversationalRectificationV3.protocol` is + `conversational-evidence-v3`; +- `newCaseCreation` and `migrations` are `enabled` and `ready`; +- `creationAudience` is `public`; +- `syntheticSmoke` is `matched`; +- `readyForNewCases` is `true`; +- ordinary health checks remain healthy. The health response must never contain + environment values or credentials. + +Using an authorized synthetic account with no real birth data, run this smoke +sequence. A plain HTTP `200` is not substitute evidence: + +1. Finish onboarding without rectification. Verify an unverified reported time + offers current-chat consent or `先校正再询问`. +2. Save a synthetic ordinary question and start v3. Verify one fixed fee and a + rich first turn containing the candidate boundary, stable/sensitive layers, + domain rationale, and a dated historical-event request. +3. Answer with one explicit event, choose `都不符合`, submit one ambiguous + event, then a clear event. Verify the ambiguous/future facts do not score. +4. Pause, reload, and resume from a second authenticated browser session. + Verify no second rectification charge. +5. Reach a candidate, verify the prior active time is still in force, reject a + mismatched candidate confirmation, then explicitly confirm the exact + candidate. Verify the time changes atomically. +6. Explicitly continue the saved ordinary question. Verify one normal + consultation reservation. Delete its chat and verify the account case still + resumes/loads. +7. For an unfinished legacy case, verify exactly one + `migration_waived` import, unchanged history, and no broad-year questionnaire. +8. Inject one transient 502. Verify byte-identical retry and stable Chinese + fallback, never raw browser English. + +Record only protocol, phase, action kind, result category, latency bucket, +billing state, error category, and deployment SHA. Narrative, event text, birth +data, email, user/user-case identifiers, tokens, and model prompts are forbidden +from telemetry. + +### Rollback + +Rollback is forward-compatible and non-destructive. First set +`RECTIFICATION_V3_CREATE_ENABLED=false`, clear +`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` and +`RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, and redeploy a revision that can still +read/resume v3. Health must report `newCaseCreation: paused`. This stops only +new v3 starts: keep reads, resume, answer, pause, confirmation, and saved-question +handoff available for existing cases. Never reverse or delete the v3 migrations, +rows, turns, evidence, receipts, or legacy import links. Never point an imported +case back to mutable legacy history. A revision in progress keeps the account's +prior active time until its exact atomic confirmation succeeds. If no compatible +reader is available, leave the current image serving existing cases and disable +only creation; do not deploy an older schema consumer. + ## Common operations ```bash diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index 9e660a16..614d2091 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -35,6 +35,13 @@ services: JYOTISH_API_BASE: http://api:5200 expose: - "3000" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)})"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + start_interval: 1s depends_on: api: condition: service_healthy @@ -53,7 +60,8 @@ services: - caddy_data:/data - caddy_config:/config depends_on: - - web + web: + condition: service_healthy volumes: caddy_data: diff --git a/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md b/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md new file mode 100644 index 00000000..873d5288 --- /dev/null +++ b/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md @@ -0,0 +1,30 @@ +# User-reported birth-time flow issues — 2026-07-20 + +This ledger records the five reported product failures and the evidence needed +to close them. It contains no copied authentication header, cookie, token, +email, user UUID, or real birth record. The original plan described this file as +an existing modification target; it did not exist in this checkout, so Task 12 +created it. + +`verified-local` means deterministic contract tests and the equivalent local +PostgreSQL 14 workflow pass. It is deliberately not `closed`: closure also +requires an authenticated synthetic production smoke whose health Git SHA is +the tested deployment SHA. + +| Issue | Reported failure | Current status | Local evidence | Production closure artifact | +| --- | --- | --- | --- | --- | +| ISSUE-BT-001 | A chat appeared impossible to delete, or a late response could recreate it. | verified-local | `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`; `frontend/tests/chat-session-delete-contract.test.ts`; the PG14 full-flow test deletes a real RLS-owned chat as `authenticated` and proves the account case remains. | Authenticated synthetic delete plus account-case reload, tied to `/api/health` deployment SHA. | +| ISSUE-BT-002 | A new chat could not establish a fresh rectification interaction and unfinished progress was coupled to chat state. | verified-local | `20260720010000_conversational_rectification_schema.sql`; account-level resume in `frontend/tests/conversational-rectification-e2e.test.ts` across two route clients; Task 9 current-chat consent tests. | Authenticated new-device/new-chat resume smoke tied to the deployed SHA. | +| ISSUE-BT-003 | Confirming a candidate such as `17:15` surfaced `The string did not match the expected pattern`. | verified-local | Atomic v3 confirmation in `20260720030000_conversational_rectification_transitions.sql`; client retry/fallback and mismatched-then-exact confirmation in `frontend/tests/conversational-rectification-e2e.test.ts`; the PG14 full-flow test rejects `05:20`, confirms exact `05:21`, and proves the old time survives until commit. | Authenticated production exact-candidate confirmation with old-time preservation, plus transient deployment-error probe. | +| ISSUE-BT-004 | Choosing `都不符合` surfaced the same raw English pattern error. | verified-local | The actual orchestrator treats `都不符合` as a normal direction change; Task 12 E2E advances the durable turn and preserves the single fee; client maps terminal 502/non-JSON failures to stable Chinese copy. | Authenticated production `都不符合` action followed by reload/resume, tied to the deployed SHA. | +| ISSUE-BT-005 | Initialization used generic broad-year choices and lost the rich card/chat rectification analysis. | verified-local | Task 9 onboarding soft gate; v3 narrative grounding rejects broad-year questionnaires; Task 12 asserts candidate boundary, D1/D9/D10 layers, three domain rationales, free text, and year/month event request; PG14 proves future background persists without scoring and the legacy suite imports old unfinished work once with `migration_waived`. | Authenticated synthetic first-turn snapshot and one legacy import smoke tied to the deployed SHA. | + +## Release decision + +All five issues remain `verified-local` until the production closure artifacts +above are attached. A public 200 response, an unverified browser session, or a +local in-memory test cannot change them to `closed`. The deployment sequence and +non-destructive rollback are defined in `deploy/README.md`. The executable +creation policy keeps rollout `smoke_only` for one unlogged synthetic account +until the smoke SHA matches the exact deployed revision; ordinary users cannot +incur a new rectification charge during that canary window. diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index e4da60b5..74c81471 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -1,4 +1,13 @@ import { NextResponse } from "next/server"; +import { + parseRectificationPriceCredits, +} from "@/lib/birth-time-consultation-consent"; +import { resolveAccountRectificationCase } from "@/lib/account-rectification-case"; +import { + accountProfilePatchSchema, + applyAccountProfileConcurrencyGuards, + resolveAccountBirthTimeApplicationPatch, +} from "@/lib/account-profile-patch"; import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin"; import { isSupabaseConfigurationError, @@ -7,43 +16,7 @@ import { createServerSupabaseClient } from "@/lib/supabase/server"; export const runtime = "nodejs"; -type ProfilePatchPayload = { - name?: unknown; - birth_date?: unknown; - birth_time?: unknown; - reported_birth_time?: unknown; - birth_time_source?: unknown; - birth_time_period?: unknown; - birth_time_clue?: unknown; - uncertainty_before_minutes?: unknown; - uncertainty_after_minutes?: unknown; - country_code?: unknown; - province_code?: unknown; - city_code?: unknown; - district_code?: unknown; - latitude?: unknown; - longitude?: unknown; - timezone_offset?: unknown; -}; - -const birthTimeSources = ["hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"] as const; -const birthTimePeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const; - -function nullableString(value: unknown) { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function nullableNumber(value: unknown) { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -function nullableInteger(value: unknown) { - return typeof value === "number" && Number.isInteger(value) ? value : null; -} - -function nullableChoice(value: unknown, choices: readonly string[]) { - return typeof value === "string" && choices.includes(value) ? value : null; -} +const unfinishedRectificationStatuses = ["starting", "active", "paused", "confirming"] as const; function isMissingProfileColumn(error: { code?: string; message?: string } | null) { const message = error?.message?.toLowerCase() ?? ""; @@ -61,21 +34,49 @@ export async function GET() { if (authError || !user) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } + const userId = user.id; + const rectificationPriceCredits = parseRectificationPriceCredits( + process.env.RECTIFICATION_PRICE_CREDITS, + ); + const admin = createAdminSupabaseClient(); + const { data: rectificationCaseRows, error: rectificationCaseError } = await admin + .from("birth_time_rectification_cases") + .select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at") + .eq("user_id", user.id) + .eq("journey_protocol", "conversational-evidence-v3") + .in("status", [...unfinishedRectificationStatuses]) + .order("updated_at", { ascending: false }) + .limit(50); + if (rectificationCaseError) { + return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 }); + } + + // Read the profile after the case snapshot. If a declaration edit races + // with this request, matching uses the later profile and cannot resurrect + // an older case. A concurrently created case simply appears on refresh. const { data: profile, error } = await supabase .from("profiles") - .select("credits") - .eq("id", user.id) + .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") + .eq("id", userId) .single(); if (error) { return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 }); } + const rectificationCase = resolveAccountRectificationCase( + profile, + Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [], + ); return NextResponse.json({ user: { id: user.id, email: user.email ?? null }, credits: profile.credits, isAdmin: isAdminEmail(user.email), + rectificationPriceCredits, + hasConfirmedBirthTime: profile.birth_time_status === "confirmed" + && typeof profile.active_birth_time === "string", + rectificationCase, }); } catch (error) { if (isSupabaseConfigurationError(error)) { @@ -93,52 +94,108 @@ export async function PATCH(request: Request) { if (authError || !user) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } + const userId = user.id; - const payload = await request.json().catch(() => null) as ProfilePatchPayload | null; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return NextResponse.json({ error: "账户资料格式不正确" }, { status: 400 }); - } + const parsedPayload = accountProfilePatchSchema.safeParse( + await request.json().catch(() => null), + ); + if (!parsedPayload.success) return NextResponse.json({ + error: "账户资料格式不正确", + details: parsedPayload.error.flatten(), + }, { status: 400 }); + const payload = parsedPayload.data; const admin = createAdminSupabaseClient(); + let { data: currentProfile, error: currentProfileError } = await admin + .from("profiles") + .select("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,latitude,longitude,timezone_offset") + .eq("id", userId) + .maybeSingle(); + if (currentProfileError && isMissingProfileColumn(currentProfileError)) { + const fallback = await admin + .from("profiles") + .select("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", userId) + .maybeSingle(); + currentProfile = fallback.data ? { + ...fallback.data, + latitude: undefined, + longitude: undefined, + timezone_offset: undefined, + } : null; + currentProfileError = fallback.error; + } + if (currentProfileError) { + return NextResponse.json({ error: "暂时无法核对现有出生资料" }, { status: 500 }); + } + const applicationPatch = currentProfile + ? resolveAccountBirthTimeApplicationPatch(currentProfile, payload) + : {}; const baseProfile = { - id: user.id, - name: nullableString(payload.name), - birth_date: nullableString(payload.birth_date), - birth_time: nullableString(payload.birth_time), - reported_birth_time: nullableString(payload.reported_birth_time), - birth_time_source: nullableChoice(payload.birth_time_source, birthTimeSources), - birth_time_period: nullableChoice(payload.birth_time_period, birthTimePeriods), - birth_time_clue: nullableString(payload.birth_time_clue), - uncertainty_before_minutes: nullableInteger(payload.uncertainty_before_minutes), - uncertainty_after_minutes: nullableInteger(payload.uncertainty_after_minutes), - country_code: nullableString(payload.country_code), - province_code: nullableString(payload.province_code), - city_code: nullableString(payload.city_code), - district_code: nullableString(payload.district_code), + id: userId, + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.birth_date !== undefined ? { birth_date: payload.birth_date } : {}), + ...(payload.reported_birth_time !== undefined + ? { reported_birth_time: payload.reported_birth_time } + : {}), + ...(payload.birth_time_source !== undefined + ? { birth_time_source: payload.birth_time_source } + : {}), + ...(payload.birth_time_period !== undefined + ? { birth_time_period: payload.birth_time_period } + : {}), + ...(payload.birth_time_clue !== undefined + ? { birth_time_clue: payload.birth_time_clue } + : {}), + ...(payload.uncertainty_before_minutes !== undefined + ? { uncertainty_before_minutes: payload.uncertainty_before_minutes } + : {}), + ...(payload.uncertainty_after_minutes !== undefined + ? { uncertainty_after_minutes: payload.uncertainty_after_minutes } + : {}), + ...(payload.country_code !== undefined ? { country_code: payload.country_code } : {}), + ...(payload.province_code !== undefined ? { province_code: payload.province_code } : {}), + ...(payload.city_code !== undefined ? { city_code: payload.city_code } : {}), + ...(payload.district_code !== undefined ? { district_code: payload.district_code } : {}), + ...applicationPatch, updated_at: new Date().toISOString(), }; const withCoordinates = { ...baseProfile, - latitude: nullableNumber(payload.latitude), - longitude: nullableNumber(payload.longitude), - timezone_offset: nullableNumber(payload.timezone_offset), + ...(payload.latitude !== undefined ? { latitude: payload.latitude } : {}), + ...(payload.longitude !== undefined ? { longitude: payload.longitude } : {}), + ...(payload.timezone_offset !== undefined ? { timezone_offset: payload.timezone_offset } : {}), }; const withoutCoordinates = baseProfile; - let { data, error } = await admin - .from("profiles") - .upsert(withCoordinates, { onConflict: "id" }) - .select("id") - .single(); + const invalidatesUnconfirmedApplication = Object.keys(applicationPatch).length > 0; + async function writeProfile(values: Record) { + if (!currentProfile) { + return admin + .from("profiles") + .upsert(values, { onConflict: "id" }) + .select("id") + .maybeSingle(); + } + let query = admin.from("profiles").update(values).eq("id", userId); + if (invalidatesUnconfirmedApplication) { + query = applyAccountProfileConcurrencyGuards(query, currentProfile); + } + return query.select("id").maybeSingle(); + } + + let { data, error } = await writeProfile(withCoordinates); if (error && isMissingProfileColumn(error)) { - const fallback = await admin - .from("profiles") - .upsert(withoutCoordinates, { onConflict: "id" }) - .select("id") - .single(); + const fallback = await writeProfile(withoutCoordinates); data = fallback.data; error = fallback.error; } + if (!error && !data && invalidatesUnconfirmedApplication) { + return NextResponse.json({ + error: "出生时间状态已经变化", + message: "最新确认结果已保留,请刷新后重新编辑。", + }, { status: 409 }); + } if (error || !data) { return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 }); } diff --git a/frontend/src/app/api/birth-time-candidate-completion/route.ts b/frontend/src/app/api/birth-time-candidate-completion/route.ts index d86196bb..4452fe57 100644 --- a/frontend/src/app/api/birth-time-candidate-completion/route.ts +++ b/frontend/src/app/api/birth-time-candidate-completion/route.ts @@ -29,7 +29,7 @@ export async function POST(request: Request) { const admin = createAdminSupabaseClient(); const { data: stored, error: caseError } = await admin .from("birth_time_rectification_cases") - .select("id,user_id,status,candidate_result_id,candidate_result,turn_state") + .select("id,user_id,journey_protocol,status,candidate_result_id,candidate_result,turn_state") .eq("id", parsed.data.caseId) .eq("user_id", user.id) .maybeSingle(); diff --git a/frontend/src/app/api/birth-time-conversation/handoff/route.ts b/frontend/src/app/api/birth-time-conversation/handoff/route.ts new file mode 100644 index 00000000..7039e2df --- /dev/null +++ b/frontend/src/app/api/birth-time-conversation/handoff/route.ts @@ -0,0 +1,44 @@ +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { createRectificationHandoffService } from "@/lib/rectification-handoff-service"; +import { + createRectificationHandoffHandlers, + type RectificationHandoffRouteDependencies, +} from "@/lib/rectification-handoff-route"; + +export const runtime = "nodejs"; + +const productionDependencies: RectificationHandoffRouteDependencies = { + async authenticate() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + return error || !user ? null : { userId: user.id }; + }, + service() { + return createRectificationHandoffService(createAdminSupabaseClient()); + }, +}; + +const handlers = createRectificationHandoffHandlers(productionDependencies); + +export async function GET() { + try { + return await handlers.get(); + } catch { + return Response.json( + { code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" }, + { status: 503 }, + ); + } +} + +export async function POST(request: Request) { + try { + return await handlers.post(request); + } catch { + return Response.json( + { code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" }, + { status: 503 }, + ); + } +} diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts new file mode 100644 index 00000000..6949651e --- /dev/null +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -0,0 +1,803 @@ +import { randomUUID } from "node:crypto"; +import { + conversationalRectificationCommandSchema, + type ConversationalRectificationCommand, + type ConversationalRectificationTurn, +} from "../../../lib/conversational-rectification/contracts.ts"; +import { + ConversationalRectificationError, + toConversationalRectificationPublicError, +} from "../../../lib/conversational-rectification/errors.ts"; +import { + createConversationalRectificationService, + conversationalRectificationTelemetryOutcome, + evidencePredatesBirthDate, + type ConversationalRectificationPacketBuildInput, + type ConversationalRectificationService, +} from "../../../lib/conversational-rectification/orchestrator.ts"; +import { + declaredBirthInputSchema, + type DeclaredBirthInput, + type LifeEventEvidence, +} from "../../../lib/conversational-rectification/persistence-contracts.ts"; +import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts"; +import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts"; +import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts"; +import { conversationalRectificationCreationPolicyFromEnvironment } from "../../../lib/conversational-rectification/creation-policy.ts"; +import { + conversationalRectificationLatencyBucket, + createConversationalRectificationTelemetry, + recordConversationalRectificationTelemetry, + safeConversationalRectificationDeploymentSha, + type ConversationalRectificationTelemetryPayload, + type ConversationalRectificationTelemetrySink, +} from "../../../lib/birth-time-journey-telemetry.ts"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type AuthenticatedRequest = Readonly<{ + userId: string; + context: unknown; +}>; + +export type BirthTimeConversationRouteService = ConversationalRectificationService; + +export type BirthTimeConversationRouteLog = Readonly<{ + code: string; +}>; + +export type BirthTimeConversationPostDependencies = Readonly<{ + authenticate(request: Request): Promise; + createService(authenticated: AuthenticatedRequest): Promise; + createRequestId?(request: Request): string; + log?(entry: BirthTimeConversationRouteLog): void; + telemetry?: ConversationalRectificationTelemetrySink; + deploymentSha?: string; + now?(): number; +}>; + +type ProfileQueryResult = Readonly<{ + data: unknown; + error: unknown; +}>; + +type ProfileClient = { + from(table: string): { + select(columns: string): { + eq(column: string, value: string): { + maybeSingle(): PromiseLike; + }; + }; + }; +}; + +async function authenticateProductionRequest(): Promise { + const { createServerSupabaseClient } = await import("../../../lib/supabase/server.ts"); + const serverClient = await createServerSupabaseClient(); + const { data: { user }, error } = await serverClient.auth.getUser(); + if (error || !user) return null; + return { userId: user.id, context: serverClient }; +} + +async function requestPayload(request: Request): Promise { + try { + return await request.json(); + } catch (error) { + if (error instanceof SyntaxError) return null; + throw error; + } +} + +function profileRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function integer(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) ? value : null; +} + +function declaredBirthInputFromProfile(value: unknown): DeclaredBirthInput { + const profile = profileRecord(value); + if (!profile) throw new ConversationalRectificationError("profile_incomplete"); + const birthDate = text(profile.birth_date); + const source = text(profile.birth_time_source); + const cityCode = text(profile.city_code); + const latitude = finiteNumber(profile.latitude); + const longitude = finiteNumber(profile.longitude); + const timezoneOffset = finiteNumber(profile.timezone_offset); + if (!birthDate || !source || !cityCode || latitude === null || longitude === null + || timezoneOffset === null) { + throw new ConversationalRectificationError("profile_incomplete"); + } + const birthplace = { + ...(text(profile.country_code) ? { countryCode: text(profile.country_code) } : {}), + ...(text(profile.province_code) ? { provinceCode: text(profile.province_code) } : {}), + cityCode, + ...(text(profile.district_code) ? { districtCode: text(profile.district_code) } : {}), + latitude, + longitude, + timezoneOffset, + }; + const common = { + birthDate, + birthTimeClue: text(profile.birth_time_clue), + birthplace, + }; + const reportedTime = text(profile.reported_birth_time)?.slice(0, 5) ?? null; + const period = text(profile.birth_time_period); + const before = integer(profile.uncertainty_before_minutes); + const after = integer(profile.uncertainty_after_minutes); + let declaredBirthInput: unknown; + switch (source) { + case "hospital_record": + declaredBirthInput = { + ...common, source, reportedTime, + uncertaintyBeforeMinutes: 2, uncertaintyAfterMinutes: 2, + }; + break; + case "family_exact": + case "approximate": + declaredBirthInput = { + ...common, source, reportedTime, + uncertaintyBeforeMinutes: before, uncertaintyAfterMinutes: after, + }; + break; + case "period_only": + declaredBirthInput = { ...common, source, reportedPeriod: period }; + break; + case "unknown": + declaredBirthInput = { ...common, source }; + break; + case "legacy_import": + declaredBirthInput = { + ...common, + source, + ...(reportedTime ? { reportedTime } : {}), + ...(period ? { reportedPeriod: period } : {}), + ...(before === null ? {} : { uncertaintyBeforeMinutes: before }), + ...(after === null ? {} : { uncertaintyAfterMinutes: after }), + }; + break; + default: + throw new ConversationalRectificationError("profile_incomplete"); + } + const parsed = declaredBirthInputSchema.safeParse(declaredBirthInput); + if (!parsed.success) throw new ConversationalRectificationError("profile_incomplete"); + return parsed.data; +} + +export function declaredBirthInputForLegacyCase( + currentProfileValue: unknown, + legacyCaseValue: unknown, +): DeclaredBirthInput { + const currentProfile = profileRecord(currentProfileValue); + const legacyCase = profileRecord(legacyCaseValue); + if (!currentProfile || !legacyCase) { + throw new ConversationalRectificationError("profile_incomplete"); + } + return declaredBirthInputFromProfile({ + ...currentProfile, + birth_date: legacyCase.reported_date, + reported_birth_time: legacyCase.reported_time, + birth_time_source: legacyCase.source, + birth_time_period: legacyCase.reported_period, + uncertainty_before_minutes: legacyCase.uncertainty_before_minutes, + uncertainty_after_minutes: legacyCase.uncertainty_after_minutes, + }); +} + +export type ProductionConversationalRectificationProfileDependencies = Readonly<{ + loadProfile(userId: string): Promise; + loadRectificationCase(userId: string, caseId: string): Promise; +}>; + +export async function loadProductionConversationalRectificationProfile( + dependencies: ProductionConversationalRectificationProfileDependencies, + userId: string, +): Promise> { + const profileValue = await dependencies.loadProfile(userId); + const profile = profileRecord(profileValue); + if (!profile) throw new ConversationalRectificationError("profile_incomplete"); + const declaredBirthInput = declaredBirthInputFromProfile(profile); + const priorCaseId = text(profile.rectification_case_id); + if (!priorCaseId) return { + declaredBirthInput, + revisionOfCaseId: null, + legacyCaseId: null, + }; + + const prior = profileRecord(await dependencies.loadRectificationCase(userId, priorCaseId)); + const terminalV3Revision = prior + && text(prior.id) === priorCaseId + && text(prior.journey_protocol) === "conversational-evidence-v3" + && (text(prior.status) === "completed" || text(prior.status) === "abandoned"); + const protocol = prior ? text(prior.journey_protocol) : null; + const status = prior ? text(prior.status) : null; + const unfinishedLegacyStatuses = new Set([ + "assessing", + "rectifying", + "candidate", + "confirming", + ]); + const unfinishedLegacy = prior + && text(prior.id) === priorCaseId + && (protocol === "legacy-guided-v1" || protocol === "dynamic-choice-v2") + && status !== null + && unfinishedLegacyStatuses.has(status); + return { + declaredBirthInput, + revisionOfCaseId: terminalV3Revision ? priorCaseId : null, + legacyCaseId: unfinishedLegacy ? priorCaseId : null, + }; +} + +function priceCredits(): number { + const raw = process.env.RECTIFICATION_PRICE_CREDITS?.trim() ?? "1"; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 1 || value > 100) { + throw new ConversationalRectificationError("service_unavailable"); + } + return value; +} + +function minute(value: string): number { + const [hour = 0, part = 0] = value.split(":").map(Number); + return hour * 60 + part; +} + +function clock(value: number): string { + const normalized = ((value % 1_440) + 1_440) % 1_440; + return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`; +} + +function declaredRange(input: DeclaredBirthInput): { readonly startTime: string; readonly endTime: string } { + if (input.source === "period_only") { + return { + early_morning: { startTime: "04:00", endTime: "07:59" }, + morning: { startTime: "08:00", endTime: "11:59" }, + afternoon: { startTime: "12:00", endTime: "17:59" }, + evening: { startTime: "18:00", endTime: "22:59" }, + late_night: { startTime: "23:00", endTime: "03:59" }, + }[input.reportedPeriod]; + } + if (input.source === "unknown") return { startTime: "00:00", endTime: "23:59" }; + if (input.source === "legacy_import" && !input.reportedTime) { + if (input.reportedPeriod) { + return declaredRange({ ...input, source: "period_only", reportedPeriod: input.reportedPeriod }); + } + return { startTime: "00:00", endTime: "23:59" }; + } + const reportedTime = input.reportedTime; + if (!reportedTime) throw new ConversationalRectificationError("profile_incomplete"); + const before = input.uncertaintyBeforeMinutes ?? 2; + const after = input.uncertaintyAfterMinutes ?? 2; + return { + startTime: clock(minute(reportedTime) - before), + endTime: clock(minute(reportedTime) + after), + }; +} + +function scanCoordinates(range: { readonly startTime: string; readonly endTime: string }) { + const start = minute(range.startTime); + let end = minute(range.endTime); + if (end < start) end += 1_440; + const center = Math.round((start + end) / 2); + return { + centerTime: clock(center), + uncertaintyMinutes: Math.max(1, Math.ceil((end - start) / 2)), + }; +} + +function boundedScanRanges(range: { readonly startTime: string; readonly endTime: string }) { + const start = minute(range.startTime); + let end = minute(range.endTime); + if (end < start) end += 1_440; + if (end - start <= 360) return [range]; + + const ranges: Array<{ readonly startTime: string; readonly endTime: string }> = []; + let cursor = start; + while (end - cursor > 360) { + ranges.push({ startTime: clock(cursor), endTime: clock(cursor + 360) }); + cursor += 360; + } + if (cursor < end) { + // A symmetric integer-minute scan needs an even endpoint span. Pull an + // odd final span back by one minute, overlapping rather than inventing a + // minute outside the user's declared range. + const finalStart = (end - cursor) % 2 === 0 ? cursor : cursor - 1; + ranges.push({ startTime: clock(finalStart), endTime: clock(end) }); + } + return ranges; +} + +function currentRange(input: ConversationalRectificationPacketBuildInput) { + const start = input.privateCandidate?.rangeStart; + const end = input.privateCandidate?.rangeEnd; + return start && end ? { startTime: start, endTime: end } : declaredRange(input.declaredBirthInput); +} + +function scoreableLifeEvents( + evidence: readonly LifeEventEvidence[], + birthDate: string, +): LifeEvent[] { + return evidence.flatMap((item) => { + if (item.scoreable !== true || !item.dateValue + || !(["day", "month", "year"] as const).includes(item.datePrecision as "day" | "month" | "year") + || evidencePredatesBirthDate(item, birthDate)) { + return []; + } + if (item.domain === "family" || item.domain === "other") return []; + return [{ + id: item.id, + domain: item.domain, + precision: item.datePrecision as "day" | "month" | "year", + date: item.dateValue, + } as LifeEvent]; + }).slice(-6); +} + +function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] { + const raw = profileRecord(scan.raw.candidate_scan); + const samples = Array.isArray(raw?.samples) ? raw.samples : []; + const links = samples.flatMap((item, sampleIndex) => { + const rawTime = text(profileRecord(item)?.time); + const match = rawTime?.match(/(?:^|[T\s])(([01]\d|2[0-3]):[0-5]\d)/); + return match?.[1] ? [{ sampleIndex, time: match[1] }] : []; + }); + if (links.length !== scan.samples.length) { + throw new ConversationalRectificationError("service_unavailable"); + } + return links; +} + +function timeOffsetFromRangeStart(time: string, rangeStart: string): number { + const start = minute(rangeStart); + let value = minute(time); + if (value < start) value += 1_440; + return value - start; +} + +function timeIsInsideRange( + time: string, + range: { readonly startTime: string; readonly endTime: string }, +): boolean { + const offset = timeOffsetFromRangeStart(time, range.startTime); + const endOffset = timeOffsetFromRangeStart(range.endTime, range.startTime); + return offset <= endOffset; +} + +function mergeQuestionnaireScans( + scans: readonly RectificationQuestionnaire[], + range: { readonly startTime: string; readonly endTime: string }, +): RectificationQuestionnaire { + const first = scans[0]; + if (!first) throw new ConversationalRectificationError("service_unavailable"); + + const byTime = new Map(); + const questions = new Map(); + for (const scan of scans) { + for (const question of scan.questions) { + if (!questions.has(question.id)) questions.set(question.id, question); + } + const rawCandidateScan = profileRecord(scan.raw.candidate_scan); + const rawSamples = Array.isArray(rawCandidateScan?.samples) ? rawCandidateScan.samples : []; + for (const link of sampleTimes(scan)) { + const sample = scan.samples[link.sampleIndex]; + const rawSample = rawSamples[link.sampleIndex]; + if (!sample || rawSample === undefined || !timeIsInsideRange(link.time, range)) continue; + if (!byTime.has(link.time)) byTime.set(link.time, { sample, rawSample }); + } + } + const merged = [...byTime.entries()].sort(([left], [right]) => + timeOffsetFromRangeStart(left, range.startTime) + - timeOffsetFromRangeStart(right, range.startTime)); + const firstCandidateScan = profileRecord(first.raw.candidate_scan) ?? {}; + return { + questions: [...questions.values()], + samples: merged.map(([, item]) => item.sample), + raw: { + ...first.raw, + candidate_scan: { + ...firstCandidateScan, + samples: merged.map(([, item]) => item.rawSample), + }, + }, + }; +} + +function layerMetadata(scan: RectificationQuestionnaire, calculationVersion: string) { + const layers = [ + ["D1", "ascendantSign"], + ["D4", "d4Sign"], + ["D9", "d9Sign"], + ["D10", "d10Sign"], + ["D24", "d24Sign"], + ["D30", "d30Sign"], + ] as const; + const availableLayers = layers + .filter(([, key]) => scan.samples.some((sample) => typeof sample[key] === "string" && sample[key]?.trim())) + .map(([layer]) => layer); + return { + availableLayers, + layerReferences: Object.fromEntries(availableLayers.map((layer) => [ + layer, + [`server-scan-${calculationVersion}-${layer.toLowerCase()}`], + ])), + }; +} + +function boundaryDistance(range: { readonly startTime: string; readonly endTime: string }, representative: string) { + const start = minute(range.startTime); + let end = minute(range.endTime); + let value = minute(representative); + if (end < start) end += 1_440; + if (value < start) value += 1_440; + return Math.max(0, Math.min(value - start, end - value)); +} + +export async function buildProductionConversationalRectificationPacket( + engine: BirthTimeJourneyEngine, + input: ConversationalRectificationPacketBuildInput, +) { + const place = input.declaredBirthInput.birthplace; + if (place.latitude === undefined || place.longitude === undefined) { + throw new ConversationalRectificationError("profile_incomplete"); + } + const baseRange = currentRange(input); + const events = scoreableLifeEvents( + input.evidence as readonly LifeEventEvidence[], + input.declaredBirthInput.birthDate, + ); + const eventScore: CandidateResult | null = events.length >= 3 + ? await engine.scoreEvents({ + birthDate: input.declaredBirthInput.birthDate, + startTime: baseRange.startTime, + endTime: baseRange.endTime, + lat: place.latitude, + lon: place.longitude, + tz: place.timezoneOffset, + events, + }) + : null; + const selectedRange = !input.preserveCandidateRange && eventScore?.winningSegment + ? { startTime: eventScore.winningSegment.startTime, endTime: eventScore.winningSegment.endTime } + : baseRange; + const questionnaires: RectificationQuestionnaire[] = []; + for (const scanRange of boundedScanRanges(selectedRange)) { + const scanPoint = scanCoordinates(scanRange); + const { questionnaire } = await engine.scan({ + birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`, + uncertaintyMinutes: scanPoint.uncertaintyMinutes, + lat: place.latitude, + lon: place.longitude, + tz: place.timezoneOffset, + ayanamsa: "lahiri", + }); + questionnaires.push(questionnaire); + } + const questionnaire = mergeQuestionnaireScans(questionnaires, selectedRange); + const candidateDifferences = await engine.buildDifferencePacket({ + caseId: input.caseId, + asOfDate: input.asOfDate, + birthDate: input.declaredBirthInput.birthDate, + startTime: selectedRange.startTime, + endTime: selectedRange.endTime, + lat: place.latitude, + lon: place.longitude, + tz: place.timezoneOffset, + evidence: [], + dismissedOpportunityIds: [], + questionFingerprints: [], + partitionFingerprints: [], + recentRanges: [], + candidateModel: null, + }); + const calculationVersion = eventScore + ? `${candidateDifferences.packet.scoringVersion}+${eventScore.algorithmVersion}` + : candidateDifferences.packet.scoringVersion; + const metadata = layerMetadata(questionnaire, calculationVersion); + const representative = eventScore?.winningSegment?.representativeTime + ?? scanCoordinates(selectedRange).centerTime; + const { buildRectificationTechnicalPacket } = await import( + "../../../lib/conversational-rectification/technical-packet.ts" + ); + return { + packet: buildRectificationTechnicalPacket({ + scan: questionnaire, + candidateDifferences, + eventScore: input.preserveCandidateRange && eventScore + ? { ...eventScore, confidence: "low", canApply: false, winningSegment: null } + : eventScore, + consultation: { + source: "server_consultation_workflow", + calculationVersion, + availableLayers: metadata.availableLayers, + layerReferences: metadata.layerReferences, + timeLinkedScanSamples: sampleTimes(questionnaire), + boundaryDistanceMinutes: boundaryDistance(selectedRange, representative), + futureWindows: [], + }, + }), + resultId: eventScore?.resultId ?? null, + }; +} + +async function productionNarrativeGenerator(): Promise { + const [{ defaultLanguageModel }, { Agent }] = await Promise.all([ + import("../../../mastra/model.ts"), + import("@mastra/core/agent"), + ]); + const model = defaultLanguageModel(); + if (!model) { + return { + modelId: "deterministic-rectification-fallback", + async generate() { throw new Error("NarrativeModelUnavailable"); }, + }; + } + const agent = new Agent({ + id: `conversational-rectification-${model.id}`, + name: "Conversational Rectification Narrator", + model: model.model, + instructions: "Return only the exact JSON object requested by the user prompt. Use only supplied packet facts. Never invent times, layers, references, scores, dates, or confirmation state.", + }); + return { + modelId: model.id, + async generate(prompt) { + const result = await agent.generate([{ role: "user", content: prompt }]); + return { text: result.text }; + }, + }; +} + +async function createProductionService( + authenticated: AuthenticatedRequest, +): Promise { + const [ + { createAdminSupabaseClient }, + { createSupabaseConversationalRectificationStore }, + { createSupabaseConversationalRectificationBilling }, + { createJyotishBirthTimeJourneyEngine }, + narrativeGenerator, + ] = await Promise.all([ + import("../../../lib/supabase/admin.ts"), + import("../../../lib/conversational-rectification/store.ts"), + import("../../../lib/conversational-rectification/billing.ts"), + import("../../../lib/birth-time-journey-engine.ts"), + productionNarrativeGenerator(), + ]); + const admin = createAdminSupabaseClient(); + const profileClient = authenticated.context as ProfileClient; + const engine = createJyotishBirthTimeJourneyEngine(); + return createConversationalRectificationService({ + store: createSupabaseConversationalRectificationStore(admin), + billing: createSupabaseConversationalRectificationBilling(admin), + get rectificationPriceCredits() { return priceCredits(); }, + allowNewCaseCreation: conversationalRectificationCreationPolicyFromEnvironment( + authenticated.userId, + ).allowNewCaseCreation, + async loadDeclaredProfile(userId) { + return loadProductionConversationalRectificationProfile({ + async loadProfile(receivedUserId) { + const { data, error } = await profileClient + .from("profiles") + .select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id") + .eq("id", receivedUserId) + .maybeSingle(); + if (error) throw new ConversationalRectificationError("store_unavailable"); + return data; + }, + async loadRectificationCase(receivedUserId, receivedCaseId) { + const { data, error } = await admin + .from("birth_time_rectification_cases") + .select("id,journey_protocol,status") + .eq("id", receivedCaseId) + .eq("user_id", receivedUserId) + .maybeSingle(); + if (error) throw new ConversationalRectificationError("store_unavailable"); + return data; + }, + }, userId); + }, + async loadLegacyCase(userId, legacyCaseId) { + const { createJourneyLoadClient, loadStoredRectificationCase } = await import( + "../../../lib/birth-time-journey-case-loader.ts" + ); + const { data: identity, error } = await admin + .from("birth_time_rectification_cases") + .select("id,user_id,journey_protocol,status,reported_date,reported_time,reported_period,source,uncertainty_before_minutes,uncertainty_after_minutes") + .eq("id", legacyCaseId) + .eq("user_id", userId) + .maybeSingle(); + if (error || !identity + || (identity.journey_protocol !== "legacy-guided-v1" + && identity.journey_protocol !== "dynamic-choice-v2")) return null; + const { data: currentProfile, error: profileError } = await profileClient + .from("profiles") + .select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id") + .eq("id", userId) + .maybeSingle(); + if (profileError || !currentProfile) { + throw new ConversationalRectificationError("store_unavailable"); + } + const declaredBirthInput = declaredBirthInputForLegacyCase(currentProfile, identity); + const loaded = await loadStoredRectificationCase( + createJourneyLoadClient(admin), + userId, + legacyCaseId, + ); + if (!loaded) return null; + const winning = loaded.candidateResult?.winningSegment; + const snapshotRange = loaded.snapshot.reportedRange; + const currentRange = loaded.journeyProtocol === "dynamic-choice-v2" + ? loaded.dynamicTurnState.progress.currentRange + : winning + ? { startTime: winning.startTime, endTime: winning.endTime } + : snapshotRange.startTime && snapshotRange.endTime + ? { startTime: snapshotRange.startTime, endTime: snapshotRange.endTime } + : null; + if (!currentRange) throw new ConversationalRectificationError("store_unavailable"); + return { + caseId: loaded.id, + userId: loaded.userId, + journeyProtocol: loaded.journeyProtocol, + status: identity.status, + turnVersion: loaded.turnVersion ?? 0, + declaredBirthInput, + currentRange, + lifeEvents: loaded.lifeEvents ?? [], + }; + }, + buildTechnicalPacket: (input) => buildProductionConversationalRectificationPacket(engine, input), + narrativeGenerator, + asOfDate: () => new Date().toISOString().slice(0, 10), + }); +} + +function stableRequestId(request: Request): string { + const supplied = request.headers.get("x-request-id"); + return supplied && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(supplied) + ? supplied.toLowerCase() + : randomUUID(); +} + +async function dispatch( + service: BirthTimeConversationRouteService, + userId: string, + command: ConversationalRectificationCommand, +): Promise { + switch (command.type) { + case "start": return service.start(userId, command); + case "resume": return service.resume(userId, command); + case "answer": return service.answer(userId, command); + case "pause": return service.pause(userId, command); + case "abandon": return service.abandon(userId, command); + case "confirm": return service.confirm(userId, command); + } +} + +function telemetryPhase( + turn: Pick | null, +): ConversationalRectificationTelemetryPayload["phase"] { + switch (turn?.status) { + case "active": return "collecting_evidence"; + case "paused": return "paused"; + case "confirming": return "confirming"; + case "completed": return "completed"; + case "abandoned": return "abandoned"; + default: return "entry"; + } +} + +function telemetryErrorCategory( + code: string, +): ConversationalRectificationTelemetryPayload["errorCategory"] { + if (code === "authentication_required") return "authentication"; + if (code === "invalid_command" || code === "profile_incomplete") return "validation"; + if (code === "stale_turn" || code === "action_conflict" || code === "candidate_changed" + || code === "invalid_transition" || code === "case_not_found") return "conflict"; + if (code === "billing_failed") return "billing"; + if (code === "service_unavailable" || code === "store_unavailable") return "dependency"; + return "unknown"; +} + +function telemetryResultCategory( + status: number, +): ConversationalRectificationTelemetryPayload["resultCategory"] { + if (status === 409) return "conflict"; + if (status >= 400 && status < 500) return "rejected"; + return "failed"; +} + +export function createBirthTimeConversationPostHandler( + dependencies: BirthTimeConversationPostDependencies, +) { + return async function handleBirthTimeConversationPost(request: Request): Promise { + const startedAt = dependencies.now?.() ?? Date.now(); + const now = dependencies.now ?? Date.now; + const telemetry = dependencies.telemetry + ? createConversationalRectificationTelemetry(dependencies.telemetry) + : recordConversationalRectificationTelemetry; + const deploymentSha = safeConversationalRectificationDeploymentSha( + dependencies.deploymentSha + ?? process.env.GITHUB_SHA + ?? process.env.VERCEL_GIT_COMMIT_SHA + ?? process.env.NEXT_PUBLIC_GIT_COMMIT, + ); + dependencies.createRequestId?.(request); + let actionKind: ConversationalRectificationTelemetryPayload["actionKind"] = "unknown"; + let service: BirthTimeConversationRouteService | null = null; + try { + const authenticated = await dependencies.authenticate(request); + if (!authenticated) throw new ConversationalRectificationError("authentication_required"); + + const parsed = conversationalRectificationCommandSchema.safeParse(await requestPayload(request)); + if (!parsed.success) throw new ConversationalRectificationError("invalid_command"); + actionKind = parsed.data.type; + + service = await dependencies.createService(authenticated); + const turn = await dispatch(service, authenticated.userId, parsed.data); + const outcome = conversationalRectificationTelemetryOutcome(service); + telemetry({ + protocol: "conversational-evidence-v3", + phase: telemetryPhase(turn), + actionKind, + resultCategory: "success", + latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt), + billingState: outcome?.billingState ?? (actionKind === "start" ? "unknown" : "unchanged"), + errorCategory: "none", + deploymentSha, + }); + return Response.json(turn); + } catch (error) { + const publicError = toConversationalRectificationPublicError(error); + const outcome = service ? conversationalRectificationTelemetryOutcome(service) : null; + dependencies.log?.({ code: publicError.code }); + telemetry({ + protocol: "conversational-evidence-v3", + phase: telemetryPhase(outcome?.caseStatus ? { status: outcome.caseStatus } : null), + actionKind, + resultCategory: telemetryResultCategory(publicError.status), + latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt), + billingState: outcome?.billingState + ?? (publicError.code === "billing_failed" ? "unknown" : "not_applicable"), + errorCategory: telemetryErrorCategory(publicError.code), + deploymentSha, + }); + return Response.json(publicError, { status: publicError.status }); + } + }; +} + +const productionPost = createBirthTimeConversationPostHandler({ + authenticate: authenticateProductionRequest, + createService: createProductionService, + createRequestId: stableRequestId, + deploymentSha: process.env.GITHUB_SHA + ?? process.env.VERCEL_GIT_COMMIT_SHA + ?? process.env.NEXT_PUBLIC_GIT_COMMIT, + log(entry) { + console.error(`[birth-time-conversation] code=${entry.code}`); + }, +}); + +export async function POST(request: Request) { + return productionPost(request); +} diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index da7c7426..e32ea0c5 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -184,6 +184,15 @@ export async function POST(request: Request) { resultId: parsed.data.resultId, time: parsed.data.time, }), "turn_advanced"); + case "confirm_dynamic_candidate": + return responseWithJourneyMetric(service.confirmDynamicCandidate({ + userId: user.id, + caseId: parsed.data.caseId, + actionId: parsed.data.actionId, + expectedVersion: parsed.data.turnVersion, + resultId: parsed.data.resultId, + time: parsed.data.time, + }), "turn_advanced"); default: { const exhaustive: never = parsed.data; return exhaustive; diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 01b1db91..1918e1ff 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { consultationInputSchema, consultationWorkflowReceipt, + getGeneralJyotishAgent, getJyotishAgent, runConsultationWorkflow, } from "@/mastra"; @@ -19,16 +20,30 @@ import { reserveConsultationModel } from "@/lib/consultation-model-selection"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { streamTextResponse } from "@/lib/stream-text-response"; -import { guardPreciseTimingOutput } from "@/lib/timing-output-guard"; +import { + applyBirthTimeModeToWorkflowContext, + consultationBirthTimeModeSchema, + createBirthTimeModeOutputGuard, + shouldRunBirthChartWorkflow, + type ConsultationBirthTimeMode, +} from "@/lib/consultation-birth-time-mode"; +import { + ConsultationProfileTruthError, + prepareConsultationRoute, +} from "@/lib/consultation-route-service"; +import { + createRectificationHandoffService, + type RectificationHandoffExecution, + type RectificationHandoffService, +} from "@/lib/rectification-handoff-service"; import { z } from "zod"; export const runtime = "nodejs"; export const maxDuration = 60; -const chatRequestSchema = consultationInputSchema.extend({ +const chatRequestMetadataSchema = z.object({ requestId: z.string().uuid(), modelId: z.string().trim().min(1).max(64), - entrypoint: consultationEntrypointSchema.optional(), name: z.string().trim().max(80).optional().default(""), history: z .array( @@ -41,6 +56,32 @@ const chatRequestSchema = consultationInputSchema.extend({ .default([]), }); +const rectificationHandoffSchema = z.object({ + caseId: z.string().uuid(), + turnVersion: z.number().int().nonnegative(), + claimActionId: z.string().uuid(), + requestId: z.string().uuid(), +}).strict(); + +const chartChatRequestSchema = consultationInputSchema.extend({ + ...chatRequestMetadataSchema.shape, + consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"]) + .optional() + .default("verified_chart"), + entrypoint: consultationEntrypointSchema.optional(), + rectificationHandoff: rectificationHandoffSchema.optional(), +}).strict(); + +const generalChatRequestSchema = z.object({ + ...chatRequestMetadataSchema.shape, + consultationMode: z.literal("general_no_birth_time"), + question: z.string().trim().min(1).max(500), + theme: z.enum(["career", "marriage", "wealth", "timing", "general"]), + entrypoint: z.undefined().optional(), +}).strict(); + +const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]); + function currentTimeContext(now = new Date()) { const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000) .toISOString() @@ -119,20 +160,13 @@ export async function POST(request: Request) { ); } - const userControlledPrompt = [ - parsed.data.question, - ...parsed.data.history - .filter((message) => message.role === "user") - .map((message) => message.text), - ].join("\n"); - if (blocksPromptExtraction(userControlledPrompt)) { + if (parsed.data.entrypoint === "birth_time_rectification") { return NextResponse.json( { - error: "无法处理该请求", - message: - "我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。", + error: "旧版生时校正入口已停用", + message: "请从首页生时校正卡片开始或继续对话式校正,本次不会扣点。", }, - { status: 400 }, + { status: 409 }, ); } @@ -145,20 +179,157 @@ export async function POST(request: Request) { const userId = user.id; const requestId = parsed.data.requestId; - let modelSelection; - try { - modelSelection = await reserveConsultationModel( - parsed.data.modelId, - resolveLanguageModel, - () => - runCreditRpc( - accounting, - "begin_consultation_credit", - userId, - requestId, - ), + const handoff = "rectificationHandoff" in parsed.data + ? parsed.data.rectificationHandoff + : undefined; + let handoffService: RectificationHandoffService | null = null; + let handoffExecution: RectificationHandoffExecution | null = null; + let handoffSettlement: Promise | null = null; + + async function settleHandoff(emitted: boolean) { + if (!handoff || !handoffService || !handoffExecution + || handoffExecution.status !== "ready") return; + handoffSettlement ??= handoffService.settle({ + userId, + caseId: handoff.caseId, + claimActionId: handoff.claimActionId, + requestId: handoff.requestId, + emitted, + }).then(() => undefined); + await handoffSettlement; + } + + if (handoff) { + if (parsed.data.consultationMode !== "verified_chart" + || parsed.data.entrypoint !== undefined + || requestId !== handoff.requestId) { + return NextResponse.json( + { + error: "原问题交接请求不一致", + message: "请刷新校正结果后重新点击继续,本次不会扣点。", + }, + { status: 409 }, + ); + } + try { + handoffService = createRectificationHandoffService(accounting); + handoffExecution = await handoffService.beginExecution({ + userId, + caseId: handoff.caseId, + turnVersion: handoff.turnVersion, + claimActionId: handoff.claimActionId, + requestId: handoff.requestId, + question: parsed.data.question, + }); + } catch { + return NextResponse.json( + { + error: "原问题状态已经变化", + message: "请刷新后查看最新状态,本次不会扣点。", + }, + { status: 409 }, + ); + } + if (handoffExecution.status !== "ready") { + const consumed = handoffExecution.status === "consumed"; + return NextResponse.json( + { + error: consumed ? "原问题已经继续回答" : "原问题正在另一处继续", + message: consumed + ? "刷新后即可查看最新状态,不会再次扣点。" + : "请等待当前回答完成后刷新,本次不会重复扣点。", + }, + { status: consumed ? 410 : 409 }, + ); + } + } + + const userControlledPrompt = [ + parsed.data.question, + ...parsed.data.history + .filter((message) => message.role === "user") + .map((message) => message.text), + ].join("\n"); + if (blocksPromptExtraction(userControlledPrompt)) { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch { + return NextResponse.json( + { error: "暂时无法释放原问题", message: "请稍后刷新状态。" }, + { status: 503 }, + ); + } + } + return NextResponse.json( + { + error: "无法处理该请求", + message: + "我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。", + }, + { status: 400 }, ); + } + let prepared; + try { + prepared = await prepareConsultationRoute({ + userId, + mode: parsed.data.consultationMode, + async loadProfile(profileUserId) { + const { data, error } = await supabase + .from("profiles") + .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") + .eq("id", profileUserId) + .single(); + if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable"); + return data; + }, + reserve: () => reserveConsultationModel( + parsed.data.modelId, + resolveLanguageModel, + () => handoffExecution?.billingReused + ? Promise.resolve({ + success: true, + credits: handoffExecution.credits ?? null, + error_code: null, + }) + : runCreditRpc( + accounting, + "begin_consultation_credit", + userId, + requestId, + ), + ), + }); } catch (error) { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch { + return NextResponse.json( + { + error: "暂时无法释放原问题", + message: "请稍后刷新状态,本次不会重复扣点。", + }, + { status: 503 }, + ); + } + } + if (error instanceof ConsultationProfileTruthError) { + const modeChanged = error.code === "mode_changed"; + return NextResponse.json( + modeChanged + ? { + error: "出生时间状态已经变化", + message: "请刷新后重新选择使用填报时间、一般咨询或先完成校正,本次不会扣点。", + } + : { + error: "暂时无法核对完整出生资料", + message: "出生日期、时间来源或出生地点资料不完整或不一致,请重新保存后再试,本次不会扣点。", + }, + { status: modeChanged ? 409 : 503 }, + ); + } const reason = error instanceof Error ? error.name : "UnknownError"; console.error( `[billing] reservation failed request=${requestId} reason=${reason}`, @@ -169,7 +340,19 @@ export async function POST(request: Request) { ); } + const modelSelection = prepared.reservation; + if (modelSelection.status === "unavailable") { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch { + return NextResponse.json( + { error: "暂时无法释放原问题", message: "请稍后刷新状态。" }, + { status: 503 }, + ); + } + } return NextResponse.json( { error: "模型暂不可用", @@ -183,6 +366,16 @@ export async function POST(request: Request) { const reserveResult = modelSelection.reservation; if (!reserveResult.success) { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch { + return NextResponse.json( + { error: "暂时无法释放原问题", message: "请稍后刷新状态。" }, + { status: 503 }, + ); + } + } const insufficient = reserveResult.error_code === "insufficient_credits"; return NextResponse.json( { @@ -196,6 +389,17 @@ export async function POST(request: Request) { } async function cancel() { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch (error) { + const reason = error instanceof Error ? error.name : "UnknownError"; + console.error( + `[billing] handoff release failed request=${requestId} reason=${reason}`, + ); + } + return; + } try { await runCreditRpc( accounting, @@ -212,6 +416,10 @@ export async function POST(request: Request) { } async function complete() { + if (handoffExecution?.status === "ready") { + await settleHandoff(true); + return; + } const result = await runCreditRpc( accounting, "complete_consultation_credit", @@ -229,12 +437,65 @@ export async function POST(request: Request) { } try { - const { history, name } = parsed.data; + const { history } = parsed.data; + const name = prepared.serverChart?.name ?? parsed.data.name; + const consultationMode: ConsultationBirthTimeMode = parsed.data.consultationMode; + if (!shouldRunBirthChartWorkflow(consultationMode)) { + const result = await getGeneralJyotishAgent(selectedModel).stream([ + { + role: "user", + content: [ + currentTimeContext(requestTime), + name ? `用户称呼:${name}` : "", + "当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。", + resolvedQuestion.modelQuestion, + ].filter(Boolean).join("\n"), + }, + ]); + const completeAndRecordUsage = async () => { + await complete(); + void recordModelUsage( + accounting, + userId, + requestId, + modelSelection.usageModelId, + result.totalUsage, + ); + }; + const settleInterrupted = (emitted: boolean) => + settle(emitted ? completeAndRecordUsage : cancel); + return streamTextResponse(result.textStream, { + transformText: createBirthTimeModeOutputGuard(consultationMode, false), + mode: "mastra", + requestId, + headers: { + "x-jyotish-workflow-route": "general-no-birth-time", + "x-jyotish-workflow-status": "ready", + "x-jyotish-technique-truth": "not-applicable", + "x-jyotish-precise-timing": "blocked", + "x-jyotish-missing-layers": "birth-minute", + "x-jyotish-birth-time-mode": consultationMode, + }, + ...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}), + onComplete: () => settle(completeAndRecordUsage), + onError: (_error, emitted) => settleInterrupted(emitted), + onCancel: settleInterrupted, + }); + } + + if (!prepared.serverChart) throw new Error("server_chart_truth_missing"); const toolInput = consultationInputSchema.parse({ - ...parsed.data, + ...prepared.serverChart.toolInput, + // Unverified use is still a normal chart calculation with a hard answer + // boundary. It must never reactivate the retired rectification questionnaire. + entryMode: "direct_chart", question: resolvedQuestion.modelQuestion, + theme: parsed.data.theme, }); - const workflowContext = await runConsultationWorkflow(toolInput); + const workflowContext = applyBirthTimeModeToWorkflowContext( + await runConsultationWorkflow(toolInput), + consultationMode, + ); const workflowReceipt = consultationWorkflowReceipt(workflowContext); const result = await getJyotishAgent(selectedModel, workflowContext).stream([ @@ -265,10 +526,10 @@ export async function POST(request: Request) { const settleInterrupted = (emitted: boolean) => settle(emitted ? completeAndRecordUsage : cancel); return streamTextResponse(result.textStream, { - transformText: - workflowReceipt.preciseTiming === "blocked" - ? guardPreciseTimingOutput - : undefined, + transformText: createBirthTimeModeOutputGuard( + consultationMode, + workflowReceipt.preciseTiming !== "blocked", + ), mode: "mastra", requestId, headers: { @@ -277,7 +538,9 @@ export async function POST(request: Request) { "x-jyotish-technique-truth": workflowReceipt.techniqueTruth, "x-jyotish-precise-timing": workflowReceipt.preciseTiming, "x-jyotish-missing-layers": workflowReceipt.missingLayers, + "x-jyotish-birth-time-mode": consultationMode, }, + ...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}), onComplete: () => settle(completeAndRecordUsage), onError: (_error, emitted) => settleInterrupted(emitted), onCancel: settleInterrupted, diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts index e8cab627..25927299 100644 --- a/frontend/src/app/api/health/route.ts +++ b/frontend/src/app/api/health/route.ts @@ -1,4 +1,8 @@ import { NextResponse } from "next/server"; +import { + conversationalRectificationCreationPolicyFromEnvironment, + conversationalRectificationDeploymentShaFromEnvironment, +} from "../../../lib/conversational-rectification/creation-policy.ts"; type Check = { status: "ok" | "degraded" | "blocked"; @@ -7,11 +11,6 @@ type Check = { }; const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; -const gitCommit = - process.env.GITHUB_SHA - ?? process.env.VERCEL_GIT_COMMIT_SHA - ?? process.env.NEXT_PUBLIC_GIT_COMMIT - ?? "unknown"; function envCheck(names: string[]): Check { const missing = names.filter((name) => !process.env[name]); @@ -58,6 +57,10 @@ function aggregate(checks: Record) { } export async function GET() { + const gitCommit = conversationalRectificationDeploymentShaFromEnvironment(); + const rectificationV3MigrationsReady = + process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true"; + const creationPolicy = conversationalRectificationCreationPolicyFromEnvironment(); const checks = { web: { status: "ok" } satisfies Check, supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]), @@ -66,6 +69,8 @@ export async function GET() { jyotishApi: await jyotishApiCheck(), }; const status = aggregate(checks); + const rectificationV3Ready = status === "ok" + && creationPolicy.audience === "public"; return NextResponse.json( { status, @@ -73,6 +78,16 @@ export async function GET() { deployment: { gitCommit, }, + rollout: { + conversationalRectificationV3: { + protocol: "conversational-evidence-v3", + newCaseCreation: creationPolicy.audience === "paused" ? "paused" : "enabled", + creationAudience: creationPolicy.audience, + migrations: rectificationV3MigrationsReady ? "ready" : "unverified", + syntheticSmoke: creationPolicy.smokeMatchesDeployment ? "matched" : "pending", + readyForNewCases: rectificationV3Ready, + }, + }, checks, }, { status: status === "ok" ? 200 : 503 }, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c07a0eab..5d26b307 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -132,6 +132,10 @@ button:disabled { cursor: default; opacity: .45; } .onboarding-card-actions { display: flex; justify-content: flex-end; padding-top: 2px; } .onboarding-card-actions .button-primary { min-width: 84px; } .birth-time-transition-card { position: relative; overflow: hidden; } +.unverified-birth-time-choice-scrim { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: var(--space-4); overflow-y: auto; background: var(--color-scrim); } +.unverified-birth-time-choice { width: min(100%, 620px); margin: 0; overflow: visible; } +.unverified-birth-time-choice .onboarding-card-actions { flex-wrap: wrap; gap: var(--space-2); } +.unverified-birth-time-choice button { min-height: 44px; } .birth-time-transition-fields { min-width: 0; display: grid; gap: 14px; margin: 0; padding: 0; border: 0; } .birth-time-assessment-overlay { position: absolute; z-index: 2; inset: 0; display: grid; place-items: center; padding: var(--space-6); border-radius: inherit; background: color-mix(in srgb, var(--color-canvas-soft) 94%, transparent); backdrop-filter: blur(2px); } .birth-time-assessment-progress { width: min(360px, 100%); display: grid; justify-items: center; gap: var(--space-2); color: var(--color-ink-secondary); text-align: center; } @@ -439,6 +443,53 @@ button:disabled { cursor: default; opacity: .45; } .birth-time-success-note { padding: var(--space-4); border-left: 2px solid var(--color-success); background: var(--color-success-muted); color: var(--color-success); } .birth-time-candidate-terminal { justify-items: start; } .birth-time-candidate-result, .birth-time-candidate-result > *, .birth-time-confirmation-panel > * { min-width: 0; max-width: 100%; } +.conversational-rectification { width: 100%; min-width: 0; max-width: 720px; display: grid; gap: var(--space-5); overflow-wrap: anywhere; color: var(--color-ink); } +.conversational-rectification > *, .conversational-rectification form, .conversational-rectification fieldset { min-width: 0; max-width: 100%; } +.conversational-rectification button { min-height: 44px; max-width: 100%; overflow-wrap: anywhere; } +.conversational-rectification :where(button, textarea, summary, .conversational-status):focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; } +.conversational-narrative { padding: var(--space-5); border-left: 3px solid var(--color-action); border-radius: 0 var(--radius-lg) var(--radius-lg) 0; background: var(--color-action-soft); } +.conversational-narrative .message-markdown { overflow-wrap: anywhere; } +.conversational-domain-picker { display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; } +.conversational-domain-picker legend { margin-bottom: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; } +.conversational-domain-picker > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-2); } +.conversational-domain-picker button { padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; text-align: left; } +.conversational-domain-picker button[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); } +.conversational-composer { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } +.conversational-composer label { display: grid; gap: var(--space-1); font-size: var(--type-body-sm); font-weight: 600; } +.conversational-composer label span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 400; line-height: 1.5; } +.conversational-composer textarea { width: 100%; min-width: 0; min-height: 112px; resize: vertical; padding: var(--space-3); border: 1px solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); line-height: 1.55; } +.conversational-composer-footer { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } +.conversational-composer-footer small { color: var(--color-ink-tertiary); font-size: var(--type-overline); } +.conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-abandon-confirmation, .conversational-empty-state { min-width: 0; display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } +.conversational-evidence-recap > header, .conversational-candidate > header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } +.conversational-evidence-recap h3, .conversational-candidate h3, .conversational-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } +.conversational-evidence-recap > header span, .conversational-candidate > header span { color: var(--color-ink-secondary); font-size: var(--type-caption); } +.conversational-candidate > header .is-unverified { color: var(--color-warning); font-weight: 600; } +.conversational-candidate > header .is-confirmed { color: var(--color-success); font-weight: 600; } +.conversational-evidence-recap ul { min-width: 0; display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; } +.conversational-evidence-recap li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-canvas-soft); } +.conversational-evidence-recap li > div { min-width: 0; } +.conversational-evidence-recap time { color: var(--color-ink-tertiary); font-size: var(--type-overline); } +.conversational-evidence-recap p, .conversational-candidate p, .conversational-confirmation p, .conversational-original-question p, .conversational-abandon-confirmation p, .conversational-empty-state p, .conversational-status p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; } +.conversational-evidence-recap li button { min-width: 56px; padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; } +.conversational-candidate dl, .conversational-technical-receipt dl { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); } +.conversational-candidate dl > div, .conversational-technical-receipt dl > div { min-width: 0; display: grid; gap: var(--space-1); padding: var(--space-3); background: var(--color-canvas-soft); } +.conversational-candidate dt, .conversational-technical-receipt dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } +.conversational-candidate dd, .conversational-technical-receipt dd { min-width: 0; margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); overflow-wrap: anywhere; } +.conversational-candidate time { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.conversational-technical-receipt { min-width: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); } +.conversational-technical-receipt summary { min-height: 44px; display: flex; align-items: center; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); font-weight: 600; } +.conversational-technical-receipt code { font-family: var(--font-mono); font-size: var(--type-overline); } +.conversational-confirmation { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); } +.conversational-status { min-width: 0; min-height: 1px; } +.conversational-original-question { background: var(--color-success-muted); } +.conversational-session-actions { min-width: 0; display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); } +.conversational-abandon { min-height: 44px; padding: 0 var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-danger); cursor: pointer; } +.conversational-abandon.is-confirm { border-color: var(--color-danger); background: var(--color-danger); color: var(--color-on-dark); } +.conversational-abandon-scrim { position: fixed; inset: 0; z-index: 80; display: grid; align-items: center; justify-items: center; padding: var(--space-4); overflow-y: auto; background: var(--color-scrim); } +.conversational-abandon-confirmation { width: min(480px, 100%); border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); box-shadow: var(--shadow-elevated); } +.conversational-abandon-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } +.conversational-abandon-confirmation > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } .phrase-nowrap { white-space: nowrap; } .starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); border: 0; } @@ -709,3 +760,12 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: } .session-delete-overlay { z-index: 100; } .session-delete-confirmation p { margin: 0; color: var(--color-ink-secondary); } + +@media (max-width: 430px) { + .conversational-rectification { width: 100%; max-width: 100%; gap: var(--space-4); overflow-x: clip; } + .conversational-narrative, .conversational-composer, .conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-abandon-confirmation, .conversational-empty-state { padding: var(--space-3); } + .conversational-domain-picker > div, .conversational-candidate dl, .conversational-technical-receipt dl { grid-template-columns: minmax(0, 1fr); } + .conversational-composer-footer { align-items: stretch; flex-direction: column; } + .conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button, .conversational-session-actions > button, .conversational-abandon-confirmation button { width: 100%; } + .conversational-session-actions, .conversational-abandon-confirmation > div { flex-direction: column; } +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 1959c3cd..734a045d 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -13,6 +13,7 @@ import { import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; import { ChatMessageContent } from "@/components/chat-message-content"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; +import { UnverifiedBirthTimeChoice } from "@/components/unverified-birth-time-choice"; import { ModelSelector } from "@/components/model-selector"; import { Button } from "@/components/ui/button"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; @@ -21,19 +22,42 @@ import { chinaLocations, type ProvinceNode } from "@/data/china-locations"; import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import { + applyBirthTimeDraftPatch, assistantIntentCopy, birthTimeDisplayState, birthTimePersistenceValues, + declaredBirthInputChanged, describeBirthTimeDraft, + isDeclaredBirthProfileComplete, isBirthTimeDraftReady, - isBirthTimeReadyForConsultation, type BirthTimeDraft, type BirthTimeSource, } from "@/lib/birth-time-intake-model"; +import { + birthTimeConsultationOptionsCopy, + canUseUnverifiedBirthTime, + clearBirthTimeConsultationConsent, + createLatestAccountRequestGuard, + createBirthTimeConsultationConsentState, + grantBirthTimeConsultationConsent, + resolveBirthTimeConsultationRoute, + resolveRectificationCardAction, + unverifiedBirthTime, + type AccountRectificationCaseState, + type BirthTimeConsultationConsentState, + type RectificationCardAction, +} from "@/lib/birth-time-consultation-consent"; +import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; +import { sendConversationalRectificationCommand } from "@/lib/conversational-rectification/client"; +import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts"; +import { + createDurableRectificationQuestionHandoffClient, + createRectificationQuestionHandoffCoordinator, + DurableRectificationHandoffError, +} from "@/lib/rectification-question-handoff"; import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey"; import { requestBirthTimeAssessment, - resumeBirthTimeJourney, type JourneyClientResponse, } from "@/lib/birth-time-journey-client"; import { @@ -43,6 +67,7 @@ import { } from "@/lib/birth-time-guided-preview"; import { keepFocusWithin } from "@/lib/focus-trap"; import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view"; +import { persistExistingChatSession } from "@/lib/chat-session-persistence"; import { OnboardingAuthenticationError, type OnboardingContent, @@ -73,6 +98,15 @@ const BirthTimeRectification = dynamic( }, ); +const ConversationalBirthTimeRectification = dynamic( + () => import("@/components/conversational-birth-time-rectification") + .then((module) => module.ConversationalBirthTimeRectification), + { + ssr: false, + loading: () =>

正在加载生时校正对话…

, + }, +); + type Theme = ReplyTheme; type Message = ChatMessage; type Profile = BirthTimeDraft & { @@ -119,7 +153,14 @@ type ChatSession = { id: string; title: string; theme: Theme; modelId: string; m type RequestError = { sessionId: string; message: string }; type StreamingReply = { sessionId: string; text: string }; type BirthPlace = { label: string; lat: number; lon: number; tz: number }; -type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean }; +type Account = { + user: { id: string; email: string | null }; + credits: number; + isAdmin: boolean; + rectificationPriceCredits: number; + hasConfirmedBirthTime: boolean; + rectificationCase: AccountRectificationCaseState | null; +}; type OnboardingStep = "name" | "birth" | "place" | "rectification"; type AccountDialog = "profile" | "redeem" | "logout"; type DailyStarlanguageCard = { trend: string; action: string; caution: string }; @@ -130,20 +171,26 @@ type DailyStarlanguageApiResponse = { claim_status?: "exploratory_unvalidated"; boundary?: "not_deterministic_prediction"; }; -type BirthRectificationPreview = { - status?: "ok" | "blocked"; - candidate_scan?: { start?: string; end?: string; candidate_count?: number }; - question_count?: number; - boundary?: "not_auto_rectified"; - source?: "active_rectification_questions" | "fallback_unavailable"; -}; type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] }; +type PendingBirthTimeChoice = Readonly<{ + sessionId: string; + question: string; + entrypoint: ConsultationEntrypoint | null; + theme: Theme; +}>; +type ConsultationRectificationHandoff = Readonly<{ + caseId: string; + turnVersion: number; + claimActionId: string; + requestId: string; +}>; type PendingConsultation = { readonly requestId: string; readonly sessionId: string; readonly question: string; readonly entrypoint: ConsultationEntrypoint | null; readonly theme: Theme; + readonly rectificationHandoff: ConsultationRectificationHandoff | null; readonly previousSession: ChatSession; readonly optimisticSession: ChatSession; readonly previousOnboardingState: boolean; @@ -161,6 +208,12 @@ const themes: Array<{ id: Exclude; label: string; prompt: stri { id: "timing", label: "时运", prompt: "未来哪些阶段值得把握?" }, ]; +const rectificationCardLabels = { + start: "开始生时校正", + resume: "继续上次校正", + revise: "再次校正", +} as const satisfies Record; + const accountDialogTitles = { profile: "个人资料", redeem: "兑换点数", @@ -390,21 +443,10 @@ async function fetchDailyStarlanguage(profile: Profile) { return payload.card; } -async function fetchBirthRectificationPreview(profile: Profile) { - const response = await fetch("/api/birth-rectification", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profile }), - }); - if (!response.ok) throw new Error("birth_rectification_preview_unavailable"); - return await response.json().catch(() => null) as BirthRectificationPreview | null; -} - function missingProfileStep(profile: Profile): OnboardingStep | null { if (!profile.name.trim()) return "name"; - if (!isBirthTimeDraftReady(profile)) return "birth"; - if (!selectedBirthPlace(profile)) return "place"; - if (!isBirthTimeReadyForConsultation(profile)) return "rectification"; + if (!isDeclaredBirthProfileComplete(profile)) return "birth"; + if (!isDeclaredBirthProfileComplete(profile, selectedBirthPlace(profile))) return "place"; return null; } @@ -434,7 +476,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 || !isBirthTimeReadyForConsultation(profile) || !birthPlace) return []; + if (!name || !isDeclaredBirthProfileComplete(profile) || !birthPlace) return []; return [ { role: "assistant", text: presetOnboardingMessage }, @@ -479,14 +521,15 @@ function readProfile(value: unknown): Profile { const time = typeof profile.active_birth_time === "string" ? profile.active_birth_time.slice(0, 5) : legacyTime; - const reportedTime = typeof profile.reported_birth_time === "string" + const persistedReportedTime = 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 reportedTime = persistedReportedTime || (source === "legacy_import" ? time : ""); 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; @@ -576,6 +619,21 @@ function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (p ); } +const birthLocationKeys = ["countryCode", "provinceCode", "cityCode", "districtCode"] as const; + +function birthProfileDeclarationChanged(current: Profile, next: Profile) { + return declaredBirthInputChanged(current, next) + || birthLocationKeys.some((key) => current[key] !== next[key]); +} + +function invalidateCandidateAfterLocationChange(current: Profile, next: Profile): Profile { + const locationChanged = birthLocationKeys.some((key) => current[key] !== next[key]); + if (!locationChanged + || current.birthTimeStatus === "confirmed" + || (current.birthTimeStatus !== "candidate" && !current.time)) return next; + return { ...next, time: "", birthTimeStatus: "reported" }; +} + function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) { return ( <> @@ -583,8 +641,11 @@ function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onCha 如何称呼你 onChange({ ...value, name: event.target.value })} /> - onChange({ ...value, ...patch })} /> - + onChange(applyBirthTimeDraftPatch(value, patch))} /> + onChange(invalidateCandidateAfterLocationChange(value, next))} + /> ); } @@ -681,7 +742,6 @@ export default function Home() { const [synastryReportCard, setSynastryReportCard] = useState(null); const [synastryHistory, setSynastryHistory] = useState([]); const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState(null); - const [birthRectificationPreview, setBirthRectificationPreview] = useState(null); const [profileNotice, setProfileNotice] = useState(""); const [account, setAccount] = useState(null); const [accountError, setAccountError] = useState(""); @@ -707,6 +767,17 @@ export default function Home() { const [pendingSessionId, setPendingSessionId] = useState(null); const [streamingReply, setStreamingReply] = useState(null); const [requestError, setRequestError] = useState(null); + const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState( + createBirthTimeConsultationConsentState, + ); + const [pendingBirthTimeChoice, setPendingBirthTimeChoice] = useState(null); + const [rectificationSurfaceOpen, setRectificationSurfaceOpen] = useState(false); + const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); + const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); + const [rectificationLoading, setRectificationLoading] = useState(false); + const [rectificationMutationPending, setRectificationMutationPending] = useState(false); + const [rectificationContinuationPending, setRectificationContinuationPending] = useState(false); + const [rectificationError, setRectificationError] = useState(""); const [hydrated, setHydrated] = useState(false); const [profileSaving, setProfileSaving] = useState(false); const [creatingSession, setCreatingSession] = useState(false); @@ -739,6 +810,12 @@ export default function Home() { const activeSessionIdRef = useRef(""); const chartLibraryLoadedAccount = useRef(""); const activeOnboardingRequestIdentity = useRef(""); + const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); + const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator()); + const durableRectificationQuestionHandoff = useRef( + createDurableRectificationQuestionHandoffClient(), + ); + const rectificationContinuationInFlight = useRef(false); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); const birthTimeRevisionPending = useRef(false); @@ -752,14 +829,28 @@ export default function Home() { }); const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; + const activeBirthTimeChoice = pendingBirthTimeChoice?.sessionId === activeSession?.id + ? pendingBirthTimeChoice + : null; const visibleSessions = sessions .filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id)) .sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id))); const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : ""; const isLoading = pendingSessionId === activeSession?.id; - const productEntrypointsDisabled = !hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog; + const productEntrypointsDisabled = !hydrated + || Boolean(pendingSessionId) + || cancellationPending + || rectificationMutationPending + || rectificationContinuationPending + || !account + || !modelCatalog; const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : ""; const accountId = account?.user.id; + const rectificationCardAction = resolveRectificationCardAction({ + rectificationCase: account?.rectificationCase ?? null, + hasConfirmedBirthTime: account?.hasConfirmedBirthTime ?? false, + }); + const rectificationCardLabel = rectificationCardLabels[rectificationCardAction]; const onboardingFingerprint = onboardingProfileFingerprint(profile); useEffect(() => { @@ -929,7 +1020,14 @@ export default function Home() { messages: previewMessages, updatedAt: timestamp(), }; - setAccount({ user: { id: "preview-user", email: "preview@local.test" }, credits: 8, isAdmin: false }); + setAccount({ + user: { id: "preview-user", email: "preview@local.test" }, + credits: 8, + isAdmin: false, + rectificationPriceCredits: 1, + hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", + rectificationCase: null, + }); setModelCatalog(previewModelCatalog); setProfile(previewProfile); setProfileDraft(previewProfile); @@ -1018,20 +1116,6 @@ export default function Home() { setOnboardingStep(missingProfileStep(nextProfile) ?? "name"); setSessions(nextSessions); setActiveSessionId(nextSessions[0].id); - if ((nextProfile.birthTimeStatus === "rectifying" - || (nextProfile.birthTimeStatus === "candidate" && !nextProfile.time)) - && nextProfile.rectificationCaseId) { - try { - const resumed = await resumeBirthTimeJourney(nextProfile.rectificationCaseId); - if (!controller.signal.aborted) { - setBirthTimeJourney(resumed); - } - } catch (caught) { - if (!controller.signal.aborted) { - setBirthTimeError(caught instanceof Error ? caught.message : "暂时无法继续上次的时间校正。"); - } - } - } if (modelCatalogResult.unavailable) { setComposerNotice("模型服务暂时不可用,当前无法发送问题。"); } else if (parsedSessions.fallbackSessionIds.length > 0) { @@ -1142,22 +1226,6 @@ export default function Home() { }; }, [hydrated, profile, profileComplete]); - useEffect(() => { - if (!hydrated || !profileComplete) return; - let cancelled = false; - setBirthRectificationPreview(null); - void fetchBirthRectificationPreview(profile) - .then((preview) => { - if (!cancelled) setBirthRectificationPreview(preview); - }) - .catch(() => { - if (!cancelled) setBirthRectificationPreview({ status: "blocked", boundary: "not_auto_rectified", source: "fallback_unavailable" }); - }); - return () => { - cancelled = true; - }; - }, [hydrated, profile, profileComplete]); - useEffect(() => { const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; conversationEnd.current?.scrollIntoView({ behavior: isLoading || reduceMotion ? "auto" : "smooth", block: "end" }); @@ -1192,10 +1260,25 @@ export default function Home() { }, [activeAccountDialog, signingOut]); async function refreshAccount() { + const requestIdentity = accountRefreshGuard.current.begin(); try { - setAccount(await fetchAccount()); + const latest = await fetchAccount(); + if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; + setAccount((current) => { + if (current?.rectificationCase + && latest.rectificationCase?.caseId === current.rectificationCase.caseId + && latest.rectificationCase.turnVersion < current.rectificationCase.turnVersion) { + return { + ...latest, + hasConfirmedBirthTime: latest.hasConfirmedBirthTime || current.hasConfirmedBirthTime, + rectificationCase: current.rectificationCase, + }; + } + return latest; + }); setAccountError(""); } catch (caught) { + if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息"); } } @@ -1204,7 +1287,7 @@ export default function Home() { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); } - async function persistSession(session: ChatSession) { + async function persistSession(session: ChatSession, mode: "create" | "update" = "update") { if (!account) throw new Error("账户尚未加载完成"); if (process.env.NODE_ENV === "development" && uiPreview.current) return; const supabase = createBrowserSupabaseClient(); @@ -1215,22 +1298,26 @@ export default function Home() { messages: session.messages, updated_at: new Date(session.updatedAt).toISOString(), }; - const { data, error } = await supabase - .from("chat_sessions") - .update(values) - .eq("id", session.id) - .eq("user_id", account.user.id) - .select("id") - .maybeSingle(); - if (error) throw new Error(`云端同步失败:${error.message}`); - if (data) return; + if (mode === "create") { + const { error } = await supabase.from("chat_sessions").insert({ + id: session.id, + user_id: account.user.id, + ...values, + }); + if (error) throw new Error(`云端同步失败:${error.message}`); + return; + } - const { error: insertError } = await supabase.from("chat_sessions").insert({ - id: session.id, - user_id: account.user.id, - ...values, + await persistExistingChatSession(async () => { + const { data, error } = await supabase + .from("chat_sessions") + .update(values) + .eq("id", session.id) + .eq("user_id", account.user.id) + .select("id") + .maybeSingle(); + return { found: Boolean(data), error: error?.message ?? null }; }); - if (insertError) throw new Error(`云端同步失败:${insertError.message}`); } async function renameSession(session: ChatSession) { @@ -1250,6 +1337,8 @@ export default function Home() { const previousSessions = sessions; const nextSessions = sessions.filter((item) => item.id !== session.id); setSessions(nextSessions); + setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id)); + setPendingBirthTimeChoice((current) => current?.sessionId === session.id ? null : current); setPinnedSessionIds((current) => current.filter((id) => id !== session.id)); setArchivedSessionIds((current) => current.filter((id) => id !== session.id)); if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? ""); @@ -1314,7 +1403,7 @@ export default function Home() { setComposerNotice(""); setRequestError(null); try { - await persistSession(nextSession); + await persistSession(nextSession, "create"); } catch (caught) { setSessions((current) => current.filter((session) => session.id !== nextSession.id)); setActiveSessionId(previousSessionId); @@ -1543,15 +1632,18 @@ export default function Home() { setProfileNotice(""); setAccountError(""); try { + const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft); await persistProfile(profileDraft); - const nextProfile = profileDraft.birthTimeStatus === "confirmed" - ? profileDraft - : await assessSavedBirthTime(profileDraft); - setProfile(nextProfile); - setProfileDraft(nextProfile); - setProfileNotice(nextProfile.birthTimeStatus === "confirmed" + setProfile(profileDraft); + setProfileDraft(profileDraft); + if (declarationChanged) { + setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState()); + setAccount((current) => current ? { ...current, rectificationCase: null } : current); + void refreshAccount(); + } + setProfileNotice(profileDraft.birthTimeStatus === "confirmed" ? "出生资料已保存到云端,可在同一账号的其他设备使用。" - : "资料已保存,当前时间仍在校正中,不会用于正式排盘。"); + : `出生资料已保存。${birthTimeConsultationOptionsCopy(profileDraft)}`); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败")); } finally { @@ -1590,15 +1682,7 @@ export default function Home() { setAccountError(""); try { await persistProfile(profileDraft); - if (birthTimeRevisionPending.current) { - setBirthTimeAssessmentPhase("assessing"); - const assessedProfile = await assessSavedBirthTime(profileDraft); - birthTimeRevisionPending.current = false; - setPresetMessageLength(0); - if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(true); - else setOnboardingStep("rectification"); - return; - } + birthTimeRevisionPending.current = false; setProfile(profileDraft); setPresetMessageLength(0); const nextStep = missingProfileStep(profileDraft); @@ -1627,14 +1711,11 @@ export default function Home() { setAccountError(""); try { await persistProfile(profileDraft); - setBirthTimeAssessmentPhase("assessing"); - const assessedProfile = await assessSavedBirthTime(profileDraft); + setProfile(profileDraft); + setProfileDraft(profileDraft); + setStartGreeting(createStartGreeting(profileDraft.name)); setPresetMessageLength(0); - if (assessedProfile.birthTimeStatus === "confirmed") { - setOnboardingJustCompleted(true); - } else { - setOnboardingStep("rectification"); - } + setOnboardingJustCompleted(true); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败")); } finally { @@ -1749,12 +1830,131 @@ export default function Home() { chooseSuggestedQuestion("深入看今日", "timing", "daily_starlanguage"); } - function draftBirthTimeRectificationQuestion() { - chooseSuggestedQuestion( - birthTimeDisplay ? "再次校正" : "生时校正", - "timing", - "birth_time_rectification", + function synchronizeRectificationQuestion(turn: ConversationalRectificationTurn) { + if (!turn.pendingConsultationQuestion || !activeSession) return; + rectificationQuestionHandoff.current.synchronizeDurableQuestion( + turn.pendingConsultationQuestion, + { sessionId: activeSession.id, theme: activeSession.theme }, ); + setRectificationPendingQuestion(turn.pendingConsultationQuestion); + } + + async function openBirthTimeRectification(pendingConsultationQuestion: string | null = null) { + if (!account || rectificationLoading || rectificationMutationPending + || rectificationContinuationInFlight.current) return; + const action = resolveRectificationCardAction({ + rectificationCase: account.rectificationCase, + hasConfirmedBirthTime: account.hasConfirmedBirthTime, + }); + setPendingBirthTimeChoice(null); + setDraft(""); + setDraftTheme(null); + setDraftEntrypoint(null); + setRectificationPendingQuestion( + pendingConsultationQuestion + ?? rectificationQuestionHandoff.current.peek()?.question + ?? null, + ); + setRectificationInitialTurn(null); + setRectificationError(""); + setRectificationSurfaceOpen(true); + setRectificationLoading(true); + try { + if (action !== "resume" || !account.rectificationCase) { + const durable = await durableRectificationQuestionHandoff.current.load(); + if (!durable || durable.status === "consumed") return; + setRectificationInitialTurn(durable.turn); + synchronizeRectificationQuestion(durable.turn); + return; + } + + let current = account.rectificationCase; + let turn: ConversationalRectificationTurn; + if (pendingConsultationQuestion) { + try { + turn = await durableRectificationQuestionHandoff.current.attach({ + caseId: current.caseId, + turnVersion: current.turnVersion, + question: pendingConsultationQuestion, + }); + } catch (error) { + if (!(error instanceof DurableRectificationHandoffError) + || error.status !== 409) throw error; + const latest = await fetchAccount(); + if (!latest.rectificationCase + || latest.rectificationCase.caseId !== current.caseId) throw error; + current = latest.rectificationCase; + setAccount(latest); + turn = await durableRectificationQuestionHandoff.current.attach({ + caseId: current.caseId, + turnVersion: current.turnVersion, + question: pendingConsultationQuestion, + }); + } + } else { + turn = await sendConversationalRectificationCommand({ + type: "resume", + caseId: current.caseId, + actionId: globalThis.crypto.randomUUID(), + turnVersion: current.turnVersion, + }); + } + setRectificationInitialTurn(turn); + synchronizeRectificationQuestion(turn); + } catch (caught) { + setRectificationError(caught instanceof Error + ? caught.message + : "生时校正暂时无法继续,请稍后重试。"); + } finally { + setRectificationLoading(false); + } + } + + function handleConversationalRectificationTurn(turn: ConversationalRectificationTurn) { + const requestIdentity = accountRefreshGuard.current.begin(); + setRectificationInitialTurn(turn); + synchronizeRectificationQuestion(turn); + setAccount((current) => current ? { + ...current, + hasConfirmedBirthTime: current.hasConfirmedBirthTime + || (turn.status === "completed" && turn.candidate.status === "confirmed"), + rectificationCase: { + caseId: turn.caseId, + journeyProtocol: "conversational-evidence-v3", + status: turn.status, + turnVersion: turn.turnVersion, + isRevision: current.rectificationCase?.isRevision + ?? current.hasConfirmedBirthTime, + preservesActiveTime: current.rectificationCase?.preservesActiveTime + ?? current.hasConfirmedBirthTime, + }, + } : current); + if (turn.status === "completed" + && turn.candidate.status === "confirmed" + && turn.candidate.representativeTime) { + setProfile((current) => ({ + ...current, + time: turn.candidate.representativeTime ?? current.time, + birthTimeStatus: "confirmed", + rectificationCaseId: turn.caseId, + })); + setProfileDraft((current) => ({ + ...current, + time: turn.candidate.representativeTime ?? current.time, + birthTimeStatus: "confirmed", + rectificationCaseId: turn.caseId, + })); + } + void fetchAccount() + .then((latest) => { + if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; + setAccount((current) => { + if (latest.rectificationCase?.caseId !== turn.caseId + || latest.rectificationCase.turnVersion < turn.turnVersion) return current; + return latest; + }); + }) + .catch(() => undefined); } async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) { @@ -1925,8 +2125,10 @@ export default function Home() { setPendingSessionId(null); setConsultationPhase(null); setRequestError(null); - cancellationFeedbackRequest.current = pending.requestId; - setComposerNotice("已停止,问题已放回输入框,正在确认点数…"); + cancellationFeedbackRequest.current = pending.rectificationHandoff ? null : pending.requestId; + setComposerNotice(pending.rectificationHandoff + ? "已停止,原问题仍由校正案例保留;正在释放本次继续操作…" + : "已停止,问题已放回输入框,正在确认点数…"); window.requestAnimationFrame(() => composerInput.current?.focus()); if (pending.phase === "undo" || isPreview) { @@ -1937,11 +2139,15 @@ export default function Home() { return; } - await confirmCancellation( - pending.requestId, - pending.sessionId, - "已停止,问题已放回输入框,本次未扣点。", - ); + if (pending.rectificationHandoff) { + setComposerNotice("已停止;原问题仍保留,可刷新校正状态后重试。"); + } else { + await confirmCancellation( + pending.requestId, + pending.sessionId, + "已停止,问题已放回输入框,本次未扣点。", + ); + } } function completeConsultationInterface(requestId: string) { @@ -1956,30 +2162,66 @@ export default function Home() { text: string, requestedTheme?: Theme, entrypoint: ConsultationEntrypoint | null = null, - ) { + consentGrantedForRequest: ConsultationBirthTimeMode | null = null, + targetSessionId: string | null = null, + rectificationHandoff: ConsultationRectificationHandoff | null = null, + ): Promise { const originalQuestion = text; const question = text.trim(); - if (!question || !activeSession || !modelCatalog || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return; - - if (account.credits <= 0) { - openAccountDialog("redeem", creditTrigger.current); - return; - } + const currentSession = targetSessionId + ? sessions.find((session) => session.id === targetSessionId) + : activeSession; + if (!question || !currentSession || !modelCatalog || pendingSessionId + || cancellationInFlight.current || pendingConsultation.current || !account) return false; if (!isProfileComplete(profile)) { openAccountDialog("profile"); setProfileNotice("请先补充出生资料,才能进行星盘计算。"); - return; + return false; + } + + if (entrypoint === "birth_time_rectification") { + await openBirthTimeRectification(null); + return false; } const birthPlace = selectedBirthPlace(profile); - if (!birthPlace) return; + if (!birthPlace) return false; - const currentSession = activeSession; const theme = requestedTheme ?? currentSession.theme; const sessionId = currentSession.id; + const consentForDecision = consentGrantedForRequest === "unverified_birth_time" + ? grantBirthTimeConsultationConsent( + birthTimeConsultationConsent, + sessionId, + "unverified_birth_time", + ) + : birthTimeConsultationConsent; + const consultationRoute = resolveBirthTimeConsultationRoute( + profile, + consentForDecision, + sessionId, + ); + if (consultationRoute.kind === "choice") { + setPendingBirthTimeChoice({ + sessionId, + question, + entrypoint, + theme, + }); + setComposerNotice(consultationRoute.canUseUnverifiedTime + ? "请选择在当前聊天临时使用填报时间,或先校正再询问。" + : "你还没有可使用的具体出生分钟,可以先校正,或改问不依赖出生分钟的一般问题。"); + return false; + } + + if (account.credits <= 0) { + openAccountDialog("redeem", creditTrigger.current); + return false; + } + const [year, month, day] = profile.date.split("-").map(Number); - const [hour, minute] = profile.time.split(":").map(Number); + const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? []; const preservedMessages = onboardingJustCompleted && currentSession.messages.length === 0 ? completedOnboardingTranscript(profile, startGreeting) @@ -1991,7 +2233,7 @@ export default function Home() { messages: [...preservedMessages, { role: "user", text: question }], updatedAt: timestamp(), }; - const requestId = globalThis.crypto.randomUUID(); + const requestId = rectificationHandoff?.requestId ?? globalThis.crypto.randomUUID(); const controller = new AbortController(); const previousOnboardingState = onboardingJustCompleted; cancellationFeedbackRequest.current = null; @@ -2005,6 +2247,7 @@ export default function Home() { question: originalQuestion, entrypoint, theme, + rectificationHandoff, previousSession: currentSession, optimisticSession: userSession, previousOnboardingState, @@ -2036,7 +2279,7 @@ export default function Home() { await new Promise((resolve) => window.setTimeout(resolve, uiPreviewMode.current === "streaming" || uiPreviewMode.current === "partial" ? 15_000 : 800)); if (controller.signal.aborted) { if (pendingConsultation.current?.requestId === requestId) pendingConsultation.current = null; - return; + return false; } const previewReply = parseAgentReply([ "这是本地交互预览。正式对话会结合你的星盘证据继续分析。", @@ -2055,11 +2298,11 @@ export default function Home() { }; updateSession(sessionId, () => previewSession); completeConsultationInterface(requestId); - return; + return true; } await waitForUndoWindow(controller.signal); - if (controller.signal.aborted) return; + if (controller.signal.aborted) return false; if (pendingConsultation.current?.requestId === requestId) { pendingConsultation.current = { ...pendingConsultation.current, @@ -2076,24 +2319,28 @@ export default function Home() { body: JSON.stringify({ requestId, modelId: currentSession.modelId, - entrypoint: entrypoint ?? undefined, name: profile.name, - year, - month, - day, - hour, - minute, - city: birthPlace.label, - lat: birthPlace.lat, - lon: birthPlace.lon, - tz: birthPlace.tz, + consultationMode: consultationRoute.mode, + ...(consultationRoute.mode === "general_no_birth_time" ? {} : { + entrypoint: entrypoint ?? undefined, + year, + month, + day, + hour, + minute, + city: birthPlace.label, + lat: birthPlace.lat, + lon: birthPlace.lon, + tz: birthPlace.tz, + entryMode: "direct_chart" as const, + }), theme, - entryMode: profile.birthTimeStatus === "confirmed" ? "direct_chart" : "rectification", question, history: currentSession.messages.slice(-12).map((message) => ({ role: message.role, text: message.text.slice(0, 4000), })), + ...(rectificationHandoff ? { rectificationHandoff } : {}), }), signal: controller.signal, }); @@ -2126,7 +2373,7 @@ export default function Home() { } } answer += decoder.decode(); - if (controller.signal.aborted) return; + if (controller.signal.aborted) return Boolean(latestPartialReply); if (!answer.trim()) throw new Error("Agent 没有返回内容,请重试。"); const reply = parseAgentReply(answer, theme); if (!reply.text) throw new Error("Agent 没有返回可显示的回答,请重试。"); @@ -2148,6 +2395,7 @@ export default function Home() { }); } void refreshAccount(); + return true; } catch (caught) { const cancelled = controller.signal.aborted; const ownsInterface = pendingConsultation.current?.requestId === requestId; @@ -2171,12 +2419,14 @@ export default function Home() { } } } - if (ownsInterface && !partialReply) { + if (ownsInterface && !partialReply && !rectificationHandoff) { await confirmCancellation( requestId, sessionId, "问题已放回输入框,本次未扣点。", ); + } else if (ownsInterface && !partialReply && rectificationHandoff) { + setComposerNotice("原问题仍保留;请刷新校正状态后重试,本次不会重复扣点。"); } else if (!cancelled && ownsInterface) { const interruptedSession: ChatSession = { ...userSession, @@ -2200,6 +2450,7 @@ export default function Home() { setComposerNotice("回答中途断开,已保留现有内容,本次已计费。"); } } + return Boolean(partialReply); } finally { cancellationRequests.current.delete(requestId); completeConsultationInterface(requestId); @@ -2216,6 +2467,187 @@ export default function Home() { } } + + async function continueRectificationOriginalQuestion(question: string) { + if (rectificationContinuationInFlight.current || rectificationMutationPending + || rectificationLoading || !activeSession || !account) return; + const confirmedTurn = rectificationInitialTurn; + if (!confirmedTurn || confirmedTurn.status !== "completed" + || confirmedTurn.pendingConsultationQuestion !== question + || !confirmedTurn.actions.includes("continue_original_question")) return; + if (account.credits <= 0) { + openAccountDialog("redeem", creditTrigger.current); + return; + } + if (rectificationQuestionHandoff.current.peek() + && !sessions.some((session) => session.id === rectificationQuestionHandoff.current.peek()?.sessionId)) { + rectificationQuestionHandoff.current.clear(); + } + + rectificationContinuationInFlight.current = true; + setRectificationContinuationPending(true); + setRectificationError(""); + try { + const durableClaim = await durableRectificationQuestionHandoff.current.claim({ + caseId: confirmedTurn.caseId, + turnVersion: confirmedTurn.turnVersion, + question, + }); + if (durableClaim.status === "in_progress") { + setComposerNotice("原问题正在另一设备继续回答;完成后刷新即可查看,不会重复扣点。"); + return; + } + if (durableClaim.status === "consumed") { + setRectificationSurfaceOpen(false); + setRectificationPendingQuestion(null); + setRectificationInitialTurn(null); + setComposerNotice("原问题已经继续回答,不会再次发送或扣点。"); + return; + } + if (durableClaim.status !== "claimed") { + setComposerNotice("原问题仍保留,请刷新校正状态后重试。"); + return; + } + const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion( + question, + { sessionId: activeSession.id, theme: activeSession.theme }, + async (context) => { + activeSessionIdRef.current = context.sessionId; + setActiveSessionId(context.sessionId); + setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent( + current, + context.sessionId, + )); + return send( + context.question, + context.theme, + null, + null, + context.sessionId, + { + caseId: durableClaim.caseId, + turnVersion: durableClaim.turnVersion, + claimActionId: durableClaim.claimActionId, + requestId: durableClaim.requestId, + }, + ); + }, + ); + if (completed) { + setRectificationSurfaceOpen(false); + setRectificationPendingQuestion(null); + setRectificationInitialTurn(null); + setComposerNotice("已使用新确认时间继续回答原问题。"); + } else { + setComposerNotice("原问题仍保留,可再次点击继续回答。"); + } + } catch { + setComposerNotice("原问题仍保留,可再次点击继续回答。"); + } finally { + rectificationContinuationInFlight.current = false; + setRectificationContinuationPending(false); + } + } + + function restoreQuestionFromRectification() { + if (rectificationLoading || rectificationMutationPending + || rectificationContinuationInFlight.current) return; + const durableQuestion = rectificationInitialTurn?.pendingConsultationQuestion + ?? rectificationPendingQuestion + ?? rectificationQuestionHandoff.current.peek()?.question + ?? null; + let handoff = activeSession + ? rectificationQuestionHandoff.current.synchronizeDurableQuestion( + durableQuestion, + { sessionId: activeSession.id, theme: activeSession.theme }, + ) + : null; + const handoffSessionId = handoff?.sessionId ?? null; + if (handoffSessionId + && !sessions.some((session) => session.id === handoffSessionId) + && activeSession) { + rectificationQuestionHandoff.current.clear(); + handoff = rectificationQuestionHandoff.current.synchronizeDurableQuestion( + durableQuestion, + { sessionId: activeSession.id, theme: activeSession.theme }, + ); + } + if (handoff) { + activeSessionIdRef.current = handoff.sessionId; + setActiveSessionId(handoff.sessionId); + setDraft(handoff.question); + setDraftTheme(handoff.theme); + setDraftEntrypoint(null); + setComposerNotice("原问题已放回输入框;没有发起普通咨询,也未扣咨询点数。"); + rectificationQuestionHandoff.current.clear(); + window.requestAnimationFrame(() => composerInput.current?.focus()); + } + setRectificationSurfaceOpen(false); + setRectificationInitialTurn(null); + } + + function useUnverifiedTimeForPendingConsultation() { + if (!pendingBirthTimeChoice + || !activeSession + || pendingBirthTimeChoice.sessionId !== activeSession.id + || !canUseUnverifiedBirthTime(profile)) return; + const pending = pendingBirthTimeChoice; + setBirthTimeConsultationConsent((current) => grantBirthTimeConsultationConsent( + current, + activeSession.id, + "unverified_birth_time", + )); + setPendingBirthTimeChoice(null); + setComposerNotice("本次聊天会标明出生时间尚未校正;新聊天会重新提醒。"); + void send(pending.question, pending.theme, pending.entrypoint, "unverified_birth_time"); + } + + function continueGenerallyWithoutBirthTime() { + if (!pendingBirthTimeChoice + || !activeSession + || pendingBirthTimeChoice.sessionId !== activeSession.id + || canUseUnverifiedBirthTime(profile)) return; + const pending = pendingBirthTimeChoice; + setBirthTimeConsultationConsent((current) => grantBirthTimeConsultationConsent( + current, + activeSession.id, + "general_no_birth_time", + )); + setDraft(pending.question); + setDraftTheme("general"); + // Product entrypoints such as "今日星语" require a chart. General mode + // deliberately restores only visible text and never carries hidden routing. + setDraftEntrypoint(null); + setPendingBirthTimeChoice(null); + setComposerNotice("原问题尚未发送,也没有扣点。请把它改成不依赖个人出生分钟的一般问题后再发送。"); + window.requestAnimationFrame(() => composerInput.current?.focus()); + } + + function rectifyBeforePendingConsultation() { + if (!pendingBirthTimeChoice + || !activeSession + || pendingBirthTimeChoice.sessionId !== activeSession.id) return; + const pending = pendingBirthTimeChoice; + rectificationQuestionHandoff.current.capture({ + question: pending.question, + sessionId: pending.sessionId, + theme: pending.theme, + }); + setPendingBirthTimeChoice(null); + setComposerNotice(""); + void openBirthTimeRectification(pending.question); + } + + function cancelPendingBirthTimeChoice() { + if (!pendingBirthTimeChoice) return; + setDraft(pendingBirthTimeChoice.question); + setDraftTheme(pendingBirthTimeChoice.theme); + setDraftEntrypoint(pendingBirthTimeChoice.entrypoint); + setPendingBirthTimeChoice(null); + setComposerNotice(""); + window.requestAnimationFrame(() => composerInput.current?.focus()); + } + function submit(event: FormEvent) { event.preventDefault(); if (!profileComplete) { @@ -2384,7 +2816,7 @@ export default function Home() {
出生时间按你实际知道的程度填写,不需要猜测
- setProfileDraft((current) => ({ ...current, ...patch }))} /> + setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} /> {accountError &&

{accountError}

}
@@ -2432,7 +2864,7 @@ export default function Home() { {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} - {profileComplete && presetMessageFinished && (onboardingPending ? ( + {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && !activeBirthTimeChoice && (onboardingPending ? (
正在准备三个入门问题…
) : (
@@ -2462,9 +2894,9 @@ export default function Home() { +
+ {rectificationLoading ? ( +

正在恢复账户里的校正进度…

+ ) : rectificationError ? ( +
+

{rectificationError}

+ +
+ ) : ( + void continueRectificationOriginalQuestion(question)} + /> + )} + + )}
{activeSuggestions.length > 0 && (
{activeSuggestions.map((question) => ( - + ))}
)} @@ -2543,7 +3037,7 @@ export default function Home() { : "例如:未来半年是否适合换工作?"} rows={1} maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500} - disabled={isLoading || cancellationPending || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} + disabled={isLoading || cancellationPending || Boolean(activeBirthTimeChoice) || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} value={draft} onChange={(event) => { setDraft(event.target.value); @@ -2565,7 +3059,7 @@ export default function Home() {
-
{displayState.kind === "candidate" ? "当前工作排盘时间" : "当前排盘时间"}
+
{displayState.kind === "candidate" ? "待验证候选时间" : "当前排盘时间"}
{displayState.activeTime}
@@ -78,7 +79,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
{displayState.kind === "candidate" && ( -

已用于当前排盘,但仍保留为候选结果,不会标记成出生记录中的确定分钟。

+

这仍是未确认候选,不会自动成为出生分钟;{birthTimeConsultationOptionsCopy(value)}

)} )} diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx new file mode 100644 index 00000000..54b69f68 --- /dev/null +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -0,0 +1,494 @@ +"use client"; + +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import { ChatMessageContent } from "./chat-message-content.tsx"; +import { + useConversationalRectification, + type ConversationalRectificationController, +} from "../hooks/use-conversational-rectification.ts"; +import type { ConversationalRectificationTurn } from "../lib/conversational-rectification/contracts.ts"; + +type EvidenceDomain = NonNullable< + ConversationalRectificationTurn["evidenceRequest"] +>["domains"][number]; + +const domainLabels = { + career: "事业与身份", + education: "学业与学习", + relocation: "搬迁与居住地", + relationship: "重要关系", + family: "家庭变化", + other: "其他关键经历", +} as const satisfies Readonly>; + +type SurfaceProps = Readonly<{ + controller: ConversationalRectificationController; + pendingConsultationQuestion?: string | null; + continuationPending?: boolean; + onContinueOriginalQuestion?: (question: string) => void; +}>; + +function safely(request: Promise) { + void request.catch(() => undefined); +} + +function CandidateSummary({ turn }: { readonly turn: ConversationalRectificationTurn }) { + const candidate = turn.candidate; + const confirmed = candidate.status === "confirmed" && turn.status === "completed"; + const status = confirmed + ? "已明确确认" + : candidate.status === "ready_for_confirmation" + ? "待确认 · 未验证" + : "待验证 · 未确认"; + + return ( +
+
+

候选时间

+ {status} +
+ {candidate.representativeTime ? ( +
+
+
代表时间
+
+
+
+
候选范围
+
{candidate.rangeStart && candidate.rangeEnd + ? `${candidate.rangeStart}—${candidate.rangeEnd}` + : "尚未缩小"}
+
+
+ ) :

尚未形成可供确认的具体时间。

} + {!confirmed &&

候选仍待真实经历验证,未经你的明确确认不会成为当前排盘时间。

} +
+ ); +} + +function TechnicalReceipt({ turn }: { readonly turn: ConversationalRectificationTurn }) { + const receipt = turn.technicalReceipt; + return ( +
+ 本轮技术回执 +
+
计算版本
{receipt.calculationVersion}
+
稳定层
{receipt.stableLayers.join("、") || "无"}
+
分钟敏感层
{receipt.sensitiveLayers.join("、") || "无"}
+
候选差异引用
{receipt.candidateDifferenceRefs.join("、") || "无"}
+
+
+ ); +} + +export function ConversationalRectificationSurface({ + controller, + pendingConsultationQuestion, + continuationPending = false, + onContinueOriginalQuestion, +}: SurfaceProps) { + const [abandonArmedFor, setAbandonArmedFor] = useState(null); + const [localAnnouncement, setLocalAnnouncement] = useState | null>(null); + const composer = useRef(null); + const abandonTrigger = useRef(null); + const abandonCancel = useRef(null); + const abandonConfirm = useRef(null); + const terminalStatus = useRef(null); + const restoreAbandonFocus = useRef(false); + const focusTerminalForCase = useRef(null); + const turn = controller.turn; + const pendingQuestion = turn?.status === "completed" + ? turn.pendingConsultationQuestion + : turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; + const abandonIdentity = turn + ? `${turn.caseId}:${turn.turnVersion}:${turn.status}` + : null; + const canAbandon = Boolean( + turn?.actions.includes("abandon") + && turn.status !== "abandoned" + && turn.status !== "completed", + ); + const abandonArmed = canAbandon && abandonArmedFor === abandonIdentity; + const statusAnnouncement = localAnnouncement?.identity === abandonIdentity + ? localAnnouncement.message + : ""; + + useEffect(() => { + if (abandonArmed) { + abandonCancel.current?.focus(); + return; + } + if (restoreAbandonFocus.current) { + restoreAbandonFocus.current = false; + abandonTrigger.current?.focus(); + } + }, [abandonArmed]); + + useEffect(() => { + const requestedCase = focusTerminalForCase.current; + if (!requestedCase) return; + if (!turn || turn.caseId !== requestedCase) { + focusTerminalForCase.current = null; + return; + } + if (turn.status === "abandoned") { + focusTerminalForCase.current = null; + terminalStatus.current?.focus(); + } + }, [turn]); + + if (!turn) { + return ( +
+
+

系统会先说明候选边界,再邀请你提供已经发生的真实经历。

+ +
+ {controller.error &&

{controller.error}

} +
+ ); + } + + const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed"; + const requestedDomains = turn.evidenceRequest?.domains ?? []; + const submit = () => { + if (canAnswer && controller.draft.trim() && !controller.pending) safely(controller.answer()); + }; + const focusComposer = () => composer.current?.focus(); + const continueLocally = () => { + if (abandonIdentity) { + setLocalAnnouncement({ + identity: abandonIdentity, + message: "现在可以继续填写真实经历,输入框已就绪;发送后才会推进校正进度。", + }); + } + focusComposer(); + }; + const closeAbandonDialog = () => { + restoreAbandonFocus.current = true; + setAbandonArmedFor(null); + }; + const handleAbandonDialogKey = (event: ReactKeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeAbandonDialog(); + return; + } + if (event.key !== "Tab") return; + if (event.shiftKey && document.activeElement === abandonCancel.current) { + event.preventDefault(); + abandonConfirm.current?.focus(); + } else if (!event.shiftKey && document.activeElement === abandonConfirm.current) { + event.preventDefault(); + abandonCancel.current?.focus(); + } + }; + const confirmAbandon = () => { + focusTerminalForCase.current = turn.caseId; + void controller.abandon().catch(() => { + if (focusTerminalForCase.current === turn.caseId) { + focusTerminalForCase.current = null; + } + }); + }; + + return ( +
+
+ +
+ + {requestedDomains.length > 0 && ( +
+ 这轮想先补充哪个领域? +
+ {requestedDomains.map((domain) => ( + + ))} +
+
+ )} + + { + event.preventDefault(); + submit(); + }} + > + {controller.correctionTarget && ( +
+

+ 正在更正:{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary} +

+ 一次只更正一条事件。提交后会保留原记录用于审计,但候选评分只使用更正后的有效证据。 + +
+ )} + +