diff --git a/CHANGELOG.md b/CHANGELOG.md index 36095f83..c8094535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-03 — 岁差成为用户设置(默认仍是 Raman) + +自 `80102459`(2026-08-20)起,产品计算默认岁差已是 Raman,但参考集对照仍按 Lahiri 口径写死期望值,API 回执里的岁差标签也和实际计算对不上。本轮**不改**默认值(仍是 Raman),把岁差做成账户设置:`raman` / `lahiri` / `kp` / `true_pushya`。公开案例复验与夹具大运显式传 `--ayanamsa lahiri`。升级后老用户可能看到 Moon nakshatra 或大运主星与 8 月 20 日前的 Lahiri 盘不同;可在「星盘资料 → 编辑本人资料」里切回 Lahiri。切换只影响之后的新计算。 + ## 2026-08-30 — 非收敛区间提议不再是无出口死胡同(Skill 10.0.13 不变) 训练门已开、覆盖层仍挡住确认、可渲染区分题又问不出来时,助手会说「可以先按当前区间看盘,也可以再补一件记得时间的经历」,但时间卡、下一问、区间数值都不给。本轮把这句话改成与真实承载一致。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 974124d3..fbff19a9 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7872,3 +7872,19 @@ - 相关记录:BUG-416、BUG-431、BUG-498 - 复发自:BUG-431 - 修复版本:待发布 + +## BUG-511 | 公开案例复验因默认岁差改 Raman 而失败,标签与计算不一致 + +- 状态:resolved +- 首次发现:2026-09-03 +- 最近更新:2026-09-03 +- 影响面:`tests/run_real_case_revalidation.py`、`scripts/jyotish_api_server.py` 岁差回执、账户 `profiles.ayanamsa`、咨询/报告/校正/每日星语请求口径 +- 用户现象:发布门真实案例复验 `valid=false`,gated 通过率掉到门槛以下。同一张虚构盘在旧生产与当前默认下 Moon nakshatra / 首段大运不同。回执上的岁差名有时写成 Lahiri 或 Raman,与该次实际计算不是同一套。 +- 触发条件:`80102459` 把引擎默认改成 Raman 后,参考集调用没有显式锁定 Lahiri;VedAstro `ayanamsa_policy` 与部分回执字段仍写死字面量。 +- 根因:参考集对照依赖「默认值恰好是 Lahiri」;产品默认改为 Raman 后口径漂移。三处标签用字面量兜底,不读该次请求实际岁差。账户没有用户可选岁差。 +- 修复:参考集与夹具大运显式 `--ayanamsa lahiri`。API 回执与 VedAstro 对照改用该次请求实际岁差。`profiles.ayanamsa` 默认 `raman`,设置里可选四个值;咨询、报告、校正、星盘库、每日星语走 `resolveAyanamsa`。`DEFAULT_AYANAMSA_NAME` 本轮不改。 +- 验证:夹具大运日期不变;前端 `resolveAyanamsa`、账户 PATCH 四值校验、咨询 toolInput 带 profile 岁差、校正快照带岁差但不进 fingerprint;`npm run test:db` 跑迁移。公开案例复验命令见本轮 PROGRESS。 +- 防复发:参考集与外部对照必须显式传岁差,不得依赖默认值。回执只显示实际名。岁差不进校正 `baselineFingerprint`,不重算历史报告。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-repo-hygiene-20260903.md b/docs/tasks/PROGRESS-repo-hygiene-20260903.md new file mode 100644 index 00000000..3d433bc8 --- /dev/null +++ b/docs/tasks/PROGRESS-repo-hygiene-20260903.md @@ -0,0 +1,72 @@ +# PROGRESS · 仓库整备(2026-09-03) + +工作树:`/Users/jesse/Downloads/Copse/astrology/.worktrees/repo-hygiene-20260903` +分支:`codex/repo-hygiene-20260903` +基线:开工后快进到 `origin/staging` @ `58cae371`(含 BUG-510 与上游同步任务书)。任务书:`docs/tasks/TASK-repo-hygiene-20260903.md`。 +未碰 `main`。未改 `DEFAULT_AYANAMSA_NAME`(保持 `raman`)。未改校正打分/阈值/golden。未改 `page.tsx`(仍 2041 行)。`scripts/jyotish_api_server.py` 仍 11120 行(与 rebase 后 HEAD 相同)。 + +BUG 编号:开工时 `docs/BUG_HISTORY.md` 最大号是 **BUG-510**。本轮岁差记 **BUG-511**。 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 0 岁差设置 + 参考集锁口径 | 待验收 | 本提交。产品 staging 实测仍欠 | +| 1 发布门本地可复现 | 未开始 | 任务 0 先推 | +| 2 注册表白名单 | 未开始 | | +| 3 仓库地址 | 未开始 | | +| 4 早期日志搬家 | 未开始 | | +| 5 远端分支清理 | 未开始 | | + +## 环境备忘 + +- 本 worktree **没有** `.venv`。预检与 Python 测试用主仓 `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv`(`mcp` 1.28.1,`hypothesis` 已装)。 +- 任务书写把迁移放进 `frontend/db/migrations/`。实际 `frontend/scripts/db-migrate.mjs` 会跑 `frontend/db/migrations` **和** `frontend/supabase/migrations`。账户资料列修复历来放 supabase(`account-api` 还断言同类 SQL 不在 `db/migrations`)。本轮唯一迁移是 `frontend/supabase/migrations/20260903010000_profile_ayanamsa.sql`,避免两个目录同名撞车。 +- 产品文案里的「账户与出生资料」在现界面是 **星盘资料** 里的本人编辑表,不是 `个人资料`(MVP 合同禁止在那里放出生 UI)。岁差单选加在本人星盘表单,未改 `page.tsx`。 +- `npm run test:db` 与 `npx tsx --test tests/rectification-*.test.ts` 不可并行:默认 `JYOTISHA_POSTGRES_MAX_FIXTURES=2`,抢槽时前几个库测会报泛化 `database migration failed`。隔离重跑后绿。 + +## 任务 0 + +### 0.a 参考集锁口径 + +- `tests/run_real_case_revalidation.py`:`REFERENCE_AYANAMSA = "lahiri"`,引擎 argv `--ayanamsa lahiri`,报告 `reference_ayanamsa`。 +- `scripts/local_accuracy_report.py` 同口径。 +- 夹具大运:`base` 加 `--ayanamsa lahiri`。三栏:原值=依赖默认;新值=显式 lahiri;原因=夹具日期是 Lahiri 口径。期望日期未改。 + +### 0.b 标签与计算一致 + +- `scripts/jyotish_api_server.py` 经 `_request_ayanamsa` / `DEFAULT_AYANAMSA_NAME` / `ayanamsa_display_name`,不再写死 `'lahiri'` / `'Raman'`。 +- `_request_ayanamsa` 也读 `ayanamsa_policy`,VedAstro 对照与该次请求同口径。 +- `DEFAULT_AYANAMSA_NAME` 仍是 `'raman'`。 +- 验收 grep(`scripts/jyotish_api_server.py` + `frontend/src`,排除 test):只剩 `frontend/src/lib/ayanamsa.ts` 选项/默认,以及 `personal-report-generation.ts` 的 `SAFE_AYANAMSA` 显示映射。 + +### 0.c 用户设置 + +- `frontend/src/lib/ayanamsa.ts`:`resolveAyanamsa`,默认 `raman`。 +- 迁移:`profiles.ayanamsa text not null default 'raman'` + 四值 check + `grant update (ayanamsa)` 给 `authenticated`。 +- 咨询、声明窗口、报告 worker、每日星语、合盘、校正 v9 引擎请求、生时评估 scan 均传实际岁差。 +- 校正 `V9BaselineSnapshot` 新写入带 `ayanamsa`;**不进** `baselineFingerprint`。 +- 岁差不进 `declarationFields` / `concurrencyFields`,改设置不 409、不清确认。 + +### 偏离 + +- 迁移目录:supabase,原因见环境备忘。 +- UI 位置:星盘资料本人编辑,不是不存在的「账户与出生资料」面板。 + +### 命令摘要 + +| 命令 | 结果 | +| --- | --- | +| `python tests/run_real_case_revalidation.py` | `valid: true`,`reference_ayanamsa: lahiri`,gated **66/66**,`pass_rate: 1.0` | +| `pytest …test_fixture_dasha_timeline…` + `test_api_server_growth_contract.py` | 4 passed;夹具日期未改;API 行数 11120 | +| `scripts/user_invocation_acceptance_check.py` | `status: pass`,`checks.user_invocation_tests: true`,`external_adapter_status: complete`(本机 PyJHora 可用) | +| `npx tsc --noEmit` | 0 错 | +| `npx eslint .` | 0 error,73 既有 warning | +| 前端聚焦:ayanamsa / account-api / consultation-route-service / server-owned-birth-profile / global-birth-profile-routes / settings-mvp / rectification-v9-case-service / consultation-workflow-request / consultation-birth-accuracy / consultation-agentic-runtime / personal-report-api / rectification-v9-engine-contract | fail=0 | +| `npm run test:db`(`--test-concurrency=1`,单独跑) | **34 pass / 0 fail** | +| `npx tsx --test tests/rectification-*.test.ts` 隔离重跑库测 | ingest P0 + v9-database **10 pass / 0 fail**;未改 scorer/golden | +| `wc -l frontend/src/app/page.tsx` | 2041,与 HEAD 相同 | + +产品负责人 staging 实测(虚构盘 2000-01-01 12:00 UTC 0/0:Lahiri=Swati,Raman=Vishakha)仍欠。部署 SHA 待合入 staging 后补。 + +## 任务 1–5 + +尚未开始。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index fb2b31ab..cba0a700 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -91,7 +91,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-repo-hygiene-20260903.md` | — | 岁差默认 Raman + 用户设置可选、参考集锁口径(P0,单独先推)、发布门本地可复现、注册表白名单、仓库地址、早期日志搬家、远端分支清理 | 待领取 | 分支 `codex/repo-hygiene-20260903` | +| `TASK-repo-hygiene-20260903.md` | `PROGRESS-repo-hygiene-20260903.md` | 岁差默认 Raman + 用户设置可选、参考集锁口径(P0,单独先推)、发布门本地可复现、注册表白名单、仓库地址、早期日志搬家、远端分支清理 | 执行中 | 分支 `codex/repo-hygiene-20260903` | ### 上游同步与能力接入 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 23e3722b..ec9eb848 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -200,6 +200,16 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows. - **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative. +### Ayanamsa preference + +The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa radios. Personal 个人资料 stays account basics only. + +- **Structure:** four 44px stacked radios reused from `.theme-preference-option`, plus a caption under the group. Raman is marked 默认. Each option has a one-line description; selected rows show the same `Check` as the theme panel. +- **Values:** `raman` (default) / `lahiri` / `kp` / `true_pushya`. Copy lives in `frontend/src/lib/ayanamsa.ts`. +- **Hint:** “切换只影响之后的新计算;已生成的报告和校正结果保持当时的口径。” +- **Placement:** after `ProfileFields` on the self-profile form only. Not on other-chart save, not on `page.tsx`. +- **States:** default, hover, selected (`aria-checked` only), keyboard focus. Switching does not clear a confirmed birth minute. + ### Rectification surface The birth-time rectification session is the consultation transcript plus a house board; questions live inside the assistant message that asked them. It shares every waiting vocabulary with the consultation surface; nothing here spins or says "loading" after the reveal. diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 92886e9d..96cad9c8 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -49,7 +49,7 @@ export async function GET() { const admin = createAdminSupabaseClient(); let { data: profile, error: profileError } = await supabase .from("profiles") - .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id") + .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,name,birth_time,rectification_case_id") .eq("id", userId) .single(); @@ -64,6 +64,7 @@ export async function GET() { ...withoutDeclaredWindow.data, declared_window_start: null, declared_window_end: null, + ayanamsa: undefined, }; profileError = withoutDeclaredWindow.error; } else if (withoutDeclaredWindow.error && isMissingProfileColumn(withoutDeclaredWindow.error)) { @@ -82,6 +83,7 @@ export async function GET() { birth_place_provider_id: undefined, timezone_id: undefined, timezone_source: undefined, + ayanamsa: undefined, } : null; profileError = fallback.error; } else { @@ -281,6 +283,7 @@ export async function PATCH(request: Request) { ...(payload.birth_place_provider_id !== undefined ? { birth_place_provider_id: payload.birth_place_provider_id } : {}), ...(payload.timezone_id !== undefined ? { timezone_id: payload.timezone_id } : {}), ...(payload.timezone_source !== undefined ? { timezone_source: payload.timezone_source } : {}), + ...(payload.ayanamsa !== undefined ? { ayanamsa: payload.ayanamsa } : {}), }; const withoutCoordinates = baseProfile; const invalidatesUnconfirmedApplication = Object.keys(applicationPatch).length > 0; diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index 1f1ba2c6..bc74187c 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { parseBirthTimeProfile } from "@/lib/birth-time-journey-adapters"; +import { resolveAyanamsa } from "@/lib/ayanamsa"; import { BirthProfileTimezoneError, resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone"; import { createJyotishBirthTimeJourneyEngine, @@ -96,7 +97,7 @@ export async function POST(request: Request) { case "assess": { const { data: profile, error } = await supabase .from("profiles") - .select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset") + .select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa") .eq("id", user.id) .maybeSingle(); if (error) throw new BirthTimeJourneyStoreError("load_case"); @@ -107,7 +108,10 @@ export async function POST(request: Request) { ); } const assessment = parseBirthTimeProfile(await resolveMissingBirthTimezoneOffset(profile)); - return responseWithJourneyMetric(service.assess(user.id, assessment), "turn_advanced"); + return responseWithJourneyMetric( + service.assess(user.id, { ...assessment, ayanamsa: resolveAyanamsa(profile) }), + "turn_advanced", + ); } case "answer_question": return responseWithJourneyMetric(service.answerQuestion( diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 076e7f2d..a95adf01 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -363,7 +363,7 @@ export async function POST(request: Request) { 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_period,declared_window_start,declared_window_end,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source") + .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa") .eq("id", profileUserId) .single(); if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable"); diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index ccab3ae5..5e706b0f 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -20,7 +20,7 @@ function createProfileRepository( async loadProfile(userId) { return admin .from("profiles") - .select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,onboarding_payload,onboarding_version,onboarding_generated_at") + .select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,onboarding_payload,onboarding_version,onboarding_generated_at") .eq("id", userId) .maybeSingle(); }, diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 6ce35f69..33becccb 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -164,7 +164,7 @@ export async function POST(request: Request) { if (userId) { const result = await supabase .from("profiles") - .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,latitude,longitude,timezone_offset,birth_place_label") + .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,latitude,longitude,timezone_offset,birth_place_label,ayanamsa") .eq("id", userId) .maybeSingle(); profile = result.data ?? null; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f94c7f73..786b380d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1623,6 +1623,10 @@ button:disabled { cursor: default; opacity: .45; } .theme-preference-option:hover, .theme-preference-option[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); } .theme-preference-option > svg { width: 18px; height: 18px; color: var(--color-ink-tertiary); } .theme-preference-option > svg:last-child { color: var(--color-action); } +.ayanamsa-preference-option { grid-template-columns: minmax(0, 1fr) 18px; } +.ayanamsa-preference-option[aria-checked="true"] { border-color: var(--color-action); background: var(--color-action-soft); } +.ayanamsa-preference-option small { display: block; margin-top: var(--space-1); color: var(--color-ink-tertiary); font-size: var(--type-caption); font-weight: 400; } +.ayanamsa-preference-hint { margin: var(--space-3) 0 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; } .account-menu-danger, .account-menu-danger > svg { color: var(--color-danger); } .account-menu-danger[data-highlighted] { background: var(--color-danger-muted); } diff --git a/frontend/src/components/ayanamsa-preference-field.tsx b/frontend/src/components/ayanamsa-preference-field.tsx new file mode 100644 index 00000000..41644bdc --- /dev/null +++ b/frontend/src/components/ayanamsa-preference-field.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Check } from "lucide-react"; + +import { + AYANAMSA_OPTIONS, + AYANAMSA_SWITCH_HINT, + type AyanamsaName, +} from "@/lib/ayanamsa"; + +export function AyanamsaPreferenceField({ + value, + onChange, +}: { + readonly value: AyanamsaName; + readonly onChange: (next: AyanamsaName) => void; +}) { + return ( +
+
+ 岁差 + 用来把黄道转成印度占星用的恒星黄道 +
+
+ {AYANAMSA_OPTIONS.map((option) => { + const selected = value === option.value; + return ( + + ); + })} +
+

