diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d57831fc..cf9784ee 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4490,3 +4490,19 @@ - 相关记录:BUG-265、BUG-269、BUG-283 - 复发自:BUG-283(不再等模型,但仍把完整排盘当首页可用性) - 修复版本:待提交 + +## BUG-300 | 网页咨询把已算技法从模型包裁掉,模型用参数知识填洞 + +- 状态:resolved +- 首次发现:2026-08-19 +- 最近更新:2026-08-19 +- 影响面:普通咨询 `toModelOutput`、`_attach_local_consultation_layers`、口头回答密度 +- 用户现象:线上咨询缺 Yoga、Arudha/UL/A10、Chara、行运等本地 skill 会用的结构,模型用常识补度数和宫位。 +- 触发条件:有出生分钟的个人盘咨询。计算工作流已挂上分盘谱、Narayana、功能性吉凶,但发给模型的证据包没有这些层。 +- 根因:`toModelOutput` 的本命投影只保留升一/行星/宫位/Shadbala/AV/功能性吉凶/分盘谱。Yoga、Arudha、Gulika、Kakshya、KP、Chara、行运留在 `toAgentConsultationContext` 里却不进模型包。咨询路径也未把 Yoga/Chara/Sade Sati 写入 modules。 +- 修复:咨询 attach 补 Yoga、路线相关 Chara、Sade Sati 行运。模型包投影这些层,并给出 `must_use_layers` 已执行短名单。未交付层禁止用常识补算。MEVG / VedAstro 仍诚实 `blocked`。不绑 AGENTS.md 或整份 SKILL。 +- 验证:`frontend/tests/consultation-context.test.ts`、`consultation-spectrum-parity.test.ts`、`consultation-voice-contract.test.ts`;`tests/test_consultation_consumer_context.py`。 +- 防复发:咨询 `toModelOutput` 必须包含 skill 共同基础里本地已执行层;`local_layers` 不得再读引擎未写入的 module 键。 +- 相关记录:BUG-279、BUG-287、BUG-298 +- 复发自:BUG-279(算了却不交给模型) +- 修复版本:待提交 diff --git a/frontend/src/lib/consultation-technique-audit.ts b/frontend/src/lib/consultation-technique-audit.ts index 0370884b..8ab312da 100644 --- a/frontend/src/lib/consultation-technique-audit.ts +++ b/frontend/src/lib/consultation-technique-audit.ts @@ -34,6 +34,9 @@ const TECHNIQUE_LABELS: Readonly> = { "Arudha / UL / A10": "Arudha / UL / A10", "Narayana Dasha": "Narayana Dasha", "Vimshottari sub-periods": "Vimshottari 小运", + Yogas: "格局 Yoga", + "Chara Dasha": "Chara Dasha", + "Transits / Sade Sati": "行运 / 土星过月", Ashtakavarga: "Ashtakavarga", "KP cusps": "KP 宫头", "Gulika / Mandi": "Gulika / Mandi", @@ -81,6 +84,8 @@ const NOTE_LABELS: Readonly> = { observation_only: "仅观察层", "Chesta/Sthana/Dig/Drik/Kala/Naisargika as components; absolute Virupa truth is not closed": "绝对 Virupa 真值未闭环", "observation_only; not verified day/month timing": "仅观察层,不作已核验日期", + "observation windows, not guaranteed events": "观察窗口,不是保证事件", + "commercial claims stay non-deterministic; not a majority-vote truth": "商业断语保持非确定,不以多数引擎表决", "this run is a natal consultation": "本轮为本命咨询", horary_path: "问卦路径", "requires a partner chart": "需要伴侣盘", diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 9a2adb7d..ed5f35aa 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -434,6 +434,7 @@ function mergeConsultationEvidencePackets( ...(audit !== undefined ? { technique_audit_table: audit } : {}), ...(varga !== undefined ? { varga_spectrum: varga } : {}), ...(western !== undefined ? { western_spectrum: western } : {}), + must_use_layers: unionStringList(contracts.map((contract) => contract.must_use_layers)), }, claim_cards: options.claimCards, // `not_auto_rectified` is the restrictive boundary, so one domain reporting diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index d103e0f6..0f427fca 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -176,6 +176,7 @@ export const consultationEvidencePacketSchema = z.object({ technique_audit_table: modelOutputEvidenceSchema.optional(), varga_spectrum: modelOutputEvidenceSchema.optional(), western_spectrum: modelOutputEvidenceSchema.optional(), + must_use_layers: modelOutputEvidenceSchema.optional(), }), claim_cards: z.array(z.object({ category: z.enum(consultationEvidenceCategoryValues), @@ -247,6 +248,8 @@ const natalFoundationKeys = new Set([ "components", "matrix", "matrixshape", "signs", "sources", "source", "method", "boundary", "functionalbeneficmalefic", "functionalbenefics", "functionalmalefics", "functionalneutrals", "yogakarakas", "ownedhouses", "ascendantsign", "effectonconfidence", + "yogas", "category", "arudhapadas", "padas", "a10", "a7", "ul", "al", "gulika", "mandi", "kakshya", + "kpcusps", "cusps", "sublord", "star", "starlord", "claimboundary", "reason", ]); const timingKeys = new Set([ @@ -255,6 +258,9 @@ 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", + "fromage", "toage", "phase", "phasename", "moonsign", "saturnsign", "intensity", "active", + "date", "target", "kind", "orb", "boundary", "claimboundary", ]); const validationKeys = new Set([ @@ -334,10 +340,17 @@ function projectTechniqueAudit(value: unknown): ModelOutputValue | undefined { const rows = value.slice(0, 80).map((item) => { const row = record(item); const projected: { [key: string]: ModelOutputValue } = {}; - for (const key of ["technique", "status", "system", "boundary"] as const) { + for (const key of ["technique", "status", "system", "boundary", "effect_on_confidence"] as const) { const scalar = boundedPrimitive(row[key]); if (scalar !== undefined) projected[key] = scalar; } + for (const key of ["key_functional_benefics", "key_functional_malefics"] as const) { + if (!Array.isArray(row[key])) continue; + const names = row[key] + .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + .slice(0, 9); + if (names.length) projected[key] = names; + } return projected; }).filter((row) => Object.keys(row).length > 0); return rows.length ? rows : undefined; @@ -385,6 +398,11 @@ function projectNatalFoundation(context: ReturnType typeof item === "string").slice(0, limit) : []; } +const SHARED_MUST_USE_LAYERS = [ + "Formal Vargas D1–D60", + "Functional Benefic/Malefic", + "Yogas", + "Vimshottari sub-periods", + "Narayana Dasha", + "Shadbala components", + "Ashtakavarga", + "Arudha / UL / A10", + "Western natal (tropical)", + "Transits / Sade Sati", +] as const; + +const DOMAIN_MUST_USE_LAYERS: Readonly> = { + career: ["Formal Vargas D1–D60", "Arudha / UL / A10", "Narayana Dasha", "Chara Dasha"], + marriage: ["Formal Vargas D1–D60", "Arudha / UL / A10"], + wealth: ["Formal Vargas D1–D60", "Ashtakavarga"], + timing: ["Narayana Dasha", "Vimshottari sub-periods", "Transits / Sade Sati", "Chara Dasha"], + annual: ["Narayana Dasha", "Vimshottari sub-periods", "Chara Dasha"], + health: ["Formal Vargas D1–D60", "Shadbala components"], +}; + +function mustUseExecutedLayers(route: string, audit: unknown): string[] { + const rows = Array.isArray(audit) ? audit : []; + const executed = new Set( + rows + .map((item) => record(item)) + .filter((row) => row.status === "executed") + .map((row) => String(row.technique || "")), + ); + const wanted = [...SHARED_MUST_USE_LAYERS, ...(DOMAIN_MUST_USE_LAYERS[route] ?? [])]; + const output: string[] = []; + const seen = new Set(); + for (const name of wanted) { + if (!executed.has(name) || seen.has(name)) continue; + seen.add(name); + output.push(name); + } + return output.slice(0, 24); +} + function projectEvidenceContract(contract: ReturnType["evidence_contract"]) { const policy = record(contract.answer_policy); const audit = projectTechniqueAudit(contract.technique_audit_table); const varga = projectVargaSpectrum(contract.varga_spectrum); const western = projectWesternSpectrum(contract.western_spectrum); + const mustUse = mustUseExecutedLayers(String(contract.route || ""), contract.technique_audit_table); return { available_layers: stringList(contract.available_layers, 80), missing_route_layers: stringList(contract.missing_route_layers, 40), @@ -478,6 +540,7 @@ function projectEvidenceContract(contract: ReturnType 0 ? { must_use_layers: mustUse } : {}), }; } @@ -570,6 +633,10 @@ export function toAgentConsultationContext(data: JsonRecord) { // precise timing as allowed. The field now names the layer the engine actually attaches. dasha_sub_periods: modules.dasha_sub_periods, narayana_dasha: modules.narayana_dasha, gulika: modules.gulika, kakshya: modules.kakshya, + yogas: modules.yogas || chart.yogas, + kp_cusps: modules.kp_cusps, + chara_dasha: modules.chara_dasha, + transits: modules.transits, functional_benefic_malefic: modules.functional_benefic_malefic || record(data.machine_evidence_packet).functional_benefic_malefic, }, diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index fccdae20..d457fe06 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -44,6 +44,7 @@ When reference_transparency is present: - Only say the chart calculation failed when evidence_contract.hard_blockers is non-empty. - Never claim D2, D11, D9, D10, A10, UL, Narayana Dasha, a delivered Varga, or a delivered Western layer is missing when it appears in evidence_contract.available_layers, evidence_contract.varga_spectrum, evidence_contract.western_spectrum, chart, or local_layers. - evidence_contract.technique_audit_table is the invocation record for this run. Use every executed layer that is relevant to the question. Status executed means the server ran it; blocked means it could not; not_applicable means it does not apply. Never treat an omitted row as permission to invent the technique, and never silently skip an executed layer the question needs. +- 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. - 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. diff --git a/frontend/tests/consultation-context.test.ts b/frontend/tests/consultation-context.test.ts index 54fd5dd1..986a9d79 100644 --- a/frontend/tests/consultation-context.test.ts +++ b/frontend/tests/consultation-context.test.ts @@ -93,6 +93,25 @@ test("projects only bounded server-selected evidence to the model", () => { functional_malefics: ["Mercury"], }, kakshya: { status: "observation_only", boundary: "not verified day/month timing" }, + yogas: { status: "executed", count: 1, yogas: [{ name: "Gaja Kesari", planets: ["Jupiter", "Moon"] }] }, + arudha_padas: { + padas: { + A10: { name: "Karma Pada (A10)", sign: "Capricorn" }, + UL: { name: "Upapada (UL)", sign: "Libra" }, + }, + }, + gulika: { sign: "Scorpio", claim_boundary: "observation_only" }, + kp_cusps: { houses: { "10": { sign: "Taurus", sublord: "Saturn" } } }, + chara_dasha: { + status: "executed", + current: { sign: "Libra", lord: "Venus", duration_years: 8 }, + sequence: [{ sign: "Libra", lord: "Venus", duration_years: 8 }], + }, + transits: { + status: "executed", + sade_sati: { phase: "none", moon_sign: "Taurus", saturn_sign: "Pisces" }, + boundary: "observation windows, not guaranteed events", + }, }, }, routing: { primary_theme: "career" }, @@ -133,7 +152,12 @@ test("projects only bounded server-selected evidence to the model", () => { user_facing_limitation: "精确月份暂不可用", technique_audit_table: [ { technique: "Formal Vargas D1–D60", status: "executed", system: "jyotish", boundary: "20/20 named traditional charts" }, + { technique: "Yogas", status: "executed", system: "jyotish" }, + { technique: "Arudha / UL / A10", status: "executed", system: "jyotish" }, + { technique: "Narayana Dasha", status: "executed", system: "jyotish" }, + { technique: "Functional Benefic/Malefic", status: "executed", key_functional_benefics: ["Jupiter"] }, { technique: "Western natal (tropical)", status: "executed", system: "western" }, + { technique: "MEVG / Global Web Evidence", status: "blocked", boundary: "caps_claims_until_global_web_evidence_runs" }, ], varga_spectrum: { status: "used", @@ -197,4 +221,16 @@ test("projects only bounded server-selected evidence to the model", () => { assert.equal(serialized.includes("technique_audit_table"), true); assert.equal(serialized.includes("functional_benefics"), true); assert.equal(serialized.includes("Jupiter"), true); + assert.equal(serialized.includes("Gaja Kesari"), true); + assert.equal(serialized.includes("Karma Pada (A10)"), true); + assert.equal(serialized.includes("chara_dasha"), true); + assert.equal(serialized.includes("Venus"), true); + assert.equal(serialized.includes("sade_sati"), true); + assert.equal(serialized.includes("Taurus"), true); + const mustUse = output.evidence_contract.must_use_layers; + assert.equal(Array.isArray(mustUse), true); + assert.equal((mustUse as string[]).includes("Yogas"), true); + assert.equal((mustUse as string[]).includes("Arudha / UL / A10"), true); + assert.equal((mustUse as string[]).includes("MEVG / Global Web Evidence"), false); + assert.equal(serialized.includes("https://"), false); }); diff --git a/frontend/tests/consultation-spectrum-parity.test.ts b/frontend/tests/consultation-spectrum-parity.test.ts new file mode 100644 index 00000000..883acd95 --- /dev/null +++ b/frontend/tests/consultation-spectrum-parity.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const root = resolve(fileURLToPath(new URL("../..", import.meta.url))); + +test("web consult compute and projection keep the live skill's locally computed spectrum", () => { + const skill = readFileSync(resolve(root, "SKILL.md"), "utf8"); + const attach = readFileSync(resolve(root, "scripts/jyotish_api_server.py"), "utf8"); + const workflow = readFileSync(resolve(root, "frontend/src/mastra/consultation-workflow.ts"), "utf8"); + const instructions = readFileSync(resolve(root, "frontend/src/mastra/index.ts"), "utf8"); + + assert.match(skill, /共同基础:D1、功能性吉凶星、相关 Yoga、Vimshottari 与 Narayana Dasha、行运、Shadbala、Ashtakavarga/); + assert.match(skill, /事业至少 `D10 \+ A10`/); + assert.match(skill, /婚恋至少 `D9 \+ UL`/); + + for (const layer of ["yogas", "chara_dasha", "transits", "arudha_padas", "narayana_dasha"]) { + assert.match(attach, new RegExp(`modules\\['${layer}'\\]`), layer); + } + assert.match(attach, /_compact_yoga_entry/); + assert.match(attach, /_compact_chara_dasha/); + assert.doesNotMatch(attach, /defer_optional_external_evidence = False/); + + 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(workflow, /transits: context\.local_layers\.transits/); + assert.match(workflow, /must_use_layers/); + assert.match(instructions, /must_use_layers is the executed shortlist/); + assert.doesNotMatch(instructions, /AGENTS\.md/); +}); diff --git a/frontend/tests/consultation-voice-contract.test.ts b/frontend/tests/consultation-voice-contract.test.ts index 744ba92e..315d0d36 100644 --- a/frontend/tests/consultation-voice-contract.test.ts +++ b/frontend/tests/consultation-voice-contract.test.ts @@ -27,6 +27,8 @@ test("product voice keeps skill technique density without dumping JSON", () => { assert.match(mastra, /The bound skill method is this product's answering contract/); assert.match(mastra, /Do not use a restricted technique/); 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, /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 69be4419..2e485a81 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -613,6 +613,92 @@ def _consultation_layer_present(value) -> bool: return status not in {'blocked', 'unavailable', 'missing'} +def _consultation_audit_status(value) -> str: + if isinstance(value, dict): + raw = str(value.get('status') or '').lower() + if raw == 'not_applicable': + return 'not_applicable' + if raw in {'blocked', 'unavailable', 'missing'}: + return 'blocked' + if _consultation_layer_present(value): + return 'executed' + if isinstance(value, list) and value: + return 'executed' + return 'blocked' + + +_CHARA_DASHA_ROUTES = frozenset({'career', 'timing', 'annual', 'marriage'}) + + +def _consultation_declared_routes(body: dict) -> set[str]: + values: list[str] = [] + for key in ('strict_workflow_route', 'theme'): + raw = body.get(key) + if isinstance(raw, str) and raw.strip(): + values.append(raw.strip().lower()) + elif isinstance(raw, list): + values.extend(str(item).strip().lower() for item in raw if item) + themes = body.get('themes') + if isinstance(themes, list): + values.extend(str(item).strip().lower() for item in themes if item) + return {item for item in values if item} + + +def _compact_yoga_entry(value) -> dict | None: + if isinstance(value, str) and value.strip(): + return {'name': value.strip()[:80]} + if not isinstance(value, dict): + return None + name = str(value.get('name') or value.get('yoga') or '').strip() + if not name: + return None + compact: dict = {'name': name[:80]} + planets = value.get('planets') + if isinstance(planets, list): + names = [str(item) for item in planets[:6] if item] + if names: + compact['planets'] = names + elif isinstance(planets, str) and planets.strip(): + compact['planets'] = [planets.strip()] + category = value.get('category') or value.get('cat') + if category: + compact['category'] = str(category)[:40] + return compact + + +def _compact_chara_dasha(raw: dict, age_years: float) -> dict: + sequence = raw.get('dasha_sequence') if isinstance(raw.get('dasha_sequence'), list) else [] + cursor = 0.0 + current = None + compact_sequence = [] + for period in sequence: + if not isinstance(period, dict): + continue + try: + duration = float(period.get('duration_years') or 0) + except (TypeError, ValueError): + duration = 0.0 + start = cursor + end = cursor + duration + row = { + 'sign': period.get('sign'), + 'lord': period.get('lord'), + 'duration_years': duration, + } + if current is None and (start <= age_years < end or (age_years >= end and period is sequence[-1])): + current = {**row, 'from_age': round(start, 2), 'to_age': round(end, 2)} + if len(compact_sequence) < 6: + compact_sequence.append(row) + cursor = end + return { + 'status': 'executed', + 'method': 'kn_rao', + 'current': current, + 'sequence': compact_sequence, + 'boundary': 'commercial claims stay non-deterministic; not a majority-vote truth', + } + + def _planet_longitudes(planets: dict) -> dict[str, float]: longitudes = {} for name, data in planets.items(): @@ -855,12 +941,17 @@ def _consultation_technique_audit_table( for raw in quality_gate.get('technique_audit_table') or []: if not isinstance(raw, dict): continue - rows.append(_audit_row( + row = _audit_row( str(raw.get('technique') or raw.get('name') or 'Technique'), raw.get('status'), system=str(raw.get('system') or 'cross_system'), boundary=str(raw.get('effect_on_confidence') or raw.get('boundary') or ''), - )) + ) + for key in ('key_functional_benefics', 'key_functional_malefics'): + names = raw.get(key) + if isinstance(names, list) and names: + row[key] = [str(item) for item in names[:9] if item] + rows.append(row) modules = chart.get('modules') if isinstance(chart.get('modules'), dict) else {} spectrum = modules.get('varga_spectrum') if isinstance(modules.get('varga_spectrum'), dict) else {} counts = spectrum.get('counts') if isinstance(spectrum.get('counts'), dict) else {} @@ -885,12 +976,13 @@ def _consultation_technique_audit_table( ('narayana_dasha', 'Narayana Dasha', None), ('dasha_sub_periods', 'Vimshottari sub-periods', None), ('ashtakavarga', 'Ashtakavarga', 'component parity remains partial'), + ('yogas', 'Yogas', None), + ('chara_dasha', 'Chara Dasha', 'commercial claims stay non-deterministic; not a majority-vote truth'), + ('transits', 'Transits / Sade Sati', 'observation windows, not guaranteed events'), ('kp_cusps', 'KP cusps', 'KP remains non-deterministic in this product'), ('gulika', 'Gulika / Mandi', 'observation_only'), ): - value = modules.get(name) - status = 'executed' if _consultation_layer_present(value) else 'blocked' - rows.append(_audit_row(label, status, boundary=boundary)) + rows.append(_audit_row(label, _consultation_audit_status(modules.get(name)), boundary=boundary)) shadbala = modules.get('shadbala') or chart.get('shadbala') rows.append(_audit_row( 'Shadbala components', @@ -905,11 +997,17 @@ def _consultation_technique_audit_table( )) if not any(row.get('technique') == 'Functional Benefic/Malefic' for row in rows): functional = modules.get('functional_benefic_malefic') - rows.append(_audit_row( + functional_row = _audit_row( 'Functional Benefic/Malefic', 'executed' if _consultation_layer_present(functional) and str(functional.get('status') or '') != 'blocked' else 'blocked', boundary=str((functional or {}).get('effect_on_confidence') or '') if isinstance(functional, dict) else '', - )) + ) + if isinstance(functional, dict): + for key in ('functional_benefics', 'functional_malefics'): + names = functional.get(key) + if isinstance(names, list) and names: + functional_row[f'key_{key}'] = [str(item) for item in names[:9] if item] + rows.append(functional_row) western = _compact_western_spectrum(western_evidence_packet) rows.append(_audit_row( 'Western natal (tropical)', @@ -1163,6 +1261,122 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, except Exception as exc: diagnostics.append({'layer': 'kakshya', 'status': 'unavailable', 'reason': exc.__class__.__name__}) + for planet in planets.values(): + if isinstance(planet, dict) and not planet.get('sign') and planet.get('sign_idx') is not None: + try: + planet['sign'] = SIGNS[int(planet['sign_idx']) % 12] + except (TypeError, ValueError, IndexError): + pass + + if not _consultation_layer_present(modules.get('yogas')): + try: + raw_yogas = chart.get('yogas') if isinstance(chart.get('yogas'), list) else [] + compact = [entry for entry in (_compact_yoga_entry(item) for item in raw_yogas) if entry] + if not compact: + api = handler._compute_yogas_api({ + **birth_payload, + 'planets': planets, + 'ascendant': ascendant, + }) + result = api.get('result') if isinstance(api, dict) else {} + extended = result.get('extended_yogas') if isinstance(result, dict) else [] + engine = result.get('rule_engine_yogas') if isinstance(result, dict) else [] + compact = [ + entry for entry in ( + _compact_yoga_entry(item) + for item in (extended or []) + (engine or []) + ) + if entry + ][:12] + modules['yogas'] = { + 'status': 'executed' if compact else 'blocked', + 'count': len(compact), + 'yogas': compact[:12], + } + if compact and not isinstance(chart.get('yogas'), list): + chart['yogas'] = compact[:12] + except Exception as exc: + modules['yogas'] = {'status': 'blocked', 'reason': exc.__class__.__name__, 'yogas': []} + diagnostics.append({'layer': 'yogas', 'status': 'unavailable', 'reason': exc.__class__.__name__}) + + if not isinstance(modules.get('chara_dasha'), dict): + if _consultation_declared_routes(body) & _CHARA_DASHA_ROUTES: + try: + jaimini = _load_local_module('jaimini') + planet_lons = _planet_longitudes(planets) + asc_sign_idx = int(ascendant.get('sign_idx', int(float(ascendant.get('lon', 0))) // 30)) % 12 + raw = jaimini.calc_chara_dasha( + asc_sign_idx, + planet_lons, + int(birth_payload.get('year', 0) or 0), + int(birth_payload.get('month', 1) or 1), + int(birth_payload.get('day', 1) or 1), + ) + modules['chara_dasha'] = _compact_chara_dasha(raw, _consultation_current_age(birth_payload, body)) + except Exception as exc: + modules['chara_dasha'] = { + 'status': 'blocked', + 'reason': exc.__class__.__name__, + 'boundary': 'commercial claims stay non-deterministic; not a majority-vote truth', + } + diagnostics.append({'layer': 'chara_dasha', 'status': 'unavailable', 'reason': exc.__class__.__name__}) + else: + modules['chara_dasha'] = { + 'status': 'not_applicable', + 'boundary': 'Chara Dasha is attached for career, timing, annual, and marriage routes', + } + + if not isinstance(modules.get('transits'), dict): + try: + sade_sati = chart.get('sade_sati') if isinstance(chart.get('sade_sati'), dict) else None + if not sade_sati: + moon = planets.get('Moon') if isinstance(planets.get('Moon'), dict) else {} + calculation_service = _load_local_module('domain_calculation_service') + sade_sati = calculation_service.compute_sade_sati( + moon_degree=float(moon.get('lon') or 0), + asc_degree=float(ascendant.get('lon') or 0), + reference_date=_consultation_reference_date(body).strftime('%Y-%m-%d'), + tz=float(birth_payload.get('tz') or birth_payload.get('timezone') or 0), + ayanamsa=str(body.get('ayanamsa') or birth_payload.get('ayanamsa') or 'lahiri'), + ) + compact_sade = None + if isinstance(sade_sati, dict): + inner = sade_sati.get('sade_sati') if isinstance(sade_sati.get('sade_sati'), dict) else sade_sati + compact_sade = { + key: inner[key] + for key in ('active', 'phase', 'phase_name', 'moon_sign', 'saturn_sign', 'intensity') + if isinstance(inner, dict) and key in inner + } + for key in ('moon_sign', 'saturn_sign'): + if key in sade_sati and key not in compact_sade: + compact_sade[key] = sade_sati[key] + 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 + }) + modules['transits'] = { + 'status': 'executed' if compact_sade or triggers else 'blocked', + 'sade_sati': compact_sade, + 'triggers': triggers, + 'boundary': 'observation windows, not guaranteed events', + } + except Exception as exc: + modules['transits'] = { + 'status': 'blocked', + 'reason': exc.__class__.__name__, + 'boundary': 'observation windows, not guaranteed events', + } + diagnostics.append({'layer': 'transits', 'status': 'unavailable', 'reason': exc.__class__.__name__}) + chart['local_consultation_layers'] = { 'status': 'ready' if not diagnostics else 'partial', 'source': 'repository_local_engines', @@ -1172,8 +1386,9 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, 'varga_full', 'varga_spectrum', 'arudha_padas', 'narayana_dasha', 'dasha_sub_periods', 'ashtakavarga', 'kp_cusps', 'gulika', 'functional_benefic_malefic', 'shadbala', 'kakshya', + 'yogas', 'chara_dasha', 'transits', ) - if _consultation_layer_present(modules.get(name)) + if _consultation_audit_status(modules.get(name)) == 'executed' ], 'diagnostics': diagnostics, } diff --git a/tests/test_consultation_consumer_context.py b/tests/test_consultation_consumer_context.py index 6938bb6f..b5394036 100644 --- a/tests/test_consultation_consumer_context.py +++ b/tests/test_consultation_consumer_context.py @@ -122,6 +122,39 @@ def test_local_consultation_layers_supply_d10_a10_and_narayana_without_vedastro( assert 'kakshya' in chart['local_consultation_layers']['available'] +def test_local_consultation_layers_include_yogas_transits_and_route_chara() -> None: + career = _attach_local_consultation_layers( + _handler(), + _base_chart(), + dict(_BIRTH), + {'current_date': _REFERENCE_DATE, 'strict_workflow_route': 'career'}, + ) + yogas = career['modules']['yogas'] + assert yogas['status'] == 'executed' + assert yogas['yogas'][0]['name'] + chara = career['modules']['chara_dasha'] + assert chara['status'] == 'executed' + assert chara['current']['sign'] + assert chara['sequence'][0]['lord'] + transits = career['modules']['transits'] + assert transits['status'] == 'executed' + assert transits['sade_sati']['moon_sign'] + assert transits['sade_sati']['saturn_sign'] + assert transits['sade_sati']['phase'] in {'none', 'rising', 'peak', 'setting'} + 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'] + + wealth = _attach_local_consultation_layers( + _handler(), + _base_chart(), + dict(_BIRTH), + {'current_date': _REFERENCE_DATE, 'strict_workflow_route': 'wealth'}, + ) + assert wealth['modules']['chara_dasha']['status'] == 'not_applicable' + assert 'chara_dasha' not in wealth['local_consultation_layers']['available'] + + def test_consultation_computes_formal_and_research_varga_spectrum() -> None: chart = _attach_local_consultation_layers( _handler(),