fix(ayanamsa): pin Lahiri on reference sets and add a user setting

Product default stays Raman. Public-case revalidation and fixture dashas now pass --ayanamsa lahiri; profiles store the four selectable values.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-03 16:33:46 +08:00
parent 58cae371bb
commit b6a70aa7df
44 changed files with 425 additions and 30 deletions
+4
View File
@@ -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 不变)
训练门已开、覆盖层仍挡住确认、可渲染区分题又问不出来时,助手会说「可以先按当前区间看盘,也可以再补一件记得时间的经历」,但时间卡、下一问、区间数值都不给。本轮把这句话改成与真实承载一致。
+16
View File
@@ -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`,不重算历史报告。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
@@ -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 error73 既有 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/0Lahiri=SwatiRaman=Vishakha)仍欠。部署 SHA 待合入 staging 后补。
## 任务 15
尚未开始。
+1 -1
View File
@@ -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` |
### 上游同步与能力接入
+10
View File
@@ -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.
+4 -1
View File
@@ -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;
@@ -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(
+1 -1
View File
@@ -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");
+1 -1
View File
@@ -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();
},
+1 -1
View File
@@ -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;
+4
View File
@@ -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); }
@@ -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 (
<section className="sheet-section ayanamsa-preference" aria-labelledby="ayanamsa-preference-title">
<div className="section-heading">
<b id="ayanamsa-preference-title"></b>
<small></small>
</div>
<div className="theme-preference-options" role="radiogroup" aria-labelledby="ayanamsa-preference-title">
{AYANAMSA_OPTIONS.map((option) => {
const selected = value === option.value;
return (
<button
className="theme-preference-option ayanamsa-preference-option"
key={option.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(option.value)}
>
<span>
{option.label}
<small>{option.description}</small>
</span>
{selected ? <Check aria-hidden="true" /> : null}
</button>
);
})}
</div>
<p className="ayanamsa-preference-hint">{AYANAMSA_SWITCH_HINT}</p>
</section>
);
}
@@ -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({
<form className="profile-form chart-library-form" onSubmit={saveProfile}>
<div className="section-heading"><b></b><small></small></div>
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="self-profile-name" />
<AyanamsaPreferenceField
value={resolveAyanamsa(profileDraft)}
onChange={(ayanamsa) => setProfileDraft({ ...profileDraft, ayanamsa })}
/>
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={() => { setEditingSelfChart(false); setProfileDraft(profile); }}></button>
@@ -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 {
@@ -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;
+49
View File
@@ -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;
}
@@ -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),
};
}
@@ -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<LegacyBirthTimeJourneyEngine, "scan">,
assessment: BirthTimeAssessment,
assessment: BirthTimeAssessment & { readonly ayanamsa?: unknown },
): Promise<{
readonly stability: ScanStability;
readonly questionnaire: RectificationQuestionnaire | null;
@@ -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<VersionedJourneyResponse | DynamicVersionedJourneyResponse> {
async assess(userId: string, assessment: BirthTimeAssessment & { readonly ayanamsa?: unknown }): Promise<VersionedJourneyResponse | DynamicVersionedJourneyResponse> {
const scan = await scanAssessment(ports.engine, assessment);
const snapshot = assessBirthTime(assessment, scan.stability);
const persisted = {
@@ -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,
}),
@@ -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,
}),
+4 -1
View File
@@ -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),
};
}
+2
View File
@@ -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 } : {}),
};
}
+3
View File
@@ -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;
@@ -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<R
lon: longitude,
tz: timezoneOffset,
city: birthPlaceLabel,
ayanamsa: resolveAyanamsa(profile),
question: `请为个人报告计算 ${theme} 主题证据`,
theme,
entryMode: "direct_chart",
@@ -21,6 +21,7 @@ import { resolveReportBirthClock } from "@/lib/personal-report-route-core";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { completeUsage, releaseUsage } from "@/lib/consultation-billing";
import type { ConsultationInput } from "@/mastra/consultation-workflow";
import { resolveAyanamsa } from "@/lib/ayanamsa";
const PROFILE_COLUMNS = [
"name",
@@ -33,6 +34,7 @@ const PROFILE_COLUMNS = [
"longitude",
"timezone_offset",
"birth_place_label",
"ayanamsa",
].join(",");
type JsonRecord = Record<string, unknown>;
@@ -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",
@@ -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 {
@@ -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,
@@ -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;
@@ -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),
@@ -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;
+30
View File
@@ -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, /最新确认结果已保留/);
});
+24
View File
@@ -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);
});
@@ -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",
@@ -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/);
@@ -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",
@@ -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 () => {
@@ -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",
});
}
});
@@ -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\)/);
@@ -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", () => {
+9 -9
View File
@@ -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 印度/吠陀占星分析助手。',
+1
View File
@@ -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,
+4
View File
@@ -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),
@@ -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 = {}