{AYANAMSA_SWITCH_HINT}

+
+ ); +} diff --git a/frontend/src/components/chart-library-panel.tsx b/frontend/src/components/chart-library-panel.tsx index 1209c636..c1ca5d74 100644 --- a/frontend/src/components/chart-library-panel.tsx +++ b/frontend/src/components/chart-library-panel.tsx @@ -13,7 +13,18 @@ import { readProfile, upsertSelfChart, } from "@/lib/home-profile"; -import { emptyProfile, timestamp, type Account, type ChartLibraryRecord, type ChartRelationship, type Profile, type SynastryRelationshipType, type SynastryReportCard } from "@/lib/home-types"; +import { + emptyProfile, + timestamp, + type Account, + type ChartLibraryRecord, + type ChartRelationship, + type Profile, + type SynastryRelationshipType, + type SynastryReportCard, +} from "@/lib/home-types"; +import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field"; +import { resolveAyanamsa } from "@/lib/ayanamsa"; export type ChartLibraryPanelProps = { readonly account: Account | null; @@ -179,6 +190,10 @@ export function ChartLibraryPanel({
编辑本人星盘这份资料会用于默认解盘与新对话。
+ setProfileDraft({ ...profileDraft, ayanamsa })} + /> {profileNotice &&

{profileNotice}

}
diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts index f5695264..d27750e6 100644 --- a/frontend/src/hooks/use-profile-onboarding.ts +++ b/frontend/src/hooks/use-profile-onboarding.ts @@ -32,6 +32,7 @@ import { readProfile, selectedBirthPlace, } from "@/lib/home-profile"; +import { resolveAyanamsa } from "@/lib/ayanamsa"; import { createStartGreeting } from "@/lib/onboarding-client"; import { timestamp, @@ -226,6 +227,7 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { timezone_id: nextProfile.timezoneId || null, timezone_offset: birthPlace?.tz ?? null, timezone_source: nextProfile.timezoneSource || null, + ayanamsa: resolveAyanamsa(nextProfile), }), }); const payload = await response.json().catch(() => null) as { diff --git a/frontend/src/lib/account-profile-patch.ts b/frontend/src/lib/account-profile-patch.ts index 014fee12..eaf4bf39 100644 --- a/frontend/src/lib/account-profile-patch.ts +++ b/frontend/src/lib/account-profile-patch.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts"; +import { AYANAMSA_VALUES } from "./ayanamsa.ts"; const nullableTrimmedString = (maximum: number) => z.string().trim().min(1).max(maximum).nullable(); const nullableBirthDate = z.string().refine((value) => parseBirthDate(value) !== undefined, { @@ -52,6 +53,7 @@ export const accountProfilePatchSchema = z.object({ birth_place_provider_id: nullableTrimmedString(160).optional(), timezone_id: nullableTrimmedString(80).optional(), timezone_source: z.literal("iana_historical").nullable().optional(), + ayanamsa: z.enum(AYANAMSA_VALUES).optional(), }).strict().superRefine((value, context) => { const source = value.birth_time_source; const time = value.reported_birth_time; diff --git a/frontend/src/lib/ayanamsa.ts b/frontend/src/lib/ayanamsa.ts new file mode 100644 index 00000000..dc046784 --- /dev/null +++ b/frontend/src/lib/ayanamsa.ts @@ -0,0 +1,49 @@ +export const AYANAMSA_VALUES = ["raman", "lahiri", "kp", "true_pushya"] as const; +export type AyanamsaName = (typeof AYANAMSA_VALUES)[number]; +export const DEFAULT_AYANAMSA: AyanamsaName = "raman"; + +export const AYANAMSA_OPTIONS = [ + { + value: "raman", + label: "Raman(默认)", + description: "本站现在默认用的岁差。", + }, + { + value: "lahiri", + label: "Lahiri", + description: "公开星历和教材里最常见的岁差。", + }, + { + value: "kp", + label: "KP", + description: "克里希那穆提体系用的岁差。", + }, + { + value: "true_pushya", + label: "True Pushya", + description: "True Pushya 岁差。", + }, +] as const satisfies ReadonlyArray<{ + value: AyanamsaName; + label: string; + description: string; +}>; + +export const AYANAMSA_SWITCH_HINT = + "切换只影响之后的新计算;已生成的报告和校正结果保持当时的口径。"; + +export function isAyanamsaName(value: unknown): value is AyanamsaName { + return typeof value === "string" && (AYANAMSA_VALUES as readonly string[]).includes(value); +} + +export function resolveAyanamsa( + profile?: { ayanamsa?: unknown } | null, +): AyanamsaName { + const raw = profile?.ayanamsa; + if (typeof raw === "string") { + const key = raw.trim().toLowerCase().replace(/-/g, "_"); + if (key === "krishnamurti" || key === "krishnamurti_paddhati") return "kp"; + if (isAyanamsaName(key)) return key; + } + return DEFAULT_AYANAMSA; +} diff --git a/frontend/src/lib/birth-rectification-payload.ts b/frontend/src/lib/birth-rectification-payload.ts index 7a7d8ea9..d7fc3ba8 100644 --- a/frontend/src/lib/birth-rectification-payload.ts +++ b/frontend/src/lib/birth-rectification-payload.ts @@ -1,4 +1,5 @@ import { chinaLocations } from "@/data/china-locations"; +import { resolveAyanamsa, type AyanamsaName } from "@/lib/ayanamsa"; import { finiteBirthNumber, resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone"; export type BirthRectificationProfile = { @@ -11,6 +12,7 @@ export type BirthRectificationProfile = { longitude?: number | null; timezoneId?: string; timezoneOffset?: number | null; + ayanamsa?: AyanamsaName | string | null; }; export async function payloadFromProfile(profile: BirthRectificationProfile) { @@ -33,5 +35,6 @@ export async function payloadFromProfile(profile: BirthRectificationProfile) { lat, lon, tz, + ayanamsa: resolveAyanamsa(resolved), }; } diff --git a/frontend/src/lib/birth-time-journey-assessment.ts b/frontend/src/lib/birth-time-journey-assessment.ts index c5c63be5..dbae900e 100644 --- a/frontend/src/lib/birth-time-journey-assessment.ts +++ b/frontend/src/lib/birth-time-journey-assessment.ts @@ -7,8 +7,9 @@ import type { BirthTimeAssessment, ScanStability, } from "./birth-time-journey.ts"; +import { resolveAyanamsa } from "./ayanamsa.ts"; -function scanInput(assessment: BirthTimeAssessment): JourneyScanInput | null { +function scanInput(assessment: BirthTimeAssessment & { readonly ayanamsa?: unknown }): JourneyScanInput | null { if (assessment.source === "unknown") return null; if (assessment.source === "period_only") { const periodScan = { @@ -25,7 +26,7 @@ function scanInput(assessment: BirthTimeAssessment): JourneyScanInput | null { lat: assessment.location.lat, lon: assessment.location.lon, tz: assessment.location.tz, - ayanamsa: "raman", + ayanamsa: resolveAyanamsa(assessment), }; } return { @@ -37,7 +38,7 @@ function scanInput(assessment: BirthTimeAssessment): JourneyScanInput | null { lat: assessment.location.lat, lon: assessment.location.lon, tz: assessment.location.tz, - ayanamsa: "raman", + ayanamsa: resolveAyanamsa(assessment), }; } @@ -55,7 +56,7 @@ function questionnaireStability( export async function scanAssessment( engine: Pick, - assessment: BirthTimeAssessment, + assessment: BirthTimeAssessment & { readonly ayanamsa?: unknown }, ): Promise<{ readonly stability: ScanStability; readonly questionnaire: RectificationQuestionnaire | null; diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts index a99bc4e0..9cc1d90c 100644 --- a/frontend/src/lib/birth-time-journey-service.ts +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -18,6 +18,7 @@ import type { DynamicStoredFields, LegacyStoredFields } from "./birth-time-journ import { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError } from "./birth-time-journey-errors.ts"; import { createDynamicJourneyMethods } from "./birth-time-dynamic-service-methods.ts"; import { createDynamicCandidateConfirmation } from "./birth-time-dynamic-candidate-confirmation.ts"; +import type { AyanamsaName } from "./ayanamsa.ts"; export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError }; @@ -53,7 +54,7 @@ export type JourneyScanInput = { readonly lat: number; readonly lon: number; readonly tz: number; - readonly ayanamsa: "raman" | "lahiri"; + readonly ayanamsa: AyanamsaName; }; export type JourneyScoreInput = { @@ -207,7 +208,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) { const dynamicMethods = createDynamicJourneyMethods(ports); const dynamicCandidates = createDynamicCandidateConfirmation(ports); return { - async assess(userId: string, assessment: BirthTimeAssessment): Promise { + async assess(userId: string, assessment: BirthTimeAssessment & { readonly ayanamsa?: unknown }): Promise { const scan = await scanAssessment(ports.engine, assessment); const snapshot = assessBirthTime(assessment, scan.stability); const persisted = { diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts index b11ab3b0..2133f36f 100644 --- a/frontend/src/lib/consultation-route-service.ts +++ b/frontend/src/lib/consultation-route-service.ts @@ -10,6 +10,7 @@ import { type ConsultationBirthTimeMode, } from "./consultation-birth-time-mode.ts"; import type { GeneralDailyReference } from "./general-daily-panchanga.ts"; +import { resolveAyanamsa, type AyanamsaName } from "./ayanamsa.ts"; export type ConsultationProfileTruthErrorCode = | "profile_unavailable" @@ -42,6 +43,7 @@ type ServerChartToolInput = Readonly<{ lat: number; lon: number; tz: number; + ayanamsa: AyanamsaName; declared_accuracy: DeclaredBirthAccuracy["declaredAccuracy"]; time_source: string; }>; @@ -84,6 +86,7 @@ export type DeclaredBirthWindowConsultation = Readonly<{ lat: number; lon: number; tz: number; + ayanamsa: AyanamsaName; rangeStart: string; rangeEnd: string; }>; @@ -352,6 +355,7 @@ function serverChartFromProfile( lat: latitude, lon: longitude, tz: timezoneOffset, + ayanamsa: resolveAyanamsa(profile), declared_accuracy: accuracy.declaredAccuracy, time_source: accuracy.timeSource, }), @@ -435,6 +439,7 @@ function declaredWindowFromProfile(value: unknown): DeclaredBirthWindowConsultat lat: latitude, lon: longitude, tz: timezoneOffset, + ayanamsa: resolveAyanamsa(profile), rangeStart: range.startTime, rangeEnd: range.endTime, }), diff --git a/frontend/src/lib/declared-window-chart.ts b/frontend/src/lib/declared-window-chart.ts index 276db754..a45f3130 100644 --- a/frontend/src/lib/declared-window-chart.ts +++ b/frontend/src/lib/declared-window-chart.ts @@ -61,6 +61,7 @@ export async function fetchDeclaredWindowChart( lat: toolInput.lat, lon: toolInput.lon, tz: toolInput.tz, + ayanamsa: toolInput.ayanamsa, range_start: toolInput.rangeStart, range_end: toolInput.rangeEnd, }), diff --git a/frontend/src/lib/global-birth-payloads.ts b/frontend/src/lib/global-birth-payloads.ts index 6759bf0e..5bffb2a5 100644 --- a/frontend/src/lib/global-birth-payloads.ts +++ b/frontend/src/lib/global-birth-payloads.ts @@ -1,5 +1,6 @@ import { chinaLocations } from "@/data/china-locations"; import { finiteBirthNumber, resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone"; +import { resolveAyanamsa, type AyanamsaName } from "@/lib/ayanamsa"; export type GlobalBirthProfile = { name?: string; @@ -13,6 +14,7 @@ export type GlobalBirthProfile = { longitude?: number | null; timezoneId?: string; timezoneOffset?: number | null; + ayanamsa?: AyanamsaName | string; }; function resolvedLocation(profile: GlobalBirthProfile) { @@ -46,7 +48,7 @@ export async function dailyProfilePayload(profile: GlobalBirthProfile, today: st tz, transit_date: today, today, - ayanamsa: "raman", + ayanamsa: resolveAyanamsa(profile), node_mode: "mean", }; } @@ -76,5 +78,6 @@ export async function synastryBirthPayload(profile: GlobalBirthProfile) { lat, lon, tz, + ayanamsa: resolveAyanamsa(profile), }; } diff --git a/frontend/src/lib/home-profile.ts b/frontend/src/lib/home-profile.ts index f3f7b7ea..b982a227 100644 --- a/frontend/src/lib/home-profile.ts +++ b/frontend/src/lib/home-profile.ts @@ -24,6 +24,7 @@ import { type Profile, type SynastryRelationshipType, } from "@/lib/home-types"; +import { resolveAyanamsa } from "@/lib/ayanamsa"; export function findProvince(code: string) { return china.provinces.find((province) => province.code === code); @@ -303,6 +304,7 @@ export function readProfile(value: unknown): Profile { latitude, longitude, timezoneOffset, + ayanamsa: resolveAyanamsa(profile), ...(chartRelationship ? { chartRelationship } : {}), }; } diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index 2a15ae6d..b16c4e18 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -1,6 +1,7 @@ import type { BeamAvatar } from "@/lib/beam-avatar"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import type { BirthTimeDraft } from "@/lib/birth-time-intake-model"; +import { DEFAULT_AYANAMSA, type AyanamsaName } from "@/lib/ayanamsa"; import type { AgentActivityView, ChatMessage } from "@/lib/chat-message-view"; import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline"; import type { PublicThinkingSection } from "@/lib/consultation-thinking-plan"; @@ -28,6 +29,7 @@ export type Profile = BirthTimeDraft & { longitude: number | null; timezoneOffset: number | null; rectificationCaseId: string; + ayanamsa: AyanamsaName; chartRelationship?: ChartRelationship; }; export type ChartRelationship = "self" | "partner" | "family" | "friend" | "client" | "other"; @@ -230,6 +232,7 @@ export const emptyProfile: Profile = { latitude: null, longitude: null, timezoneOffset: null, + ayanamsa: DEFAULT_AYANAMSA, }; export type StoredDailyStarlanguage = { readonly day: string; diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 5d93da16..0b155e7a 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -12,6 +12,7 @@ import { z } from "zod"; import type { ConsultationInput } from "@/mastra"; +import { resolveAyanamsa } from "@/lib/ayanamsa"; import type { ReportAgentPort, ReportEvidenceBundleV2, @@ -415,6 +416,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise; @@ -119,6 +121,7 @@ async function generateProductionReport(context: PersonalReportWorkerGenerationC lon: longitude, tz: timezoneOffset, city: birthPlaceLabel, + ayanamsa: resolveAyanamsa(profile), question: `请为个人报告计算 ${rawTheme} 主题证据`, theme: rawTheme as ConsultationInput["theme"], entryMode: "direct_chart", diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index 3fa00ebf..368221b5 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -10,6 +10,7 @@ import { createHash } from "node:crypto"; import type { SupabaseClient } from "@supabase/supabase-js"; import { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts"; import { declaredClockRange } from "../../declared-birth-window.ts"; +import { resolveAyanamsa, type AyanamsaName } from "../../ayanamsa.ts"; import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts"; import { resolveActiveSkillPackage, @@ -46,6 +47,7 @@ export type V9BaselineSnapshot = Readonly<{ active_birth_time: string | null; uncertainty_before_minutes: number | null; uncertainty_after_minutes: number | null; + ayanamsa: AyanamsaName; }>; export type V9RectificationProfile = Readonly<{ @@ -167,7 +169,7 @@ export async function loadV9RectificationProfile( const { data, error } = await accounting .from("profiles") .select( - "birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset", + "birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa", ) .eq("id", userId) .single(); @@ -213,6 +215,7 @@ export async function loadV9RectificationProfile( active_birth_time: null, uncertainty_before_minutes: uncertaintyBefore, uncertainty_after_minutes: uncertaintyAfter, + ayanamsa: resolveAyanamsa(row), }; return { diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index cad8edff..f07c122f 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -23,6 +23,7 @@ import { type WindowScan, } from "./varga-observations"; import { questionContractVersionIsCompatible } from "./probe-question-contract"; +import { resolveAyanamsa } from "../../ayanamsa.ts"; export class RectificationEngineError extends Error { readonly code: string; @@ -475,6 +476,7 @@ function engineRequestBody(input: { lat, lon, tz, + ayanamsa: resolveAyanamsa(snapshot), events: input.events, birth_time_source: snapshot.birth_time_source, timezone_id: snapshot.timezone_id, diff --git a/frontend/src/lib/server-owned-birth-profile.ts b/frontend/src/lib/server-owned-birth-profile.ts index 24def083..363f5904 100644 --- a/frontend/src/lib/server-owned-birth-profile.ts +++ b/frontend/src/lib/server-owned-birth-profile.ts @@ -1,9 +1,10 @@ import type { GlobalBirthProfile } from "./global-birth-payloads.ts"; +import { resolveAyanamsa } from "./ayanamsa.ts"; const usableActiveStatuses = new Set(["accepted", "confirmed"]); export const ACCOUNT_BIRTH_SELECT = - "name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id" as const; + "name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id,ayanamsa" as const; export type AccountBirthRow = Readonly<{ name?: unknown; @@ -20,6 +21,7 @@ export type AccountBirthRow = Readonly<{ longitude?: unknown; timezone_offset?: unknown; timezone_id?: unknown; + ayanamsa?: unknown; }>; function asAccountBirthRow(row: unknown): AccountBirthRow { @@ -84,6 +86,7 @@ export function globalBirthProfileFromAccountRow(row: unknown): GlobalBirthProfi longitude: finiteNumber(accountRow.longitude) ?? null, timezoneOffset: finiteNumber(accountRow.timezone_offset) ?? null, timezoneId: text(accountRow.timezone_id), + ayanamsa: resolveAyanamsa(accountRow), birthTimeStatus: text(accountRow.birth_time_status), }; } @@ -106,6 +109,7 @@ export function globalBirthProfileFromStoredChart(value: unknown): GlobalBirthPr longitude: record.longitude, timezone_offset: record.timezoneOffset ?? record.timezone_offset, timezone_id: record.timezoneId ?? record.timezone_id, + ayanamsa: record.ayanamsa, }); if (!profile.date || !profile.time) return null; return profile; diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index c2f0da3e..643c6515 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { consultationEvidenceCategoryValues, createConsultationPlan, type ConsultationPlan } from "../lib/consultation-plan.ts"; import { consultationThemeValues, projectConsultationWorkflowRequest } from "../lib/consultation-workflow-request.ts"; +import { AYANAMSA_VALUES, DEFAULT_AYANAMSA } from "../lib/ayanamsa.ts"; export const consultationInputSchema = z.object({ year: z.number().int().min(1900).max(2100), @@ -11,6 +12,7 @@ export const consultationInputSchema = z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180), tz: z.number().min(-12).max(14), + ayanamsa: z.enum(AYANAMSA_VALUES).default(DEFAULT_AYANAMSA), city: z.string().trim().min(1).max(120), question: z.string().trim().min(1).max(500), theme: z.enum(consultationThemeValues), diff --git a/frontend/supabase/migrations/20260903010000_profile_ayanamsa.sql b/frontend/supabase/migrations/20260903010000_profile_ayanamsa.sql new file mode 100644 index 00000000..6239f583 --- /dev/null +++ b/frontend/supabase/migrations/20260903010000_profile_ayanamsa.sql @@ -0,0 +1,15 @@ +begin; + +alter table public.profiles + add column if not exists ayanamsa text not null default 'raman'; + +alter table public.profiles + drop constraint if exists profiles_ayanamsa_check; + +alter table public.profiles + add constraint profiles_ayanamsa_check + check (ayanamsa in ('raman', 'lahiri', 'kp', 'true_pushya')); + +grant update (ayanamsa) on table public.profiles to authenticated; + +commit; diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index 0393b7ff..dcfb6516 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -47,6 +47,7 @@ test("account GET falls back when global birthplace columns are not migrated yet assert.match(getSource, /select\("credits,active_birth_time[^"]*timezone_offset(?:,[^"]*)?"\)/); assert.match(getSource, /birth_place_label: undefined/); assert.match(getSource, /timezone_id: undefined/); + assert.match(getSource, /timezone_source,ayanamsa,name,birth_time,rectification_case_id/); }); test("account API no longer reads or returns retired rectification cases", () => { @@ -92,6 +93,11 @@ test("profile patch schema validates calendar, clock, source requirements, and l timezone_source: "iana_historical", }).success, true); assert.equal(accountProfilePatchSchema.safeParse({ name: "只改称呼" }).success, true); + assert.equal(accountProfilePatchSchema.safeParse({ ayanamsa: "lahiri" }).success, true); + assert.equal(accountProfilePatchSchema.safeParse({ ayanamsa: "kp" }).success, true); + assert.equal(accountProfilePatchSchema.safeParse({ ayanamsa: "true_pushya" }).success, true); + assert.equal(accountProfilePatchSchema.safeParse({ ayanamsa: "raman" }).success, true); + assert.equal(accountProfilePatchSchema.safeParse({ ayanamsa: "fagan" }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, reported_birth_time: null, @@ -200,6 +206,10 @@ test("ordinary declaration edits clear stale candidate application but never ove ...candidate, birth_time_status: "confirmed", }, edited), {}); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ + ...candidate, + birth_time_status: "confirmed", + }, { ayanamsa: "lahiri" }), {}); assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ ...candidate, active_birth_time: null, @@ -457,6 +467,19 @@ test("reported birth-time status repair is forward-only and applied by the self- assert.match(selfHostedMigrator, /supabaseCompatibilityDirectory,/); }); +test("profile ayanamsa is added by the self-hosted migrator with Raman as the default", () => { + const ayanamsaMigration = readFileSync( + new URL("../supabase/migrations/20260903010000_profile_ayanamsa.sql", import.meta.url), + "utf8", + ); + assert.match(ayanamsaMigration, /add column if not exists ayanamsa text not null default 'raman'/); + assert.match(ayanamsaMigration, /'raman', 'lahiri', 'kp', 'true_pushya'/); + assert.equal( + existsSync(new URL("../db/migrations/20260903010000_profile_ayanamsa.sql", import.meta.url)), + false, + ); +}); + test("existing exact family declarations are forward-repaired without claiming confirmation", () => { assert.match(acceptedExactFamilyMigration, /update public\.profiles/); assert.match(acceptedExactFamilyMigration, /active_birth_time = reported_birth_time/); @@ -484,6 +507,13 @@ test("account PATCH uses the shared validator and never writes client birth_time assert.match(patchSource, /"active_birth_time"/); assert.match(source, /latitude,longitude,timezone_offset/); assert.match(source, /birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source/); + assert.match(source, /payload\.ayanamsa !== undefined \? \{ ayanamsa: payload\.ayanamsa \}/); + assert.match(patchSource, /ayanamsa: z\.enum\(AYANAMSA_VALUES\)\.optional\(\)/); + const declarationBlock = patchSource.slice( + patchSource.indexOf("const declarationFields"), + patchSource.indexOf("const concurrencyFields"), + ); + assert.doesNotMatch(declarationBlock, /ayanamsa/); assert.match(source, /applyAccountProfileConcurrencyGuards/); assert.match(source, /最新确认结果已保留/); }); diff --git a/frontend/tests/ayanamsa.test.ts b/frontend/tests/ayanamsa.test.ts new file mode 100644 index 00000000..e7043ab0 --- /dev/null +++ b/frontend/tests/ayanamsa.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AYANAMSA_VALUES, + DEFAULT_AYANAMSA, + isAyanamsaName, + resolveAyanamsa, +} from "../src/lib/ayanamsa.ts"; + +test("resolveAyanamsa defaults to Raman and accepts the four product values", () => { + assert.equal(DEFAULT_AYANAMSA, "raman"); + assert.deepEqual([...AYANAMSA_VALUES], ["raman", "lahiri", "kp", "true_pushya"]); + assert.equal(resolveAyanamsa(), "raman"); + assert.equal(resolveAyanamsa(null), "raman"); + assert.equal(resolveAyanamsa({}), "raman"); + assert.equal(resolveAyanamsa({ ayanamsa: "lahiri" }), "lahiri"); + assert.equal(resolveAyanamsa({ ayanamsa: "KP" }), "kp"); + assert.equal(resolveAyanamsa({ ayanamsa: "krishnamurti" }), "kp"); + assert.equal(resolveAyanamsa({ ayanamsa: "true-pushya" }), "true_pushya"); + assert.equal(resolveAyanamsa({ ayanamsa: "fagan" }), "raman"); + assert.equal(isAyanamsaName("raman"), true); + assert.equal(isAyanamsaName("sidereal"), false); +}); diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index ba2d2aeb..34debc67 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -38,7 +38,7 @@ import { const serverChart = { name: "测试", - toolInput: { year: 1990, month: 1, day: 2, hour: 3, minute: 4, city: "台北", lat: 25.03, lon: 121.56, tz: 8, declared_accuracy: "15min" as const, time_source: "family_vague" }, + toolInput: { year: 1990, month: 1, day: 2, hour: 3, minute: 4, city: "台北", lat: 25.03, lon: 121.56, tz: 8, ayanamsa: "raman" as const, declared_accuracy: "15min" as const, time_source: "family_vague" }, truth: { birthDate: "1990-01-02", reportedBirthTime: "03:04", activeBirthTime: null, selectedTimeKind: "reported" as const, birthTimeSource: "reported", birthTimeStatus: "reported", diff --git a/frontend/tests/consultation-route-service.test.ts b/frontend/tests/consultation-route-service.test.ts index 724fa0b9..ef85f2c9 100644 --- a/frontend/tests/consultation-route-service.test.ts +++ b/frontend/tests/consultation-route-service.test.ts @@ -58,6 +58,7 @@ test("route service loads complete server truth before billing and uses only rep lat: 36.420487, lon: 114.209936, tz: 8, + ayanamsa: "raman", declared_accuracy: "15min", time_source: "family_vague", }); @@ -72,6 +73,32 @@ test("route service loads complete server truth before billing and uses only rep }); }); +test("route service passes the profile ayanamsa into both natal and declared-window tool input", async () => { + const natal = await prepareConsultationRoute({ + userId: "user-1", + mode: "unverified_birth_time", + loadProfile: async () => ({ ...profile, ayanamsa: "lahiri" }), + reserve: async () => "reserved", + }); + assert.equal(natal.serverChart?.toolInput.ayanamsa, "lahiri"); + + const windowed = await prepareConsultationRoute({ + userId: "user-1", + mode: "general_no_birth_time", + loadProfile: async () => ({ + ...profile, + ayanamsa: "kp", + reported_birth_time: null, + active_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "evening", + birth_time_status: "reported", + }), + reserve: async () => "reserved", + }); + assert.equal(windowed.declaredWindow?.toolInput.ayanamsa, "kp"); +}); + test("pre-reserve plan guard sees resolved server mode and blocks billing on failure", async () => { const order: string[] = []; @@ -158,6 +185,7 @@ test("global normalized places use their exact coordinates and historical offset lat: 25.033, lon: 121.5654, tz: 9, + ayanamsa: "raman", declared_accuracy: "15min", time_source: "family_vague", }); @@ -316,6 +344,7 @@ test("stale general mode upgrades period-only profiles to a declared birth windo assert.equal(prepared.serverChart, null); assert.equal(prepared.declaredWindow?.toolInput.rangeStart, "18:00"); assert.equal(prepared.declaredWindow?.toolInput.rangeEnd, "22:59"); + assert.equal(prepared.declaredWindow?.toolInput.ayanamsa, "raman"); assert.equal("hour" in (prepared.declaredWindow?.toolInput ?? {}), false); assert.equal("minute" in (prepared.declaredWindow?.toolInput ?? {}), false); assert.deepEqual(prepared.generalDailyReference, { @@ -355,6 +384,7 @@ test("consult route constructs workflow input from the route service rather than assert.match(route, new RegExp(select)); assert.match(route, /prepareConsultationRoute/); + assert.match(route, /timezone_id,timezone_source,ayanamsa/); assert.match(route, /\.\.\.prepared\.serverChart\.toolInput/); const toolInput = route.slice(route.indexOf("const toolInput = consultationInputSchema.parse")); assert.doesNotMatch(toolInput.slice(0, toolInput.indexOf("const workflowContext")), /\.\.\.parsed\.data/); diff --git a/frontend/tests/consultation-workflow-request.test.ts b/frontend/tests/consultation-workflow-request.test.ts index e67ec8f3..95a37db1 100644 --- a/frontend/tests/consultation-workflow-request.test.ts +++ b/frontend/tests/consultation-workflow-request.test.ts @@ -48,6 +48,7 @@ test("timing input projects only legal private workflow fields", async () => { lat: 25, lon: 121, tz: 8, + ayanamsa: "raman", city: "台北", question: "未来哪些阶段值得把握?", theme: "timing", diff --git a/frontend/tests/global-birth-profile-routes.test.ts b/frontend/tests/global-birth-profile-routes.test.ts index 50f72c52..ba8d82ab 100644 --- a/frontend/tests/global-birth-profile-routes.test.ts +++ b/frontend/tests/global-birth-profile-routes.test.ts @@ -33,18 +33,23 @@ test("daily, synastry, and legacy rectification use global coordinates instead o }); const synastry = await synastryBirthPayload(sanFrancisco); - assert.deepEqual({ lat: synastry.lat, lon: synastry.lon, tz: synastry.tz }, { + assert.deepEqual({ lat: synastry.lat, lon: synastry.lon, tz: synastry.tz, ayanamsa: synastry.ayanamsa }, { lat: 37.7879363, lon: -122.4075201, tz: -8, + ayanamsa: "raman", }); const legacy = await payloadFromProfile(sanFrancisco); - assert.deepEqual(legacy && { lat: legacy.lat, lon: legacy.lon, tz: legacy.tz }, { + assert.deepEqual(legacy && { lat: legacy.lat, lon: legacy.lon, tz: legacy.tz, ayanamsa: legacy.ayanamsa }, { lat: 37.7879363, lon: -122.4075201, tz: -8, + ayanamsa: "raman", }); + + const lahiriDaily = await dailyProfilePayload({ ...sanFrancisco, ayanamsa: "lahiri" }, "2026-07-24"); + assert.equal(lahiriDaily?.ayanamsa, "lahiri"); }); test("shared timezone resolver supports camelCase browser profiles and preserves the historical reference time", async () => { diff --git a/frontend/tests/rectification-v9-case-service.test.ts b/frontend/tests/rectification-v9-case-service.test.ts index 99962fe9..5ebc3a74 100644 --- a/frontend/tests/rectification-v9-case-service.test.ts +++ b/frontend/tests/rectification-v9-case-service.test.ts @@ -79,6 +79,7 @@ test("loadV9RectificationProfile uses a server-owned radius around reported time assert.equal(profile.baselineFingerprint.length, 64); assert.equal(profile.baseline.uncertainty_before_minutes, 10); assert.equal(profile.baseline.uncertainty_after_minutes, 10); + assert.equal(profile.baseline.ayanamsa, "raman"); assert.deepEqual(profile.candidateRange, { start_time: "04:45", end_time: "05:15" }); assert.ok(!("password" in profile.baseline)); }); @@ -98,6 +99,18 @@ test("profile fingerprint ignores display labels and prior accepted minutes", as assert.equal(first.baselineFingerprint, presentationOnly.baselineFingerprint); }); +test("ayanamsa is stored on new snapshots but does not change the baseline fingerprint", async () => { + const raman = await loadV9RectificationProfile(fakeAccounting({ + profile: completeProfile, + }), "user-1"); + const lahiri = await loadV9RectificationProfile(fakeAccounting({ + profile: { ...completeProfile, ayanamsa: "lahiri" }, + }), "user-1"); + assert.equal(raman.baseline.ayanamsa, "raman"); + assert.equal(lahiri.baseline.ayanamsa, "lahiri"); + assert.equal(raman.baselineFingerprint, lahiri.baselineFingerprint); +}); + test("loadV9RectificationProfile normalizes PostgreSQL Date birth dates", async () => { const dateProfile = { ...completeProfile, @@ -425,6 +438,7 @@ test("homepage and new opens ignore active time and legacy uncertainty for their active_birth_time: null, uncertainty_before_minutes: 10, uncertainty_after_minutes: 10, + ayanamsa: "raman", }); } }); diff --git a/frontend/tests/server-owned-birth-profile.test.ts b/frontend/tests/server-owned-birth-profile.test.ts index 58a58f8c..3be8ef6e 100644 --- a/frontend/tests/server-owned-birth-profile.test.ts +++ b/frontend/tests/server-owned-birth-profile.test.ts @@ -68,6 +68,7 @@ test("stored other-chart JSON keeps the library camelCase shape", () => { longitude: 116.4, timezoneOffset: 8, timezoneId: "Asia/Shanghai", + ayanamsa: "raman", birthTimeStatus: undefined, }); }); @@ -82,7 +83,7 @@ test("daily and synastry routes share a literal account-birth select string", () const synastry = readFileSync(new URL("../src/app/api/synastry/route.ts", import.meta.url), "utf8"); assert.equal( ACCOUNT_BIRTH_SELECT, - "name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id", + "name,birth_date,reported_birth_time,active_birth_time,birth_time,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id,ayanamsa", ); assert.match(daily, /select\(ACCOUNT_BIRTH_SELECT\)/); assert.match(synastry, /select\(ACCOUNT_BIRTH_SELECT\)/); diff --git a/frontend/tests/settings-mvp-contract.test.ts b/frontend/tests/settings-mvp-contract.test.ts index f3466e1e..ced29f46 100644 --- a/frontend/tests/settings-mvp-contract.test.ts +++ b/frontend/tests/settings-mvp-contract.test.ts @@ -33,10 +33,13 @@ test("personal profile is limited to account basics and links to chart settings" test("the chart library owns self-profile editing and keeps other-chart save separate", () => { const charts = readFileSync(new URL("../src/components/chart-library-panel.tsx", import.meta.url), "utf8"); + const personalProfile = between(page, " renderProfile() {", " renderChartLibrary() {"); assert.match(charts, /编辑本人资料/); assert.match(charts, /onSubmit=\{saveProfile\}/); assert.match(charts, /onSubmit=\{saveOtherChart\}/); assert.match(charts, /setEditingSelfChart\(true\)/); + assert.match(charts, /AyanamsaPreferenceField/); + assert.doesNotMatch(personalProfile, /AyanamsaPreferenceField/); }); test("the general dialog renders the shared theme preference panel", () => { diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 9e7dd3cc..e5ba0026 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -106,7 +106,7 @@ except ModuleNotFoundError: # pragma: no cover - script execution path release_heavy_compute_slot, ) -from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name +from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, ayanamsa_display_name, normalize_ayanamsa_name from raman_support_observations import build_raman_support_observations load_local_env(REPO_ROOT) @@ -807,7 +807,7 @@ _CHARA_DASHA_ROUTES = frozenset({'career', 'timing', 'annual', 'marriage'}) def _request_ayanamsa(*sources) -> str: for source in sources: if isinstance(source, dict): - raw = source.get('ayanamsa') or source.get('ayanamsa_name') + raw = source.get('ayanamsa') or source.get('ayanamsa_name') or source.get('ayanamsa_policy') else: raw = source if raw not in (None, ''): @@ -2850,7 +2850,7 @@ def _attach_vedastro_main_entry_overview(chart_result, birth_payload): 'lat': birth_payload.get('lat'), 'lon': birth_payload.get('lon'), 'tz': birth_payload.get('tz'), - 'ayanamsa_policy': birth_payload.get('ayanamsa') or 'lahiri', + 'ayanamsa_policy': _request_ayanamsa(birth_payload), 'node_policy': birth_payload.get('node_mode') or birth_payload.get('nodeMode') or 'mean', }, route='overview', reference_date=reference_date, case_id='api_chart') if isinstance(vedastro_evidence, dict): @@ -4014,7 +4014,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'lat': lat, 'lon': lon, 'tz': tz, - 'ayanamsa_policy': body.get('ayanamsa_policy') or body.get('ayanamsa') or 'lahiri', + 'ayanamsa_policy': _request_ayanamsa(body), 'node_policy': body.get('node_policy') or body.get('node_mode') or 'mean', } result = _load_local_module('vedastro_service_adapter').run_range_scan_for_case( @@ -6944,13 +6944,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): except ImportError: fallback = self._fallback_chart( year, month, day, hour, minute, second, lat, lon, tz, - skip_vedastro_main_entry_overview=bool(body.get('skip_vedastro_main_entry_overview')), + skip_vedastro_main_entry_overview=bool(body.get('skip_vedastro_main_entry_overview')), ayanamsa_name=_request_ayanamsa(body), ) return _store_api_chart_response_cache(cache_payload, fallback) def _fallback_chart( self, year, month, day, hour, minute, second, lat, lon, tz, - *, skip_vedastro_main_entry_overview=False, + *, skip_vedastro_main_entry_overview=False, ayanamsa_name=DEFAULT_AYANAMSA_NAME, ): """无Swiss Ephemeris时的简化计算""" import hashlib @@ -6996,7 +6996,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'second': int(second), 'tz': f"UTC{'+' if tz >= 0 else ''}{tz}", 'lat': lat, - 'lon': lon, + 'lon': lon, 'ayanamsa_name': ayanamsa_name, 'ayanamsa_display': ayanamsa_display_name(ayanamsa_name), }, 'ascendant': {'sign': asc_sign, 'sign_idx': asc_sign_idx}, 'planets': planets, 'houses': houses, @@ -7027,7 +7027,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'lat': lat, 'lon': lon, 'tz': tz, - 'ayanamsa': 'lahiri', + 'ayanamsa': ayanamsa_name, 'node_mode': 'mean', }) _attach_guided_topics(result) @@ -7083,7 +7083,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if planet in {'Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'} and isinstance(pdata, dict) } - ayanamsa_display = birth.get('ayanamsa_display') or 'Raman' + ayanamsa_display = birth.get('ayanamsa_display') or ayanamsa_display_name(birth.get('ayanamsa_name') or birth.get('ayanamsa')) node_mode = birth.get('node_mode') or 'mean' prompt_lines = [ '你是一个审慎的 AI Native 印度/吠陀占星分析助手。', diff --git a/scripts/local_accuracy_report.py b/scripts/local_accuracy_report.py index e3256fc0..4f0c7c02 100755 --- a/scripts/local_accuracy_report.py +++ b/scripts/local_accuracy_report.py @@ -227,6 +227,7 @@ def build_report() -> dict[str, Any]: } return { "scope": "local_jyotish_accuracy_report", + "reference_ayanamsa": "lahiri", "generated_at": datetime.now(timezone.utc).isoformat(), "summary": summary, "checks": checks, diff --git a/tests/run_real_case_revalidation.py b/tests/run_real_case_revalidation.py index 6d59ca19..a1fd1654 100644 --- a/tests/run_real_case_revalidation.py +++ b/tests/run_real_case_revalidation.py @@ -26,6 +26,7 @@ INDASTRO_CASES = ROOT / INDASTRO_CASES_RELATIVE DEFAULT_MIN_PASS_RATE = 0.98 DEFAULT_DEGREE_TOLERANCE = 1.0 +REFERENCE_AYANAMSA = "lahiri" CONTROVERSIAL_HINTS = [ "内部矛盾", "需进一步验证", @@ -60,6 +61,8 @@ def run_engine(case: dict[str, Any], python: str) -> dict[str, Any]: str(case["lon"]), "--tz", str(case["tz"]), + "--ayanamsa", + REFERENCE_AYANAMSA, ] completed = subprocess.run( cmd, @@ -178,6 +181,7 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: return { "valid": pass_rate >= args.min_pass_rate and not failures, + "reference_ayanamsa": REFERENCE_AYANAMSA, "scope": "public real-person chart revalidation; not event_prediction_accuracy", "min_pass_rate": args.min_pass_rate, "pass_rate": round(pass_rate, 4), diff --git a/tests/test_user_invocation_acceptance_contract.py b/tests/test_user_invocation_acceptance_contract.py index 56f8fcbc..b0f1e9be 100644 --- a/tests/test_user_invocation_acceptance_contract.py +++ b/tests/test_user_invocation_acceptance_contract.py @@ -85,6 +85,7 @@ def test_user_entrypoint_can_start_from_guided_topics_prompt() -> None: def test_fixture_dasha_timeline_rejects_workbuddy_regression_claims() -> None: + # 原值=依赖引擎默认岁差;新值=显式 lahiri;原因=夹具日期是 Lahiri 口径。 base = [ sys.executable, "scripts/jyotish_engine.py", @@ -107,6 +108,8 @@ def test_fixture_dasha_timeline_rejects_workbuddy_regression_claims() -> None: "0", "--years", "45", + "--ayanamsa", + "lahiri", ] observed = {}