From 80969c9b31804cdf0c5860b05e2b0f7835420145 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 19 Aug 2026 19:07:44 +0800 Subject: [PATCH] fix(consult): attach transit windows and VedAstro minute snapshots Consult only copied Sade Sati and never searched trigger dates; V9 score left official minute identity unevaluated. Search a 90-day slow-planet window and run two-candidate snapshots with timeout staying not_evaluated. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 34 +- frontend/src/mastra/consultation-workflow.ts | 48 ++- frontend/src/mastra/index.ts | 1 + frontend/tests/consultation-context.test.ts | 13 +- .../consultation-spectrum-parity.test.ts | 2 + .../tests/consultation-voice-contract.test.ts | 1 + scripts/jyotish_api_server.py | 347 +++++++++++++++++- tests/test_consultation_consumer_context.py | 100 +++++ ...st_rectification_v5_vedastro_validation.py | 119 ++++++ 9 files changed, 637 insertions(+), 28 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f3baef96..42142631 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4161,7 +4161,7 @@ - 根因:一个名字指着两个对象。模型侧 `local_layers.dasha_boundaries` 读 `chart.modules.dasha_boundaries`,而这个键在整个仓库里从未被赋值过——`_attach_local_consultation_layers` 只挂 varga_full / arudha_padas / narayana_dasha / ashtakavarga / kp_cusps——所以该字段恒为 `undefined`,投影时整段消失,模型手上只剩 `chart.dasha.periods`,也就是 6 到 20 年一段的大运列表。证据门里同名的分节 `dasha_boundaries` 读的却是 `modules.dasha`(大运),它当然是 `used`,于是 `timing_layers_ready` 成立、`can_answer_precise_timing` 为真。回执因此签发了「能给到月份」的许可,而模型连一条副运边界都没有,只能在回答里如实说副运没算全。两侧各自自洽:读的一侧永远拿不到数据,判的一侧永远看得见数据,中间没有任何断言比较过这两个名字是不是同一个对象。BUG-268 让 `skillReferenceReads` 可见时曾怀疑是模型没翻方法文档,本条说明缺的是数据本身。 - 修复:三处。其一,服务端真的算出副运并挂到 `modules.dasha_sub_periods`:从 `chart.dasha.periods` 里取出覆盖参考日的那一段大运,用 `dasha_analyzer.build_antardasha` 按比例细分为 9 段。刻意不复用现成的 `_compute_vimshottari_analysis_layer`(它从月亮黄经另建一条时间线):两条线出自同一引擎、数值几乎一样,但「几乎」意味着模型手上会出现两套大运日期且无从判断该引用哪套;从包里已经展示给模型的 periods 上切,副运边界必然落在模型看得见的大运边界之内。其二,证据包新增独立分节 `dasha_sub_periods`(源路径写明 `modules.dasha_sub_periods`),精确应期改为同时要求 `dasha_boundaries` + `dasha_sub_periods` + `narayana_dasha`——大运列表只能定位十年,本就不该单独签发月份级许可。其三,模型侧字段随之改名为 `local_layers.dasha_sub_periods`,与服务端实际写入的键对齐;Agent 指令补一句:该层在场就用它的边界、不得再说副运没算,缺席则一句话说明。 - 验证:真实排盘端到端跑通(1990-05-12 北京,参考日 2026-08-18):`current.mahadasha` 与 `chart.dasha.periods` 里的 Sun 段逐字段相同(2024-07-03→2030-07-03),当前副运为 Jupiter 2026-07-21→2027-05-09,9 段副运首尾正好贴合大运首尾,`local_consultation_layers.diagnostics` 为空,分节 `used`,`can_answer_precise_timing` 为真。`tests/test_consultation_consumer_context.py` 21 项通过(新增 4 项:副运边界必须切自包里展示的 periods 且覆盖参考日;跨语言键名守卫;只缺副运时精确应期必须被拒;副运在场时放行)。测试基准盘原先只写了 `{'current_md': 'Sun'}`,已改为用 `compute_vimshottari_timeline` 派生真实 periods,否则新层在 fixture 上根本不会被执行。前端除数据库套件(本机无 Postgres)外 1633 项通过,`tsc --noEmit` 与 `eslint` 清洁。 -- 待跟进:Pratyantardasha(第三层)与行运触发仍未计算,层内 `summary` 已明说这条边界,所以「哪一周」这类问题仍只能答到副运级别。`_compute_vimshottari_analysis_layer` 那条从月亮黄经另建的时间线仍留在 dasha 接口路径上,未与本层合并。未做的验证:没有在 staging 上复看真实回答是否不再出现「副运没算全」。 +- 待跟进:Pratyantardasha(第三层)仍未计算,所以「哪一周」仍只能答到副运加 90 天慢行星触发窗,不能声称精确到日。`_compute_vimshottari_analysis_layer` 那条从月亮黄经另建的时间线仍留在 dasha 接口路径上,未与本层合并。行运触发窗已由 BUG-302 接入咨询包。未做的验证:没有在 staging 上复看真实回答是否不再出现「副运没算全」。 - 防复发:跨语言的字段名不能靠人眼对齐。一端读 `modules.X` 而另一端从不写 `X`,两侧测试都不会失败——读的一侧只看到 `undefined` 被投影掉,写的一侧根本不知道有人在读。已加的守卫直接比较两端:把 `local_layers` 里所有 `modules.` 读法抽出来(先剥注释,否则解释性注释里的旧键名会被算成读法),与服务端真实挂载后的 `chart['modules']` 键集合求差,非空即失败。另一条教训是分节名要说清自己是什么:`dasha_boundaries` 既能读成「大运边界」也能读成「所有周期边界」,正是这层歧义让门以为自己检查过副运。 - 相关记录:BUG-267(同一段代码里另一处「判据没接到权威来源」,精确应期的空判是那轮修的)、BUG-270(同为两端声明不一致且缺跨端断言)、BUG-268(同一批可观测性问题,本条是它排除掉的另一种解释) - 复发自:无 @@ -4522,3 +4522,35 @@ - 相关记录:BUG-161、BUG-159、BUG-300 - 复发自:BUG-161(超时防护把可选官方层整段关掉) - 修复版本:待提交 + +## BUG-302 | 咨询行运层只挂了 Sade Sati,触发窗从未搜索,模型只能说行运没算 + +- 状态:resolved +- 首次发现:2026-08-19 +- 最近更新:2026-08-19 +- 影响面:普通咨询 `_attach_local_consultation_layers` 的 `modules.transits`、`toModelOutput` timing / western_spectrum、口头应期 +- 用户现象:skill 共同基础含行运,引擎也有 `transit_trigger.search_all_transit_triggers`,但网页咨询只能看到土星过月阶段,回答继续写「行运触发未在本轮完整计算」。西洋应期层已算完,发给模型的压缩包却只剩 status。 +- 触发条件:有出生分钟的个人盘咨询,尤其问未来数月或行运。 +- 根因:attach 只复用 `chart.transit_triggers`(咨询排盘从不写这个键),从不调用触发搜索。西洋压缩只保留 status/boundary,把 target_date、aspects、duration windows 裁掉。 +- 修复:咨询对参考日起 90 天、土星/木星/罗睺/计都对升一与月亮做有界触发搜索,最多 6 条日期窗进模型包;空列表表示该窗无精确接触,不是没算。西洋压缩补日期、aspects、扫描窗,去掉回盘钟点和经度。口头合同:`search_period` 在场就不得再说行运没搜。不提高 `AGENT_TIMEOUT_MS`,不发明水星逆行。 +- 验证:`tests/test_consultation_consumer_context.py` 锁定搜索窗与触发压缩、西洋窗保留且去掉钟点;`frontend/tests/consultation-context.test.ts`、`consultation-spectrum-parity.test.ts`、`consultation-voice-contract.test.ts`。 +- 防复发:`modules.transits` 必须带 `search_period`;模型包 timing 必须投影 `triggers`;西洋 techniques 不得只留 status。 +- 相关记录:BUG-279、BUG-300 +- 复发自:BUG-279(副运修好后,待跟进里的行运触发仍未接上) +- 修复版本:待提交 + +## BUG-303 | 校正确认门的 VedAstro 分钟快照从未在 V9 评分路径执行,状态永远 not_evaluated + +- 状态:resolved +- 首次发现:2026-08-19 +- 最近更新:2026-08-19 +- 影响面:`/api/rectification/v5/score`、`/api/rectification/v5/diagnostics`、`confirmation_gate.vedastro_minute_sensitive` +- 用户现象:确认门 VedAstro 一行长期 `not_evaluated`。独立的 `/api/rectification/v5/vedastro-validate` 存在,但 V9 评分从不调用分钟身份层;SearchEvents 也不是确认判据。 +- 触发条件:V9 `rectification-compare-candidates` / diagnostics 产生两名候选且本地 `acceptance_allowed`。 +- 根因:`build_decision_receipt` 把 `external_validation_status` 写死为 `not_evaluated`。评分 HTTP 层不附加官方分钟快照。超时若被标成 fail,会把「没跑完」说成「校验失败」。 +- 修复:本地接受门通过且有两个不同 HH:MM 时,并行跑官方分钟快照(升一/宫界、D9、D10、Dasha 指纹),不跑 SearchEvents。区分则 `passed`,完整比较后无法区分则 `failed`;超时、不完整或未就绪保持 `not_evaluated`。引擎 `confirmation_allowed` 仍为 false;公开 AA holdout 仍 `not_ready`,不得写唯一分钟。回执不带坐标或 fingerprint。 +- 验证:`tests/test_rectification_v5_vedastro_validation.py` 锁定通过/无法区分/超时/本地未就绪跳过,且不调用 range scan。 +- 防复发:V9 评分路径必须写 `gates.exact_confirmation.external_validation_status`;赶不上不得改成 fail;SearchEvents 不得单独放行确认。 +- 相关记录:BUG-301 +- 复发自:无 +- 修复版本:待提交 diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index f8c7f3e7..f784152c 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -258,7 +258,7 @@ const timingKeys = new Set([ "boundaries", "boundarycount", "start", "end", "startyear", "endyear", "iscurrent", "activationdescription", "sign", "lord", "planet", "name", "strength", "score", "source", "method", "confidence", "confidencecap", "summary", "conclusion", "fingerprint", - "charadasha", "transits", "sadesati", "triggers", "window", "sequence", "durationyears", + "charadasha", "transits", "sadesati", "triggers", "triggercount", "searchperiod", "window", "sequence", "durationyears", "fromage", "toage", "phase", "phasename", "moonsign", "saturnsign", "intensity", "active", "date", "target", "kind", "orb", "boundary", "claimboundary", ]); @@ -357,6 +357,43 @@ function projectTechniqueAudit(value: unknown): ModelOutputValue | undefined { return rows.length ? rows : undefined; } +function projectWesternTechnique(value: unknown): ModelOutputValue | undefined { + const row = record(value); + if (!Object.keys(row).length) return undefined; + const projected: { [key: string]: ModelOutputValue } = {}; + for (const key of [ + "status", "boundary", "target_date", "start_date", "end_date", "target_year", "return_date", "event_count", + ] as const) { + const scalar = boundedPrimitive(row[key]); + if (scalar !== undefined) projected[key] = scalar; + } + if (Array.isArray(row.aspects)) { + const aspects = row.aspects.slice(0, 6).map((item) => { + const aspect = record(item); + const compact: { [key: string]: ModelOutputValue } = {}; + for (const key of ["transit_planet", "natal_point", "aspect", "orb"] as const) { + const scalar = boundedPrimitive(aspect[key]); + if (scalar !== undefined) compact[key] = scalar; + } + return compact; + }).filter((item) => Object.keys(item).length); + if (aspects.length) projected.aspects = aspects; + } + if (Array.isArray(row.windows)) { + const windows = row.windows.slice(0, 6).map((item) => { + const window = record(item); + const compact: { [key: string]: ModelOutputValue } = {}; + for (const key of ["transit_planet", "natal_point", "aspect", "start_date", "end_date", "min_orb"] as const) { + const scalar = boundedPrimitive(window[key]); + if (scalar !== undefined) compact[key] = scalar; + } + return compact; + }).filter((item) => Object.keys(item).length); + if (windows.length) projected.windows = windows; + } + return Object.keys(projected).length ? projected : undefined; +} + function projectWesternSpectrum(value: unknown): ModelOutputValue | undefined { const spectrum = record(value); if (!Object.keys(spectrum).length) return undefined; @@ -378,13 +415,8 @@ function projectWesternSpectrum(value: unknown): ModelOutputValue | undefined { if (Object.keys(techniques).length) { const compact: { [key: string]: ModelOutputValue } = {}; for (const [name, layer] of Object.entries(techniques)) { - const row = record(layer); - const projected: { [key: string]: ModelOutputValue } = {}; - for (const key of ["status", "boundary"] as const) { - const scalar = boundedPrimitive(row[key]); - if (scalar !== undefined) projected[key] = scalar; - } - if (Object.keys(projected).length) compact[name] = projected; + const projected = projectWesternTechnique(layer); + if (projected !== undefined) compact[name] = projected; } if (Object.keys(compact).length) output.techniques = compact; } diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index d457fe06..6dea366b 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -47,6 +47,7 @@ When reference_transparency is present: - evidence_contract.must_use_layers is the executed shortlist for this answer. A placement, yoga, dasha boundary, or transit that is not in this packet was not delivered: do not invent it from model knowledge. - Do not paste that Technique Audit Table into the spoken answer. The product UI renders it collapsed from the delivered rows. This is the one audit surface; do not also dump JSON. - local_layers.dasha_sub_periods carries the antardasha boundaries inside the running mahadasha. When it is present, use those boundaries and never say sub-periods were not calculated; when it is absent, say so once instead of implying the calculation broke. +- local_layers.transits.search_period is the searched observation window. When it is present, an empty triggers list means no exact contact in that window, not that transits were skipped. Quote only delivered trigger dates; do not invent a retrograde or exact hit that is not in triggers. - Treat evidence_contract.answer_policy as a hard output contract. When can_answer_precise_timing is false, provide only direction or structure and do not state a month, date, or guaranteed timing outcome. - Treat answer_policy.deterministic_claims_forbidden_for as a hard prohibition. Do not use a restricted technique to make a deterministic conclusion. reference_only, partial, blocked, research_only_blocked, and partial_registry_only are commercial claim boundaries, not validated capabilities. - Treat rectification.boundary=not_auto_rectified as final: a candidate time or score is not a verified birth time and must not be presented as one. diff --git a/frontend/tests/consultation-context.test.ts b/frontend/tests/consultation-context.test.ts index 9543766e..64b7fc89 100644 --- a/frontend/tests/consultation-context.test.ts +++ b/frontend/tests/consultation-context.test.ts @@ -110,6 +110,8 @@ test("projects only bounded server-selected evidence to the model", () => { transits: { status: "executed", sade_sati: { phase: "none", moon_sign: "Taurus", saturn_sign: "Pisces" }, + search_period: { start: "2026-07-14", end: "2026-10-12" }, + triggers: [{ date: "2026-08-21", planet: "Saturn", target: "Moon", kind: "exact_hit", orb: 0.08 }], boundary: "observation windows, not guaranteed events", }, }, @@ -168,7 +170,13 @@ test("projects only bounded server-selected evidence to the model", () => { western_spectrum: { status: "executed", natal: { sun: "Aries", moon: "Taurus", ascendant: "Leo" }, - techniques: { transits: { status: "executed" } }, + techniques: { + transits: { + status: "executed", + target_date: "2026-07-14", + aspects: [{ transit_planet: "saturn", natal_point: "moon", aspect: "square", orb: 0.4 }], + }, + }, }, vedastro_cross_check: { status: "executed", @@ -237,6 +245,9 @@ test("projects only bounded server-selected evidence to the model", () => { assert.equal(serialized.includes("Venus"), true); assert.equal(serialized.includes("sade_sati"), true); assert.equal(serialized.includes("Taurus"), true); + assert.equal(serialized.includes("2026-08-21"), true); + assert.equal(serialized.includes("search_period"), true); + assert.equal(serialized.includes('"square"'), true); const mustUse = output.evidence_contract.must_use_layers; assert.equal(Array.isArray(mustUse), true); assert.equal((mustUse as string[]).includes("Yogas"), true); diff --git a/frontend/tests/consultation-spectrum-parity.test.ts b/frontend/tests/consultation-spectrum-parity.test.ts index 94c9f31c..86c5576f 100644 --- a/frontend/tests/consultation-spectrum-parity.test.ts +++ b/frontend/tests/consultation-spectrum-parity.test.ts @@ -28,6 +28,8 @@ test("web consult compute and projection keep the live skill's locally computed assert.match(workflow, /yogas: context\.local_layers\.yogas/); assert.match(workflow, /arudha_padas: context\.local_layers\.arudha_padas/); assert.match(workflow, /chara_dasha: context\.local_layers\.chara_dasha/); + assert.match(attach, /_search_consultation_transit_triggers/); + assert.match(attach, /search_transit_triggers/); assert.match(workflow, /transits: context\.local_layers\.transits/); assert.match(workflow, /must_use_layers/); assert.match(instructions, /must_use_layers is the executed shortlist/); diff --git a/frontend/tests/consultation-voice-contract.test.ts b/frontend/tests/consultation-voice-contract.test.ts index 315d0d36..d67c4c35 100644 --- a/frontend/tests/consultation-voice-contract.test.ts +++ b/frontend/tests/consultation-voice-contract.test.ts @@ -29,6 +29,7 @@ test("product voice keeps skill technique density without dumping JSON", () => { assert.match(mastra, /Do not paste that Technique Audit Table into the spoken answer/); assert.match(mastra, /must_use_layers is the executed shortlist/); assert.match(mastra, /do not invent it from model knowledge/); + assert.match(mastra, /search_period is the searched observation window/); assert.match(mastra, /Do not announce a skipped-domain inventory/); assert.doesNotMatch(mastra, /Name the domains you did cover, say plainly that the remaining ones were not calculated/); assert.doesNotMatch(mastra, /2-5 short paragraphs/); diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 6734b52e..8aaaa0fe 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -352,6 +352,151 @@ def _compare_vedastro_minute_snapshots(candidate_snapshots): } +def _vedastro_minute_sensitive_candidate_times(result) -> list[str]: + decisions = result.get('candidate_decisions') if isinstance(result, dict) else None + times = [] + if not isinstance(decisions, list): + return times + for item in decisions: + if not isinstance(item, dict): + continue + time_value = item.get('time') + if ( + isinstance(time_value, str) + and re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', time_value) + and time_value not in times + ): + times.append(time_value) + if len(times) == 2: + break + return times + + +def _unevaluated_vedastro_minute_sensitive(reason: str) -> dict: + return { + 'status': 'not_evaluated', + 'summary': { + 'reason': reason, + 'discriminated': False, + }, + } + + +def _evaluate_vedastro_minute_sensitive_pair(request, times: list[str]) -> dict: + if not isinstance(request, dict) or len(times) != 2: + return _unevaluated_vedastro_minute_sensitive('vedastro_minute_pair_unavailable') + try: + birth_day = datetime.strptime(str(request.get('birth_date') or ''), '%Y-%m-%d') + lat = float(request['lat']) + lon = float(request['lon']) + tz = float(request['tz']) + except (KeyError, TypeError, ValueError): + return _unevaluated_vedastro_minute_sensitive('vedastro_minute_request_incomplete') + try: + adapter = _load_local_module('vedastro_service_adapter') + except Exception: + return _unevaluated_vedastro_minute_sensitive('vedastro_minute_adapter_unavailable') + + def snapshot_for(candidate_time: str): + hour, minute = candidate_time.split(':', 1) + case = { + 'year': birth_day.year, + 'month': birth_day.month, + 'day': birth_day.day, + 'hour': int(hour), + 'minute': int(minute), + 'second': 0, + 'lat': lat, + 'lon': lon, + 'tz': tz, + } + return _safe_vedastro_adapter_call( + adapter.run_rectification_minute_snapshot_for_case, + case, + case_id=f'rectification_v5_minute_{candidate_time.replace(":", "")}', + ) + + timeout = _foreground_vedastro_budget_seconds() + futures = [_FOREGROUND_VEDASTRO_EXECUTOR.submit(snapshot_for, candidate_time) for candidate_time in times] + reports = [] + deadline = time.monotonic() + timeout + for future in futures: + remaining = max(0.0, deadline - time.monotonic()) + try: + reports.append(future.result(timeout=remaining)) + except FuturesTimeoutError: + return _unevaluated_vedastro_minute_sensitive('vedastro_minute_snapshot_timeout') + except Exception: + return _unevaluated_vedastro_minute_sensitive('vedastro_minute_snapshot_error') + summaries = [ + _safe_vedastro_minute_snapshot_summary(candidate_time, report) + for candidate_time, report in zip(times, reports) + ] + if any( + report.get('status') == 'timeout' or report.get('_failure_kind') == 'timeout' + for report in reports + ): + return _unevaluated_vedastro_minute_sensitive('vedastro_minute_snapshot_timeout') + complete = all( + _vedastro_minute_snapshot_is_complete(report, summary) + for report, summary in zip(reports, summaries) + ) + if not complete: + return _unevaluated_vedastro_minute_sensitive('vedastro_minute_snapshot_incomplete') + comparison = _compare_vedastro_minute_snapshots(summaries) + discriminated_layers = [ + str(name) for name in (comparison.get('discriminated_layers') or [])[:6] if name + ] + if comparison.get('discriminated'): + return { + 'status': 'passed', + 'summary': { + 'reason': 'minute_sensitive_layers_discriminated', + 'discriminated': True, + 'discriminated_layers': discriminated_layers, + }, + } + return { + 'status': 'failed', + 'summary': { + 'reason': 'vedastro_minute_sensitive_layers_not_discriminated', + 'discriminated': False, + }, + } + + +def _attach_vedastro_minute_sensitive_to_score(result, request): + if not isinstance(result, dict): + return result + receipt = result.get('decision_receipt') + if not isinstance(receipt, dict): + receipt = result.get('candidate_decision_receipt') + if not isinstance(receipt, dict): + return result + exact = receipt.setdefault('gates', {}).setdefault('exact_confirmation', {}) + if not isinstance(exact, dict): + exact = {} + receipt.setdefault('gates', {})['exact_confirmation'] = exact + times = _vedastro_minute_sensitive_candidate_times(result) + if len(times) < 2 or receipt.get('acceptance_allowed') is not True: + exact.setdefault('external_validation_status', 'not_evaluated') + exact.setdefault('vedastro_minute_sensitive', { + 'reason': 'local_candidate_not_ready_for_minute_sensitive_check', + 'discriminated': False, + }) + result['decision_receipt'] = receipt + if isinstance(result.get('candidate_decision_receipt'), dict): + result['candidate_decision_receipt'] = receipt + return result + evaluation = _evaluate_vedastro_minute_sensitive_pair(request, times) + exact['external_validation_status'] = evaluation['status'] + exact['vedastro_minute_sensitive'] = evaluation['summary'] + result['decision_receipt'] = receipt + if 'candidate_decision_receipt' in result: + result['candidate_decision_receipt'] = receipt + return result + + def _safe_vedastro_adapter_call(call, *args, **kwargs): try: result = call(*args, **kwargs) @@ -582,6 +727,10 @@ _WESTERN_TIMING_LAYER_NAMES = ( 'transit_duration_scan', 'parans', ) +_CONSULTATION_TRANSIT_WINDOW_DAYS = 90 +_CONSULTATION_TRANSIT_PLANETS = ('Saturn', 'Jupiter', 'Rahu', 'Ketu') +_WESTERN_ASPECT_KEYS = ('transit_planet', 'natal_point', 'aspect', 'orb') +_WESTERN_WINDOW_KEYS = ('transit_planet', 'natal_point', 'aspect', 'start_date', 'end_date', 'min_orb') def _western_default_timing(body: dict) -> dict: @@ -880,6 +1029,159 @@ def _sign_of(point) -> str | None: return None +def _iso_date_only(value) -> str | None: + if hasattr(value, 'strftime'): + try: + return value.strftime('%Y-%m-%d') + except (TypeError, ValueError, OverflowError): + return None + if isinstance(value, str) and re.match(r'\d{4}-\d{2}-\d{2}', value): + return value[:10] + return None + + +def _consultation_point_lon(point) -> float | None: + if not isinstance(point, dict): + return None + raw = point.get('lon') + if raw is None: + raw = point.get('longitude') + if raw is None: + raw = point.get('degree_raw') + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _compact_consultation_trigger(item) -> dict | None: + if not isinstance(item, dict): + return None + date = _iso_date_only(item.get('date') or item.get('start_date')) + end = _iso_date_only(item.get('end_date')) + planet = item.get('planet') + target = item.get('target') or item.get('sensitive_point') + kind = item.get('kind') or item.get('type') or item.get('event') + compact = {} + if date: + compact['date'] = date + if isinstance(planet, str) and planet.strip(): + compact['planet'] = planet.strip() + if isinstance(target, str) and target.strip(): + compact['target'] = target.strip() + if isinstance(kind, str) and kind.strip(): + compact['kind'] = kind.strip()[:80] + orb = item.get('orb') + if isinstance(orb, (int, float)): + compact['orb'] = round(float(orb), 2) + if date and end and end != date: + compact['window'] = f'{date}..{end}' + return compact or None + + +def _consultation_transit_search_window(body: dict) -> tuple[datetime, datetime]: + start = _consultation_reference_date(body).replace(tzinfo=None, hour=0, minute=0, second=0, microsecond=0) + return start, start + timedelta(days=_CONSULTATION_TRANSIT_WINDOW_DAYS) + + +def _search_consultation_transit_triggers(chart: dict, body: dict, *, ayanamsa: str) -> tuple[list, dict]: + start, end = _consultation_transit_search_window(body) + period = {'start': start.strftime('%Y-%m-%d'), 'end': end.strftime('%Y-%m-%d')} + existing = chart.get('transit_triggers') + if isinstance(existing, list) and existing: + compact = [row for row in (_compact_consultation_trigger(item) for item in existing[:6]) if row] + if compact: + return compact, period + planets = chart.get('planets') if isinstance(chart.get('planets'), dict) else {} + ascendant = chart.get('ascendant') if isinstance(chart.get('ascendant'), dict) else {} + points = [] + asc_lon = _consultation_point_lon(ascendant) + if asc_lon is not None: + points.append(('Ascendant', asc_lon)) + moon = planets.get('Moon') if isinstance(planets.get('Moon'), dict) else {} + moon_lon = _consultation_point_lon(moon) + if moon_lon is not None: + points.append(('Moon', moon_lon)) + if not points: + return [], period + transit_mod = _load_local_module('transit_trigger') + found = [] + for name, degree in points: + for planet in _CONSULTATION_TRANSIT_PLANETS: + rows = transit_mod.search_transit_triggers( + planet, + degree, + start, + end, + ayanamsa_name=ayanamsa, + ) + for row in rows: + if not isinstance(row, dict): + continue + payload = dict(row) + payload.setdefault('planet', planet) + payload.setdefault('sensitive_point', name) + compact = _compact_consultation_trigger(payload) + if compact: + found.append(compact) + if len(found) >= 6: + found.sort(key=lambda item: item.get('date') or '9999-12-31') + return found[:6], period + found.sort(key=lambda item: item.get('date') or '9999-12-31') + return found[:6], period + + +def _compact_scalar_rows(rows, keys, limit=6) -> list: + compact = [] + if not isinstance(rows, list): + return compact + for item in rows: + if not isinstance(item, dict): + continue + row = {} + for key in keys: + value = item.get(key) + if key in {'start_date', 'end_date', 'target_date'} or str(key).endswith('_date'): + value = _iso_date_only(value) + if value is None or value == '': + continue + if isinstance(value, float): + row[key] = round(value, 4) + elif isinstance(value, (str, int, bool)): + row[key] = value + if row: + compact.append(row) + if len(compact) >= limit: + break + return compact + + +def _compact_western_timing_layer(layer: dict) -> dict: + compact = { + 'status': _audit_row_status(layer.get('status') or 'executed'), + 'boundary': layer.get('boundary') or layer.get('reason'), + } + for key in ('target_date', 'start_date', 'end_date'): + date = _iso_date_only(layer.get(key)) + if date: + compact[key] = date + year = layer.get('target_year') + if isinstance(year, int): + compact['target_year'] = year + return_date = _iso_date_only(layer.get('return_local_time')) + if return_date: + compact['return_date'] = return_date + aspects = _compact_scalar_rows(layer.get('aspects'), _WESTERN_ASPECT_KEYS) + if aspects: + compact['aspects'] = aspects + windows = _compact_scalar_rows(layer.get('windows'), _WESTERN_WINDOW_KEYS) + if windows: + compact['windows'] = windows + if isinstance(layer.get('event_count'), int): + compact['event_count'] = layer['event_count'] + return compact + + def _compact_western_spectrum(packet: dict | None) -> dict: if not isinstance(packet, dict) or not packet: return { @@ -896,10 +1198,7 @@ def _compact_western_spectrum(packet: dict | None) -> dict: for name in _WESTERN_TIMING_LAYER_NAMES: layer = timing.get(name) if isinstance(layer, dict): - techniques[name] = { - 'status': _audit_row_status(layer.get('status') or 'executed'), - 'boundary': layer.get('boundary') or layer.get('reason'), - } + techniques[name] = _compact_western_timing_layer(layer) else: techniques[name] = {'status': 'blocked', 'boundary': 'layer_not_materialized'} natal_status = 'executed' if packet.get('status') != 'blocked' and natal_block else str(packet.get('status') or 'blocked') @@ -1358,21 +1657,31 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, if not compact_sade: compact_sade = None triggers = [] - raw_triggers = chart.get('transit_triggers') or (modules.get('transit_triggers') if isinstance(modules.get('transit_triggers'), list) else []) - if isinstance(raw_triggers, list): - for item in raw_triggers[:6]: - if not isinstance(item, dict): - continue - triggers.append({ - key: item[key] - for key in ('date', 'planet', 'target', 'kind', 'orb') - if key in item and item[key] is not None - }) + search_period = None + try: + triggers, search_period = _search_consultation_transit_triggers( + chart, + body, + ayanamsa=str(body.get('ayanamsa') or birth_payload.get('ayanamsa') or 'lahiri'), + ) + except Exception as trigger_exc: + start, end = _consultation_transit_search_window(body) + search_period = {'start': start.strftime('%Y-%m-%d'), 'end': end.strftime('%Y-%m-%d')} + diagnostics.append({ + 'layer': 'transit_triggers', + 'status': 'unavailable', + 'reason': trigger_exc.__class__.__name__, + }) modules['transits'] = { - 'status': 'executed' if compact_sade or triggers else 'blocked', + 'status': 'executed' if compact_sade or triggers or search_period else 'blocked', 'sade_sati': compact_sade, 'triggers': triggers, - 'boundary': 'observation windows, not guaranteed events', + 'trigger_count': len(triggers), + 'search_period': search_period, + 'boundary': ( + 'observation windows, not guaranteed events; ' + 'empty triggers means none in the searched window, not that transits were skipped' + ), } except Exception as exc: modules['transits'] = { @@ -8691,18 +9000,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): def _compute_rectification_v5_score(self, body): from scripts.rectification.api_service import score_candidates + request = self._rectification_v5_request(body) return { 'success': True, 'endpoint': 'rectification_v5_score', - **score_candidates(self._rectification_v5_request(body)), + **_attach_vedastro_minute_sensitive_to_score(score_candidates(request), request), } def _compute_rectification_v5_diagnostics(self, body): from scripts.rectification.api_service import diagnostics + request = self._rectification_v5_request(body) return { 'success': True, 'endpoint': 'rectification_v5_diagnostics', - **diagnostics(self._rectification_v5_request(body)), + **_attach_vedastro_minute_sensitive_to_score(diagnostics(request), request), } def _compute_rectification_v5_vedastro_validate(self, body): diff --git a/tests/test_consultation_consumer_context.py b/tests/test_consultation_consumer_context.py index 188ed7df..61c09db0 100644 --- a/tests/test_consultation_consumer_context.py +++ b/tests/test_consultation_consumer_context.py @@ -21,6 +21,7 @@ from jyotish_api_server import ( # noqa: E402 _attach_local_consultation_layers, _build_consumer_context, _compact_vedastro_cross_check, + _compact_western_spectrum, ) from unified_consultation_orchestrator import UnifiedConsultationOrchestrator # noqa: E402 @@ -142,6 +143,9 @@ def test_local_consultation_layers_include_yogas_transits_and_route_chara() -> N assert transits['sade_sati']['moon_sign'] assert transits['sade_sati']['saturn_sign'] assert transits['sade_sati']['phase'] in {'none', 'rising', 'peak', 'setting'} + assert transits['search_period'] == {'start': '2026-07-14', 'end': '2026-10-12'} + assert isinstance(transits['triggers'], list) + assert transits['trigger_count'] == len(transits['triggers']) assert 'yogas' in career['local_consultation_layers']['available'] assert 'chara_dasha' in career['local_consultation_layers']['available'] assert 'transits' in career['local_consultation_layers']['available'] @@ -156,6 +160,102 @@ def test_local_consultation_layers_include_yogas_transits_and_route_chara() -> N assert 'chara_dasha' not in wealth['local_consultation_layers']['available'] +def test_consultation_computes_transit_trigger_windows(monkeypatch) -> None: + class FakeTransit: + @staticmethod + def search_transit_triggers(planet, target_longitude, start_date, end_date, orb=1.0, natal_planets=None, ayanamsa_name='lahiri'): + if planet != 'Saturn' or abs(float(target_longitude) - 31.4) > 0.2: + return [] + return [{ + 'date': datetime(2026, 8, 21), + 'planet': 'Saturn', + 'sensitive_point': 'Moon', + 'type': 'exact_hit', + 'orb': 0.08, + 'event': 'Saturn exact on Moon', + }] + + import jyotish_api_server as api + real_load = api._load_local_module + + def load(name): + if name == 'transit_trigger': + return FakeTransit + return real_load(name) + + monkeypatch.setattr(api, '_load_local_module', load) + api._LOCAL_MODULE_CACHE.pop('transit_trigger', None) + chart = _attach_local_consultation_layers( + _handler(), + _base_chart(), + dict(_BIRTH), + {'current_date': _REFERENCE_DATE}, + ) + transits = chart['modules']['transits'] + assert transits['search_period'] == {'start': '2026-07-14', 'end': '2026-10-12'} + assert transits['triggers'][0]['date'] == '2026-08-21' + assert transits['triggers'][0]['planet'] == 'Saturn' + assert transits['triggers'][0]['target'] == 'Moon' + assert transits['triggers'][0]['kind'] == 'exact_hit' + + +def test_compact_western_spectrum_keeps_windows_and_drops_clocks() -> None: + compact = _compact_western_spectrum({ + 'status': 'executed', + 'native_chart': { + 'zodiac': 'tropical', + 'natal': { + 'planets': {'sun': {'sign': 'Aries'}, 'moon': {'sign': 'Taurus'}}, + 'ascendant': {'sign': 'Leo'}, + }, + }, + 'timing_techniques': { + 'transits': { + 'status': 'used', + 'target_date': '2026-07-14', + 'aspects': [{'transit_planet': 'saturn', 'natal_point': 'moon', 'aspect': 'square', 'orb': 0.4}], + 'transit_planets': {'saturn': {'longitude': 25.03}}, + 'boundary': 'snapshot', + }, + 'solar_return': { + 'status': 'used', + 'target_year': 2026, + 'return_local_time': '2026-04-17T08:32:11+08:00', + 'natal_sun_longitude': 25.03, + 'boundary': 'exact return', + }, + 'transit_duration_scan': { + 'status': 'used', + 'start_date': '2026-07-14', + 'end_date': '2026-07-27', + 'windows': [{ + 'transit_planet': 'jupiter', + 'natal_point': 'ascendant', + 'aspect': 'trine', + 'start_date': '2026-07-14', + 'end_date': '2026-07-20', + 'min_orb': 0.2, + }], + 'daily_hits': [{'date': '2026-07-14'}], + 'boundary': 'windows', + }, + }, + }) + transits = compact['techniques']['transits'] + assert transits['target_date'] == '2026-07-14' + assert transits['aspects'][0]['transit_planet'] == 'saturn' + assert 'transit_planets' not in transits + solar = compact['techniques']['solar_return'] + assert solar['target_year'] == 2026 + assert solar['return_date'] == '2026-04-17' + assert 'return_local_time' not in solar + assert '08:32' not in str(compact) + assert '25.03' not in str(compact) + windows = compact['techniques']['transit_duration_scan']['windows'] + assert windows[0]['start_date'] == '2026-07-14' + assert windows[0]['end_date'] == '2026-07-20' + + def test_consultation_computes_formal_and_research_varga_spectrum() -> None: chart = _attach_local_consultation_layers( _handler(), diff --git a/tests/test_rectification_v5_vedastro_validation.py b/tests/test_rectification_v5_vedastro_validation.py index e9d3e4eb..d5ef5535 100644 --- a/tests/test_rectification_v5_vedastro_validation.py +++ b/tests/test_rectification_v5_vedastro_validation.py @@ -7,6 +7,7 @@ from scripts.jyotish_api_server import ( TECHNIQUE_EXAMPLE_ENDPOINTS, BadRequest, JyotishAPIHandler, + _attach_vedastro_minute_sensitive_to_score, ) @@ -261,3 +262,121 @@ def test_timeout_is_returned_as_safe_failure(monkeypatch): assert "vedastro_timeout" in result["blockers"] assert "vedastro_exception" in result["blockers"] assert "secret" not in str(result) + + +def _score_request(): + return { + "birth_date": "1997-08-08", + "lat": 36.419, + "lon": 114.213, + "tz": 8, + } + + +def _scored_result(*, acceptance=True): + receipt = { + "acceptance_allowed": acceptance, + "confirmation_allowed": False, + "gates": {"exact_confirmation": {"external_validation_status": "not_evaluated"}}, + } + return { + "candidate_decisions": [ + {"time": "05:13", "rank": 1}, + {"time": "05:14", "rank": 2}, + ], + "decision_receipt": receipt, + "candidate_decision_receipt": receipt, + "confirmation_allowed": False, + } + + +def test_v5_score_attaches_minute_sensitive_pass_without_search_events(monkeypatch): + range_calls = [] + + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + return minute_snapshot(case) + + @staticmethod + def run_range_scan_for_case(case, domain, start, end, case_id="user_chart"): + range_calls.append((domain, start, end)) + return {"available": True, "status": "ok"} + + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + result = _attach_vedastro_minute_sensitive_to_score(_scored_result(), _score_request()) + exact = result["decision_receipt"]["gates"]["exact_confirmation"] + assert exact["external_validation_status"] == "passed" + assert exact["vedastro_minute_sensitive"]["discriminated"] is True + assert result["confirmation_allowed"] is False + assert range_calls == [] + serialized = str(result) + assert "36.419" not in serialized + assert "114.213" not in serialized + assert "asc-" not in serialized + + +def test_v5_score_marks_identical_minute_snapshots_failed_not_not_evaluated(monkeypatch): + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + return minute_snapshot(case, same=True) + + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + result = _attach_vedastro_minute_sensitive_to_score(_scored_result(), _score_request()) + exact = result["decision_receipt"]["gates"]["exact_confirmation"] + assert exact["external_validation_status"] == "failed" + assert exact["vedastro_minute_sensitive"]["reason"] == "vedastro_minute_sensitive_layers_not_discriminated" + assert result["confirmation_allowed"] is False + + +def test_v5_score_timeout_stays_not_evaluated(monkeypatch): + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + raise TimeoutError("secret upstream URL") + + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + result = _attach_vedastro_minute_sensitive_to_score(_scored_result(), _score_request()) + exact = result["decision_receipt"]["gates"]["exact_confirmation"] + assert exact["external_validation_status"] == "not_evaluated" + assert exact["vedastro_minute_sensitive"]["reason"] == "vedastro_minute_snapshot_timeout" + assert "secret" not in str(result) + assert result["confirmation_allowed"] is False + + +def test_v5_score_skips_vedastro_until_local_acceptance(monkeypatch): + calls = [] + + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + calls.append(True) + return minute_snapshot(case) + + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + result = _attach_vedastro_minute_sensitive_to_score( + _scored_result(acceptance=False), + _score_request(), + ) + exact = result["decision_receipt"]["gates"]["exact_confirmation"] + assert exact["external_validation_status"] == "not_evaluated" + assert calls == [] + + +def test_v5_score_join_timeout_stays_not_evaluated(monkeypatch): + import time + + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + time.sleep(0.4) + return minute_snapshot(case) + + monkeypatch.setattr("scripts.jyotish_api_server._foreground_vedastro_budget_seconds", lambda: 0.05) + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + result = _attach_vedastro_minute_sensitive_to_score(_scored_result(), _score_request()) + exact = result["decision_receipt"]["gates"]["exact_confirmation"] + assert exact["external_validation_status"] == "not_evaluated" + assert exact["vedastro_minute_sensitive"]["reason"] == "vedastro_minute_snapshot_timeout" + assert result["confirmation_allowed"] is False