feat(rectification): pass ayanamsa per request

This commit is contained in:
Jesse_Chen
2026-09-04 00:25:16 +08:00
parent 3b09bbbee0
commit 44d3d3931b
15 changed files with 135 additions and 16 deletions
@@ -36,5 +36,6 @@ export async function payloadFromProfile(profile: BirthRectificationProfile) {
lon,
tz,
ayanamsa: resolveAyanamsa(resolved),
node_mode: "mean",
};
}
@@ -23,6 +23,7 @@ import {
} from "./birth-time-journey-dynamic-state.ts";
import { BirthTimeJourneyStoreError } from "./birth-time-journey-store-errors.ts";
import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
import { resolveAyanamsa } from "./ayanamsa.ts";
type JourneyLoadResult = { readonly data: unknown; readonly error: unknown };
type JourneyLoadQuery = {
@@ -141,7 +142,7 @@ export async function loadStoredRectificationCase(
const parsed = storedCaseSchema.parse(data);
const { data: profile, error: profileError } = await client
.from("profiles")
.select("birth_date,reported_birth_time,birth_time_period,latitude,longitude,timezone_id,timezone_offset")
.select("birth_date,reported_birth_time,birth_time_period,latitude,longitude,timezone_id,timezone_offset,ayanamsa")
.eq("id", userId)
.maybeSingle();
if (profileError || !profile) throw new BirthTimeJourneyStoreError("load_case");
@@ -200,6 +201,7 @@ export async function loadStoredRectificationCase(
lat: location.latitude,
lon: location.longitude,
tz: location.timezone_offset,
ayanamsa: resolveAyanamsa(resolvedProfile as { ayanamsa?: unknown }),
},
lifeEvents: parsed.life_events,
candidateResult,
@@ -13,6 +13,7 @@ import type {
DynamicChoiceScoreInput,
JourneyEventScoreInput,
} from "./birth-time-journey-service.ts";
import { resolveAyanamsa } from "./ayanamsa.ts";
export class BirthTimeJourneyEngineError extends Error {
readonly name = "BirthTimeJourneyEngineError";
@@ -87,6 +88,8 @@ export function eventScorePayload(input: JourneyEventScoreInput) {
lat: input.lat,
lon: input.lon,
tz: input.tz,
ayanamsa: resolveAyanamsa(input),
node_mode: "mean",
high_rigor: true,
events: (input.events ?? []).map((event) => ({
id: event.id,
@@ -163,6 +166,7 @@ export function createJourneyEngineMethods(wire: JourneyEngineWire): BirthTimeJo
lon: input.lon,
tz: input.tz,
ayanamsa: input.ayanamsa,
node_mode: "mean",
},
});
return { questionnaire: parseRectificationQuestionnaire(payload) };
@@ -70,6 +70,7 @@ export type JourneyEventScoreInput = {
readonly lat: number;
readonly lon: number;
readonly tz: number;
readonly ayanamsa?: AyanamsaName;
readonly events: readonly LifeEvent[];
};
@@ -123,6 +124,7 @@ type StoredRectificationCaseBase = {
readonly lat: number;
readonly lon: number;
readonly tz: number;
readonly ayanamsa?: AyanamsaName;
};
readonly lifeEvents?: readonly LifeEvent[];
readonly candidateResult?: CandidateResult | null;
@@ -4,6 +4,7 @@ import type {
AgenticRectificationCandidateResult,
AgenticRectificationContext,
} from "@/mastra/rectification-tools";
import { resolveAyanamsa, type AyanamsaName } from "../ayanamsa.ts";
import { normalizePersistedBirthDate } from "../birth-time-intake-model.ts";
import { declaredClockRange } from "../declared-birth-window.ts";
@@ -26,6 +27,7 @@ export type AgenticRectificationProfile = Readonly<{
lat: number;
lon: number;
tz: number;
ayanamsa: AyanamsaName;
declaredAccuracy: AgenticRectificationContext["declaredAccuracy"];
timeSource: AgenticRectificationContext["timeSource"];
baselineReportedTime: string | null;
@@ -166,7 +168,7 @@ export async function loadAgenticRectificationProfile(
): Promise<AgenticRectificationProfile> {
const { data, error } = await accounting
.from("profiles")
.select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset")
.select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset,ayanamsa")
.eq("id", userId)
.single();
if (error || !data) throw new AgenticRectificationProfileError("profile_unavailable");
@@ -199,6 +201,7 @@ export async function loadAgenticRectificationProfile(
lat,
lon,
tz,
ayanamsa: resolveAyanamsa(data),
declaredAccuracy: declaredAccuracyFrom(uncertaintyBefore, uncertaintyAfter, rawSource),
timeSource: timeSourceFrom(rawSource),
baselineReportedTime: reportedTime,
@@ -284,6 +287,7 @@ export function createAgenticRectificationContext(
lat: profile.lat,
lon: profile.lon,
tz: profile.tz,
ayanamsa: profile.ayanamsa,
},
candidateRange: profile.candidateRange,
declaredAccuracy: profile.declaredAccuracy,
@@ -477,6 +477,7 @@ function engineRequestBody(input: {
lon,
tz,
ayanamsa: resolveAyanamsa(snapshot),
node_mode: "mean",
events: input.events,
birth_time_source: snapshot.birth_time_source,
timezone_id: snapshot.timezone_id,
@@ -1,6 +1,7 @@
import { createTool } from "@mastra/core/tools";
import { createHash } from "node:crypto";
import { z } from "zod";
import { resolveAyanamsa, type AyanamsaName } from "../lib/ayanamsa.ts";
import { dateRangeFromDeclared } from "../lib/rectification-agentic/date-range.ts";
/**
@@ -27,6 +28,7 @@ export type AgenticRectificationBirth = Readonly<{
lat: number;
lon: number;
tz: number;
ayanamsa?: AyanamsaName | string | null;
}>;
export type AgenticRectificationCandidateRange = Readonly<{
@@ -250,6 +252,8 @@ function v5Request(
lat: ctx.birth.lat,
lon: ctx.birth.lon,
tz: ctx.birth.tz,
ayanamsa: resolveAyanamsa(ctx.birth),
node_mode: "mean",
events: events.map(toV5Event),
};
}
@@ -425,6 +429,8 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
lat: ctx.birth.lat,
lon: ctx.birth.lon,
tz: ctx.birth.tz,
ayanamsa: resolveAyanamsa(ctx.birth),
node_mode: "mean",
time_uncertainty_minutes: scanWindow.uncertaintyMinutes,
step_minutes: input.step_minutes,
};
@@ -533,6 +539,8 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
lat: ctx.birth.lat,
lon: ctx.birth.lon,
tz: ctx.birth.tz,
ayanamsa: resolveAyanamsa(ctx.birth),
node_mode: "mean",
events: [],
});
const snapshot = (data.candidate_feature_snapshot && typeof data.candidate_feature_snapshot === "object")
@@ -568,6 +576,8 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
lat: ctx.birth.lat,
lon: ctx.birth.lon,
tz: ctx.birth.tz,
ayanamsa: resolveAyanamsa(ctx.birth),
node_mode: "mean",
events: input.events.map(toV3Event),
high_rigor: true,
};
@@ -19,6 +19,7 @@ test("journey engine serializes only stored event-scoring inputs", () => {
lat: 31.2304,
lon: 121.4737,
tz: 8,
ayanamsa: "lahiri",
events: [
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" },
{ id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "education", date: "2011", precision: "year" },
@@ -33,6 +34,8 @@ test("journey engine serializes only stored event-scoring inputs", () => {
lat: 31.2304,
lon: 121.4737,
tz: 8,
ayanamsa: "lahiri",
node_mode: "mean",
high_rigor: true,
events: [
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" },
+7 -5
View File
@@ -289,7 +289,9 @@ def _controlled_transit_rules(
transit_chart = domain_calculation_service.compute_chart({
"year": event_at.year, "month": event_at.month, "day": event_at.day,
"hour": 12, "minute": 0, "lat": request["lat"], "lon": request["lon"],
"tz": request["tz"], "ayanamsa": AYANAMSA, "node_mode": NODE_MODE,
"tz": request["tz"],
"ayanamsa": request.get("ayanamsa", AYANAMSA),
"node_mode": request.get("node_mode", NODE_MODE),
})
rules: list[str] = []
for planet in ("Jupiter", "Saturn"):
@@ -425,8 +427,8 @@ def build_candidate_static_context(
"lat": request["lat"],
"lon": request["lon"],
"tz": request["tz"],
"ayanamsa": AYANAMSA,
"node_mode": NODE_MODE,
"ayanamsa": request.get("ayanamsa", AYANAMSA),
"node_mode": request.get("node_mode", NODE_MODE),
})
planet_longitudes = {
name: float(data["lon"])
@@ -642,8 +644,8 @@ def _canonical_input_contract(request: RectificationEventRequest) -> tuple[dict,
"summary": event.get("summary", ""),
} for event in request["events"]],
"calculation": {
"ayanamsa": AYANAMSA,
"node_mode": NODE_MODE,
"ayanamsa": request.get("ayanamsa", AYANAMSA),
"node_mode": request.get("node_mode", NODE_MODE),
"ephemeris_source": "swisseph_calc_ut",
},
}
+2
View File
@@ -73,6 +73,8 @@ class RectificationEventRequest(TypedDict):
lat: float
lon: float
tz: float
ayanamsa: NotRequired[str]
node_mode: NotRequired[str]
events: list[LifeEvent]
+41 -4
View File
@@ -16,6 +16,9 @@ if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from active_rectification_selector import select_next_questions
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME
DEFAULT_NODE_MODE = "mean"
OPTIONS = [
{"key": "A", "label": "明确有,且时间大致吻合", "score": 2},
@@ -60,7 +63,8 @@ def _candidate_scan(
lat: float | None = None,
lon: float | None = None,
tz: float | None = None,
ayanamsa: str = "raman",
ayanamsa: str = DEFAULT_AYANAMSA_NAME,
node_mode: str = DEFAULT_NODE_MODE,
) -> dict[str, Any]:
start = center - timedelta(minutes=uncertainty_minutes)
end = center + timedelta(minutes=uncertainty_minutes)
@@ -84,7 +88,14 @@ def _candidate_scan(
"cluster": cluster,
"sensitivity_flags": _sensitivity_flags(abs(offset)),
}
recast = _candidate_recast(candidate, lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa)
recast = _candidate_recast(
candidate,
lat=lat,
lon=lon,
tz=tz,
ayanamsa=ayanamsa,
node_mode=node_mode,
)
if recast:
sample.update(recast)
samples.append(sample)
@@ -106,6 +117,8 @@ def _candidate_scan(
"lat": lat,
"lon": lon,
"tz": tz,
"ayanamsa": ayanamsa,
"node_mode": node_mode,
},
uncertainty_minutes=uncertainty_minutes,
step_minutes=step_minutes,
@@ -126,6 +139,10 @@ def _candidate_scan(
"cluster_labels": ["early_candidate_cluster", "middle_candidate_cluster", "late_candidate_cluster"],
"samples": samples,
"minute_scan": minute_scan,
"calculation": {
"ayanamsa": ayanamsa,
"node_mode": node_mode,
},
"sensitivity_summary": {
"method": "minute_feature_scan_v2",
"high_value_layers": ["D2", "D4", "D9", "D10", "D24", "D30", "D60", "UL", "A7", "A10", "KP_cusp"],
@@ -152,6 +169,7 @@ def _candidate_recast(
lon: float | None,
tz: float | None,
ayanamsa: str,
node_mode: str = DEFAULT_NODE_MODE,
) -> dict[str, Any] | None:
if lat is None or lon is None or tz is None:
return None
@@ -171,6 +189,7 @@ def _candidate_recast(
"lon": lon,
"tz": tz,
"ayanamsa": ayanamsa,
"node_mode": node_mode,
})
planet_lons = {
name: data["lon"]
@@ -231,7 +250,8 @@ def build_questionnaire(
lat: float | None = None,
lon: float | None = None,
tz: float | None = None,
ayanamsa: str = "raman",
ayanamsa: str = DEFAULT_AYANAMSA_NAME,
node_mode: str = DEFAULT_NODE_MODE,
) -> dict[str, Any]:
questions = []
for qid, round_id, domain, sensitivity, window, prompt, yes_bias, no_bias in QUESTION_TEMPLATES:
@@ -270,6 +290,7 @@ def build_questionnaire(
lon=lon,
tz=tz,
ayanamsa=ayanamsa,
node_mode=node_mode,
)
selection = select_next_questions(
{"questions": questions, "candidate_scan": candidate_scan}, {}, limit=1
@@ -279,6 +300,10 @@ def build_questionnaire(
"schema_version": 1,
"candidate_scan": candidate_scan,
"selection": selection,
"calculation": {
"ayanamsa": ayanamsa,
"node_mode": node_mode,
},
"workflow": [
"candidate_time_scan",
"varga_arudha_kp_sensitivity_diff",
@@ -433,6 +458,10 @@ def score_answers(
"truth_status": "not_birth_time_truth",
"formula_unit_parity_status": "partial",
"timing_claim_status": "exploratory_unvalidated",
"calculation": questionnaire.get("calculation", {
"ayanamsa": DEFAULT_AYANAMSA_NAME,
"node_mode": DEFAULT_NODE_MODE,
}),
"answered_count": len(applied),
"candidate_cluster_rankings": rankings,
"technique_audit_table": [
@@ -494,10 +523,18 @@ def main() -> int:
parser.add_argument("--birth-time", required=True, help="Approximate local birth time, YYYY-MM-DD HH:MM")
parser.add_argument("--uncertainty-minutes", type=int, default=30)
parser.add_argument("--step-minutes", type=int, default=1)
parser.add_argument("--ayanamsa", default=DEFAULT_AYANAMSA_NAME)
parser.add_argument("--node-mode", choices=("mean", "true"), default=DEFAULT_NODE_MODE)
parser.add_argument("--answers-json", default="", help="Optional JSON object mapping question id to A/B/C/D")
parser.add_argument("--pretty", action="store_true")
args = parser.parse_args()
questionnaire = build_questionnaire(args.birth_time, args.uncertainty_minutes, args.step_minutes)
questionnaire = build_questionnaire(
args.birth_time,
args.uncertainty_minutes,
args.step_minutes,
ayanamsa=args.ayanamsa,
node_mode=args.node_mode,
)
report = score_answers(questionnaire, json.loads(args.answers_json)) if args.answers_json else questionnaire
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
return 0
@@ -34,6 +34,12 @@ def _engine_json(command: str, payload: dict[str, Any], *, timeout: int = 20) ->
args = [sys.executable, str(ENGINE), command]
for key in ("year", "month", "day", "hour", "minute", "lat", "lon", "tz"):
args.extend([f"--{key}", str(payload[key])])
args.extend([
"--ayanamsa",
str(payload.get("ayanamsa") or DEFAULT_AYANAMSA_NAME).strip().lower(),
"--node-mode",
str(payload.get("node_mode", payload.get("nodeMode", "mean"))).strip().lower(),
])
if command == "varga-full":
args.extend(["--divisions", ",".join(_VARGAS)])
completed = subprocess.run(args, cwd=ROOT, capture_output=True, text=True, timeout=timeout, check=True)
@@ -148,6 +154,8 @@ def main() -> int:
parser.add_argument(f"--{field}", required=True, type=cast)
parser.add_argument("--uncertainty-minutes", type=int, default=30)
parser.add_argument("--step-minutes", type=int, default=1)
parser.add_argument("--ayanamsa", default=DEFAULT_AYANAMSA_NAME)
parser.add_argument("--node-mode", choices=("mean", "true"), default="mean")
args = parser.parse_args()
print(json.dumps(scan_candidate_times(vars(args), uncertainty_minutes=args.uncertainty_minutes, step_minutes=args.step_minutes), ensure_ascii=False, indent=2))
return 0
+22 -2
View File
@@ -818,6 +818,19 @@ def _request_ayanamsa(*sources) -> str:
return DEFAULT_AYANAMSA_NAME
def _request_node_mode(*sources) -> str:
for source in sources:
if isinstance(source, dict):
raw = source.get('node_mode', source.get('nodeMode'))
else:
raw = source
if raw not in (None, ''):
if not isinstance(raw, str) or raw.strip().lower() not in {'mean', 'true'}:
raise BadRequest('node_mode must be mean or true')
return raw.strip().lower()
return 'mean'
def _consultation_declared_routes(body: dict) -> set[str]:
values: list[str] = []
for key in ('strict_workflow_route', 'theme'):
@@ -8792,8 +8805,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
lon = self._get_float(body, 'lon', 0, -180, 180)
tz = self._get_float(body, 'tz', 0, -14, 14)
ayanamsa = _request_ayanamsa(body)
if not isinstance(ayanamsa, str):
raise BadRequest('ayanamsa must be a string')
node_mode = _request_node_mode(body)
try:
module = _load_local_module('active_rectification_questions')
result = module.build_questionnaire(
@@ -8804,6 +8816,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
lon=lon,
tz=tz,
ayanamsa=ayanamsa,
node_mode=node_mode,
)
except ValueError as e:
raise BadRequest('birth_time must be YYYY-MM-DD HH:MM') from e
@@ -8839,6 +8852,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
def _compute_active_rectification_events(self, body):
allowed_fields = {
'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'events', 'high_rigor',
'ayanamsa', 'node_mode',
}
unsupported_fields = sorted(set(body) - allowed_fields)
if unsupported_fields:
@@ -8864,6 +8878,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
lat = self._get_float(body, 'lat', 0, -90, 90)
lon = self._get_float(body, 'lon', 0, -180, 180)
tz = self._get_float(body, 'tz', 0, -14, 14)
ayanamsa = _request_ayanamsa(body)
node_mode = _request_node_mode(body)
events = body.get('events')
if not isinstance(events, list) or len(events) < MIN_SCORING_EVENTS:
raise BadRequest(f'events must contain at least {MIN_SCORING_EVENTS} item')
@@ -8911,6 +8927,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'lat': lat,
'lon': lon,
'tz': tz,
'ayanamsa': ayanamsa,
'node_mode': node_mode,
'events': normalized_events,
})
external_validation = {
@@ -8935,6 +8953,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'lat': lat,
'lon': lon,
'tz': tz,
'ayanamsa': ayanamsa,
'node_mode': node_mode,
}
three_engine_packet = build_packet(engine_case)
vedastro_events = [{
+18 -1
View File
@@ -6,6 +6,8 @@ import uuid
from datetime import date
from typing import Any, Literal, NotRequired, TypedDict, cast
from scripts.ayanamsa_utils import UnsupportedAyanamsaError, normalize_ayanamsa_name
DatePrecision = Literal["day", "month", "quarter", "year", "range"]
EVENT_CONTRACT_VERSION = "rectification-event-contract-v2"
@@ -48,7 +50,10 @@ _EVENT_PROVENANCE_FIELDS = frozenset({
"date_source", "date_reliability", "date_corroboration", "date_conflict_status",
"source_turn_id", "subject",
})
_REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"}) | _REQUEST_PROVENANCE_FIELDS
_REQUEST_FIELDS = frozenset({
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
"ayanamsa", "node_mode",
}) | _REQUEST_PROVENANCE_FIELDS
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
@@ -77,6 +82,8 @@ class RectificationRequest(TypedDict):
lon: float
tz: float
events: list[LifeEvent]
ayanamsa: NotRequired[str]
node_mode: NotRequired[str]
birth_time_source: NotRequired[str | None]
timezone_id: NotRequired[str | None]
timezone_source: NotRequired[str | None]
@@ -227,6 +234,16 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
"tz": _bounded_number(body, "tz", -14, 14),
"events": cleaned_events,
}
if "ayanamsa" in body:
try:
cleaned_request["ayanamsa"] = normalize_ayanamsa_name(body.get("ayanamsa"))
except UnsupportedAyanamsaError as exc:
raise ValueError(str(exc)) from exc
if "node_mode" in body:
node_mode = body.get("node_mode")
if not isinstance(node_mode, str) or node_mode.strip().lower() not in {"mean", "true"}:
raise ValueError("node_mode must be mean or true")
cleaned_request["node_mode"] = node_mode.strip().lower()
_copy_nullable_text(body, cleaned_request, "birth_time_source", "birth_time_source", 120, _BIRTH_TIME_SOURCES)
_copy_nullable_text(body, cleaned_request, "timezone_id", "timezone_id", 120)
_copy_nullable_text(body, cleaned_request, "timezone_source", "timezone_source", 80)
+8 -2
View File
@@ -103,7 +103,7 @@ def sample_event_dates(event: LifeEvent) -> list[str]:
def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_date: str) -> dict[str, Any]:
engine_domain, _ = _ENGINE_KIND_BY_NATIVE_KIND[event["event_kind"]]
return {
legacy_request = {
"birth_date": request["birth_date"],
"start_time": request["start_time"],
"end_time": request["end_time"],
@@ -116,6 +116,10 @@ def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_dat
"date": sampled_date, "precision": "day", "summary": event.get("summary", ""),
}],
}
for key in ("ayanamsa", "node_mode"):
if key in request:
legacy_request[key] = request[key]
return legacy_request
def _canonical(value: Any) -> str:
@@ -349,7 +353,9 @@ def calculation_spec(request: RectificationRequest) -> dict[str, Any]:
"latitude": json_number(request["lat"]),
"longitude": json_number(request["lon"]),
"timezoneOffsetHours": json_number(request["tz"]),
"ayanamsa": "raman", "nodeMode": "mean", "minuteStep": 1,
"ayanamsa": request.get("ayanamsa", "raman"),
"nodeMode": request.get("node_mode", "mean"),
"minuteStep": 1,
}
for source, target in (
("birth_time_source", "birthTimeSource"),