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 = {}