diff --git a/CHANGELOG.md b/CHANGELOG.md index 403867c7..11d7043b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-03 — 聊天审计与个人报告接上行星友敌、应期种子和 PD 边界 + +咨询响应新增行星友敌表、应期叙事种子、模块执行附录与当前/下一段 Pratyantar 日期。折叠技法审计会出现这几行;Ashtottari 只标「参数敏感、未验证」,不当成已核验结论。个人报告可引用友敌等级表,timing 章节可写 PD 候选窗口。财运方法学改走 `finance-timing-strict`(`wealth-timing-strict` 仍是旧别名)。Skill 版本未变。 + ## 2026-09-03 — Skill 6.9.15:上游注册表 116 项与 REST 对标说明 接收上游 `a6f47abd` 的 25 项注册表内容(western / classical / ziwei)。网页路径未执行的新项在商业覆盖层为 `research_only_blocked`。Ashtottari 覆盖层为 `partial`(规则族限制,unverified,不得进确定性结论)。根 `SKILL.md` 补 Ashtottari / `professional_report_reference` / VedAstro REST 三段。徽章 91→116。 diff --git a/docs/tasks/PROGRESS-upstream-capabilities-20260903.md b/docs/tasks/PROGRESS-upstream-capabilities-20260903.md index 38367d60..ca3e9727 100644 --- a/docs/tasks/PROGRESS-upstream-capabilities-20260903.md +++ b/docs/tasks/PROGRESS-upstream-capabilities-20260903.md @@ -1,14 +1,79 @@ # PROGRESS · 上游能力接入(任务 3–7,2026-09-03) -工作树 / 分支同 `PROGRESS-upstream-sync-20260903.md`。任务 4 不依赖任务 2,按并行规则与任务 0 一起做。任务 3 / 5 / 6 / 7 **未开**(等任务 1–2,且 7b 在整备之后;整备 0–3 已在 staging)。 +工作树:`/Users/jesse/Downloads/Copse/astrology/.worktrees/upstream-capabilities-20260903` +分支:`codex/upstream-capabilities-20260903`(跟踪 `origin/staging` @ `f2241463`) +任务书:`docs/tasks/TASK-upstream-sync-20260903.md` + +未碰 `main`。未改 `.gitea/workflows/**`。 | 任务 | 状态 | 说明 | | --- | --- | --- | -| 3 health 三窗 + 个人报告 | 未开 | 会改 `personal-report-generation.ts` 与编排器,须在任务 2 之后 | -| 4 VedAstro REST 桥 | 完成(未提交) | 文件集与整备无交集 | +| 3 health 三窗 + 个人报告 | 未开 | 任务 6 之后 | +| 4 VedAstro REST 桥 | 完成(已在 `45d13258`) | 进度里旧「未提交」已过时 | | 5 专业报告导出 | 未开 | 依赖任务 2 的 `pl9-export` | -| 6 新字段透传 | 未开 | 任务 6 先于任务 3 与 7c | -| 7 校正三项 | 未开 | 7b 岁差/交点按请求:整备已交付 `resolveAyanamsa`,仍等任务 2 后做 Python + 前端接线 | +| 6 新字段透传 | 完成 | 聊天 + 报告 allowlist;golden 超集 | +| 7 校正三项 | 未开 | 7c 依赖 2+6 | + +## 任务 6 + +### 做了什么 + +1. 新模块 `scripts/consultation_engine_field_bridge.py`:把引擎的行星友敌、应期种子、模块执行附录、Pratyantar 当前/下一段挂到咨询盘上。`jyotish_api_server.py` 只加薄钩子(attach、重建 prompt pack、审计三行、响应顶层新键)。`page.tsx` 未动(2041 行)。API 文件 11120 → **11141**(上限 11363)。 +2. 聊天:折叠审计新增 Planetary Friendship / Ashtottari Dasha / Module Execution Audit。Ashtottari 三态仍是 `executed`,说明为 **参数敏感、未验证**。财富方法学主名改为 `finance-timing-strict`。 +3. 报告:`interpretiveFacts.planetaryFriendship`(等级表,无自由文本)与 `pratyantarTimeline`(当前+下一段)。timing 种子读 `timing_narrative`。timing claim card 的 conclusion / timingBoundary 可引用 PD 日期。友谊表不进必选技法组,收据为 `partial`。 +4. golden:在旧夹具上合并稳定新键(`timing_narrative` / `module_execution_audit` / `pratyantar_dasha_timeline`)。未把偶发的 `vedastro_gateway.official_raw_response` 写进夹具。 + +### 测试 + +| 项 | 结果 | +| --- | --- | +| `tests/test_consultation_engine_field_bridge.py` | 5 passed | +| `tests/test_api_server_growth_contract.py` | 3 passed | +| `tests/test_consultation_contract_golden.py`(非 live HTTP) | 2 passed | +| `test_consultation_foreground_times_out_vedastro_without_failing_local` | 绿。新增引擎预热 import,**不断言改值**。原值仍是 `official_closure_reason=foreground_optional_evidence_timeout`。原因:attach 首次 import `jyotish_engine` 会超过测试里 0.4s 假延迟,网关会先完成。 | +| `tsc --noEmit` | 0 错 | +| 相关前端测试(methodology / audit / interpretive facts / report v2 / plan / telemetry) | **60 pass / 0 fail** | +| 改动文件 eslint | 0 error | + +断言改动:无(超时测试只加预热 import)。 + +### 虚构 smoke + +`1990-01-01 12:00 Beijing` timing 题走 `_compute_consultation_workflow`: + +| 行 | 状态 | 说明 | +| --- | --- | --- | +| Planetary Friendship | executed | 友敌等级表;不得当作确定性结论;9 行 | +| Ashtottari Dasha | executed | 参数敏感、未验证 | +| Module Execution Audit | executed | 附录可见;不得升格为确定性结论 | +| `pratyantar_dasha_timeline` | ready | 当前 PD Rahu `2026-03-26`–`2026-09-07` | +| `timing_narrative.status` | parameter_sensitive | 有 headline | + +同输入两次 bundle hash 一致(前端测试)。未跑浏览器聊天,也没有 writer 的真实 `inputTokens`。 + +### 每章体量(过滤后 bundle JSON 字节,代理 inputTokens) + +无模型凭据,无法打真实 writer。代理:同主题过滤后的 bundle `JSON.stringify` 字节。before = 去掉友敌表、PD、timing 种子。 + +| 主题 | before | after | 比值 | +| --- | --- | --- | --- | +| general | 7384 | 7511 | 1.017 | +| wealth | 9912 | 10039 | 1.013 | +| education | 8640 | 8767 | 1.015 | +| timing | 7489 | 8816 | 1.177 | + +p50 ≈ **1.016**,最大 1.177,低于 1.3 停工线。 + +### 环境缺口 + +- 无登录态 / Chrome:网页聊天折叠表走查留给 `docs/testing/`。 +- 无模型凭据:personal_full writer 的真实 `inputTokens` 未测。 +- 合入前 staging health 可能仍停在任务 2 的 `f2241463` 或更早 SHA;部署后应对齐本提交。 + +### 未做 + +任务 3 / 5 / 7。未改 `route_aliases.py`。未把 nadi/classical 接到网页路径。 + ## 任务 4 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index ae9cec5d..1008ef18 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -98,7 +98,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-upstream-sync-20260903.md` | `PROGRESS-upstream-sync-20260903.md`、`PROGRESS-upstream-capabilities-20260903.md` | 上游快照推进到 `a6f47abd`、引擎三方合并、health 三窗 / VedAstro REST 桥 / 专业报告导出 / 新字段接聊天与报告 / 校正解冻三项(自适应选题、岁差按请求、报告敏感度节) | 执行中 | 分支 `codex/upstream-sync-20260903` @ `01a45363`;任务 0 与 4 已回报,任务 1–3 / 5–7 未开 | +| `TASK-upstream-sync-20260903.md` | `PROGRESS-upstream-sync-20260903.md`、`PROGRESS-upstream-capabilities-20260903.md` | 上游快照推进到 `a6f47abd`、引擎三方合并、health 三窗 / VedAstro REST 桥 / 专业报告导出 / 新字段接聊天与报告 / 校正解冻三项(自适应选题、岁差按请求、报告敏感度节) | 执行中 | `origin/staging` 任务 0–2、4 已入;任务 6 在 `codex/upstream-capabilities-20260903`;任务 3 / 5 / 7 未开 | ## 命名与归档 diff --git a/frontend/src/lib/consultation-methodology.ts b/frontend/src/lib/consultation-methodology.ts index a4a67e53..e21613b0 100644 --- a/frontend/src/lib/consultation-methodology.ts +++ b/frontend/src/lib/consultation-methodology.ts @@ -27,7 +27,7 @@ import { resolveLiveJyotishSkill } from "./skill-package-registry.ts"; const domainMethodology: Readonly> = { career: { strictRoute: "career-timing-strict", eventJudgment: "event_judgment_career.md" }, marriage: { strictRoute: "relationship-timing-strict", eventJudgment: "event_judgment_marriage.md" }, - wealth: { strictRoute: "wealth-timing-strict", eventJudgment: "event_judgment_wealth.md" }, + wealth: { strictRoute: "finance-timing-strict", eventJudgment: "event_judgment_wealth.md" }, timing: { strictRoute: "event-timing-strict", eventJudgment: null }, health: { strictRoute: "health-timing-strict", eventJudgment: null }, annual: { strictRoute: null, eventJudgment: null }, diff --git a/frontend/src/lib/consultation-technique-audit.ts b/frontend/src/lib/consultation-technique-audit.ts index 77161079..43d1bf0e 100644 --- a/frontend/src/lib/consultation-technique-audit.ts +++ b/frontend/src/lib/consultation-technique-audit.ts @@ -47,6 +47,9 @@ const TECHNIQUE_LABELS: Readonly> = { "Ashtakoota matching": "合盘 Ashtakoota", "Adhana / Niseka": "Adhana / Niseka", "Muhurta election": "择时 Muhurta", + "Planetary Friendship": "行星友敌", + "Ashtottari Dasha": "Ashtottari Dasha", + "Module Execution Audit": "模块执行审计", }; const WESTERN_LAYER_LABELS: Readonly> = { @@ -93,6 +96,9 @@ const NOTE_LABELS: Readonly> = { "requires a partner chart": "需要伴侣盘", "research_only; not a natal consultation layer": "仅研究用途", "not an electional request": "非择时请求", + "参数敏感、未验证": "参数敏感、未验证", + "友敌等级表;不得当作确定性结论": "友敌等级表;不得当作确定性结论", + "附录可见;不得升格为确定性结论": "附录可见;不得升格为确定性结论", layer_not_materialized: "本层未物化", }; diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts index f29176f6..2eda18db 100644 --- a/frontend/src/lib/personal-report-generation.ts +++ b/frontend/src/lib/personal-report-generation.ts @@ -25,9 +25,11 @@ import type { ReportChartFact, ReportClaimCard, ReportCurrentDashaFact, + ReportDashaPeriod, ReportFunctionalRole, ReportFunctionalRoleFact, ReportInterpretiveFacts, + ReportPlanetaryFriendshipFact, ReportSavScoreFact, ReportShadbalaRankFact, ReportThemeNarrativeSeed, @@ -1386,6 +1388,71 @@ function readCurrentDasha(workflow: JsonRecord): ReportCurrentDashaFact | null { return null; } +function celestialNameList(value: unknown): string[] { + return stringArray(value) + .flatMap((name) => { + const safe = safeCelestialName(name); + return safe ? [safe] : []; + }) + .slice(0, 8); +} + +function readPlanetaryFriendship(workflow: JsonRecord): ReportPlanetaryFriendshipFact[] { + const modules = readChartModules(workflow); + const snapshot = record(modules.planetary_friendship); + const rows = Array.isArray(snapshot?.rows) ? snapshot.rows : []; + const out: ReportPlanetaryFriendshipFact[] = []; + const seen = new Set(); + for (const item of rows) { + const row = record(item); + if (!row) continue; + const planetName = text(row.planet); + const planet = planetName ? safeCelestialName(planetName) : null; + if (!planet || seen.has(planet)) continue; + seen.add(planet); + out.push({ + planet, + greatFriends: celestialNameList(row.great_friends ?? row.greatFriends), + friends: celestialNameList(row.friends), + neutral: celestialNameList(row.neutral), + enemies: celestialNameList(row.enemies), + greatEnemies: celestialNameList(row.great_enemies ?? row.greatEnemies), + }); + if (out.length >= 9) break; + } + return out; +} + +function readPratyantarBoundary(value: unknown): ReportDashaPeriod | null { + const row = record(value); + if (!row) return null; + const lordName = text(row.lord); + const lord = lordName ? safeCelestialName(lordName) : null; + const startText = text(row.start); + const endText = text(row.end); + const start = startText ? safeDashaDate(startText) : null; + const end = endText ? safeDashaDate(endText) : null; + if (!lord || !start || !end || Date.parse(start) >= Date.parse(end)) return null; + return { lord, start, end }; +} + +function readPratyantarTimeline(workflow: JsonRecord): ReportInterpretiveFacts["pratyantarTimeline"] { + const modules = readChartModules(workflow); + const sources = [ + record(workflow.pratyantar_dasha_timeline), + record(modules.pratyantar_dasha_timeline), + record(record(modules.dasha_sub_periods)?.pratyantar_dasha_timeline), + ]; + for (const source of sources) { + if (!source) continue; + if (text(source.status)?.toLowerCase() === "blocked") continue; + const current = readPratyantarBoundary(source.current); + const next = readPratyantarBoundary(source.next); + if (current || next) return { current, next }; + } + return null; +} + function readConvergenceDomains(workflow: JsonRecord): string[] { const snapshot = readEvidenceSnapshot(workflow); const modules = readChartModules(workflow); @@ -1404,14 +1471,15 @@ function readConvergenceDomains(workflow: JsonRecord): string[] { } /** - * Server-side thematic narrative payloads. Present in the engine's own - * `_build_ai_prompt_pack`; the consultation API's chart prompt pack currently - * omits them, so this returns an empty map there instead of inventing text. + * Server-side thematic narrative payloads. The consultation API prompt pack + * now carries `timing_narrative`; career / relationship / finance seeds still + * come from the engine's own pack or from strict-module user_narrative. */ const NARRATIVE_SNAPSHOT_THEMES: Readonly> = { career_narrative: "career", relationship_narrative: "marriage", finance_narrative: "wealth", + timing_narrative: "timing", }; const STRICT_MODULE_THEMES: Readonly> = { @@ -1452,6 +1520,10 @@ function readEngineNarratives(workflow: JsonRecord): Map const draft = readNarrativePayload(record(modules[key])?.user_narrative); if (draft) drafts.set(theme, draft); } + if (!drafts.has("timing")) { + const topTiming = readNarrativePayload(workflow.timing_narrative); + if (topTiming) drafts.set("timing", topTiming); + } return drafts; } @@ -1574,6 +1646,14 @@ function buildInterpretiveThemeLines( if (facts.convergenceDomains.length > 0) { lines.push(`多套时间系统同时指向的领域:${joinNames(facts.convergenceDomains, 4)}。`); } + if (theme === "timing" && facts.pratyantarTimeline?.current) { + const current = facts.pratyantarTimeline.current; + const next = facts.pratyantarTimeline.next; + const nextClause = next ? `,下一段 ${next.lord} ${next.start} 至 ${next.end}` : ""; + lines.push( + `当前 Pratyantar 为 ${current.lord},${current.start} 至 ${current.end}${nextClause},只作候选窗口。`, + ); + } return lines.flatMap((line) => { const safe = safeSeedText(line, 400); return safe ? [safe] : []; @@ -1721,6 +1801,15 @@ export function buildReportEvidenceBundleV2( } return []; }; + const planetaryFriendship = collectFirst(readPlanetaryFriendship); + if (planetaryFriendship.length > 0) upsertReceipt("Planetary Friendship", "partial", true); + const pratyantarTimeline = (() => { + for (const { workflow } of packets) { + const timeline = readPratyantarTimeline(workflow); + if (timeline) return timeline; + } + return null; + })(); const savFacts = (() => { for (const { workflow } of packets) { const result = readSavFacts(workflow, baseAscendantSignIndex); @@ -1745,6 +1834,8 @@ export function buildReportEvidenceBundleV2( savTotal: ashtakavargaRef ? savFacts.total : null, currentDasha: vimshottariRef ? currentDasha : null, convergenceDomains: collectFirst(readConvergenceDomains), + planetaryFriendship, + pratyantarTimeline, }; // --- theme narrative seeds ---------------------------------------------- @@ -1921,18 +2012,38 @@ export function buildReportEvidenceBundleV2( status: counterFactRef.status, })) : []; + const birthTimeBoundary = input.subject.birthTimeStatus === "confirmed" + ? null + : "出生时间未达到 confirmed;时间结论仅允许方向性表达"; + const pratyantarBoundary = plan.theme === "timing" && interpretiveFacts.pratyantarTimeline?.current + ? [ + `当前 PD ${interpretiveFacts.pratyantarTimeline.current.lord} ${interpretiveFacts.pratyantarTimeline.current.start}–${interpretiveFacts.pratyantarTimeline.current.end}`, + interpretiveFacts.pratyantarTimeline.next + ? `下一段 ${interpretiveFacts.pratyantarTimeline.next.lord} ${interpretiveFacts.pratyantarTimeline.next.start}–${interpretiveFacts.pratyantarTimeline.next.end}` + : null, + "候选窗口", + ].filter(Boolean).join(";") + : null; + const timingBoundary = [birthTimeBoundary, pratyantarBoundary] + .filter((part): part is string => Boolean(part)) + .join("。") + .slice(0, 200) || null; + const conclusionWithPratyantar = plan.theme === "timing" && interpretiveFacts.pratyantarTimeline?.current + ? joinSeedSentences([ + conclusion, + `当前 Pratyantar ${interpretiveFacts.pratyantarTimeline.current.lord} ${interpretiveFacts.pratyantarTimeline.current.start} 至 ${interpretiveFacts.pratyantarTimeline.current.end},只作候选窗口。`, + ]).slice(0, 1200) + : conclusion; claimCards.push({ id: `ev-claim-${themeSlug}`, theme: plan.theme, section: plan.section, - conclusion, + conclusion: conclusionWithPratyantar, supportingFacts: supportingFacts.slice(0, 40), counterFacts, executedTechniqueRefs: uniqueMatched.map((receipt) => receipt.id), assertionLevel, - timingBoundary: input.subject.birthTimeStatus === "confirmed" - ? null - : "出生时间未达到 confirmed;时间结论仅允许方向性表达", + timingBoundary, verificationQuestions: assertionLevel === "parameter_sensitive" ? ["建议结合真实经历核验本主题的部分证据结论"] : [], @@ -3096,6 +3207,8 @@ export function filterReportEvidenceBundleForSection( savTotal: source.interpretiveFacts.savTotal, currentDasha: source.interpretiveFacts.currentDasha, convergenceDomains: source.interpretiveFacts.convergenceDomains, + planetaryFriendship: source.interpretiveFacts.planetaryFriendship, + pratyantarTimeline: section.theme === "timing" ? source.interpretiveFacts.pratyantarTimeline : null, }; const charts = source.charts.filter((chart) => (chart.id === "D1" || ledger.some((receipt) => receipt.technique.toUpperCase() === chart.id))); diff --git a/frontend/src/lib/report-evidence-bundle-v2.ts b/frontend/src/lib/report-evidence-bundle-v2.ts index b5940432..8b61a15e 100644 --- a/frontend/src/lib/report-evidence-bundle-v2.ts +++ b/frontend/src/lib/report-evidence-bundle-v2.ts @@ -160,6 +160,18 @@ export type ReportCurrentDashaFact = Readonly<{ start: string; end: string; }>; +export type ReportPlanetaryFriendshipFact = Readonly<{ + planet: string; + greatFriends: readonly string[]; + friends: readonly string[]; + neutral: readonly string[]; + enemies: readonly string[]; + greatEnemies: readonly string[]; +}>; +export type ReportPratyantarTimeline = Readonly<{ + current: ReportDashaPeriod | null; + next: ReportDashaPeriod | null; +}>; /** * Server-computed interpretive facts. Every entry is a closed vocabulary value @@ -174,6 +186,8 @@ export type ReportInterpretiveFacts = Readonly<{ savTotal: number | null; currentDasha: ReportCurrentDashaFact | null; convergenceDomains: readonly string[]; + planetaryFriendship: readonly ReportPlanetaryFriendshipFact[]; + pratyantarTimeline: ReportPratyantarTimeline | null; }>; /** @@ -256,6 +270,7 @@ const allowedTechniqueNames = new Set([ "A7", "A10", "UL", "DK", "AmK", "Vimshottari", "Narayana", "Transit", "Yoga", "Ashtakavarga", "Functional Benefic/Malefic", "Planet Degrees", "House Degrees", + "Planetary Friendship", ]); const missingTechniquePattern = /^(?:career|marriage|wealth|education|migration_home|family|health_pressure|timing|general):(?:D1|D2|D4|D6\/D30|D7\/D12|D9|D10|D11|D12|D24|A7|A10|UL|DK|AmK|Vimshottari|Narayana|Transit|Yoga|Ashtakavarga)$/; const techniqueSchema = z.string().max(80).refine( @@ -349,6 +364,18 @@ const interpretiveFactsSchema = z.object({ end: dashaDateSchema, }).strict().nullable(), convergenceDomains: z.array(seedTextSchema(80)).max(6), + planetaryFriendship: z.array(z.object({ + planet: celestialSchema, + greatFriends: z.array(celestialSchema).max(8), + friends: z.array(celestialSchema).max(8), + neutral: z.array(celestialSchema).max(8), + enemies: z.array(celestialSchema).max(8), + greatEnemies: z.array(celestialSchema).max(8), + }).strict()).max(9), + pratyantarTimeline: z.object({ + current: dashaPeriodSchema.nullable(), + next: dashaPeriodSchema.nullable(), + }).strict().nullable(), }).strict(); const themeNarrativeSeedSchema = z.object({ @@ -465,6 +492,8 @@ export function emptyReportInterpretiveFacts(): ReportInterpretiveFacts { savTotal: null, currentDasha: null, convergenceDomains: [], + planetaryFriendship: [], + pratyantarTimeline: null, }; } @@ -527,6 +556,17 @@ function sortedBundleContent(bundle: Omit) )), savScores: [...bundle.interpretiveFacts.savScores].sort((a, b) => a.house - b.house), convergenceDomains: sortedUnique(bundle.interpretiveFacts.convergenceDomains), + planetaryFriendship: [...bundle.interpretiveFacts.planetaryFriendship] + .map((row) => ({ + ...row, + greatFriends: sortedUnique(row.greatFriends), + friends: sortedUnique(row.friends), + neutral: sortedUnique(row.neutral), + enemies: sortedUnique(row.enemies), + greatEnemies: sortedUnique(row.greatEnemies), + })) + .sort((a, b) => a.planet.localeCompare(b.planet)), + pratyantarTimeline: bundle.interpretiveFacts.pratyantarTimeline, }, themeNarrativeSeeds: [...bundle.themeNarrativeSeeds] .map((seed) => ({ ...seed, evidenceRefs: sortedUnique(seed.evidenceRefs) })) @@ -640,6 +680,20 @@ export function validateReportEvidenceBundleV2(bundle: ReportEvidenceBundleV2): if (facts.currentDasha && Date.parse(facts.currentDasha.start) >= Date.parse(facts.currentDasha.end)) { throw new Error("report_bundle_current_dasha_invalid"); } + const seenFriendshipPlanets = new Set(); + for (const row of facts.planetaryFriendship) { + if (seenFriendshipPlanets.has(row.planet)) { + throw new Error(`report_bundle_duplicate_planetary_friendship:${row.planet}`); + } + seenFriendshipPlanets.add(row.planet); + } + if (facts.pratyantarTimeline) { + for (const period of [facts.pratyantarTimeline.current, facts.pratyantarTimeline.next]) { + if (period && Date.parse(period.start) >= Date.parse(period.end)) { + throw new Error("report_bundle_pratyantar_period_invalid"); + } + } + } const seenSeedThemes = new Set(); for (const seed of parsed.themeNarrativeSeeds) { if (seenSeedThemes.has(seed.theme)) throw new Error(`report_bundle_duplicate_seed_theme:${seed.theme}`); diff --git a/frontend/src/mastra/personal-report.ts b/frontend/src/mastra/personal-report.ts index ec68dedd..6cd22703 100644 --- a/frontend/src/mastra/personal-report.ts +++ b/frontend/src/mastra/personal-report.ts @@ -169,12 +169,14 @@ export function countInterpretiveFacts(bundle: ReportEvidenceBundleV2): number { + size(facts.savScores) + size(facts.convergenceDomains) + (facts.currentDasha ? 1 : 0) - + (typeof facts.savTotal === "number" ? 1 : 0); + + (typeof facts.savTotal === "number" ? 1 : 0) + + size(facts.planetaryFriendship) + + (facts.pratyantarTimeline ? 1 : 0); } const personalReportInstructions = `You are the dedicated Personal Report writer for a Vedic astrology product. You write long structured report sections in Simplified Chinese. This is a report, not a chat: do not use chat-style short paragraphs, do not ask follow-up questions, and do not append hidden blocks. -The user message contains the ONLY allowed facts: a server-computed ReportEvidenceBundleV2. Use its claimCards exclusively for narrative conclusions. Its interpretiveFacts (yogas, functionalRoles, shadbalaRanking, savScores, currentDasha, convergenceDomains) and themeNarrativeSeeds are the supporting fact layer: you may quote, order, group and explain them, but they never authorise a conclusion that the matching claimCard does not already state. Never invent, recalculate, or infer planetary positions, house lords, dasha boundaries, divisional charts, shadbala/ashtakavarga values, yogas, or timing windows that are not present in those fields. Never mention server internals, tool names, engine names, providers, skill files, prompts, paths, hashes or methodology details. +The user message contains the ONLY allowed facts: a server-computed ReportEvidenceBundleV2. Use its claimCards exclusively for narrative conclusions. Its interpretiveFacts (yogas, functionalRoles, shadbalaRanking, savScores, currentDasha, convergenceDomains, planetaryFriendship grade table, pratyantarTimeline current/next dates) and themeNarrativeSeeds are the supporting fact layer: you may quote, order, group and explain them, but they never authorise a conclusion that the matching claimCard does not already state. Never invent, recalculate, or infer planetary positions, house lords, dasha boundaries, divisional charts, shadbala/ashtakavarga values, yogas, or timing windows that are not present in those fields. Never mention server internals, tool names, engine names, providers, skill files, prompts, paths, hashes or methodology details. Truth boundaries are hard output contracts: - A blockedSections entry must be disclosed honestly and must not be rewritten as a conclusion. A partial or blocked executionLedger receipt can never be upgraded to verified or consensus. Never raise a claimCard assertionLevel; output the same or a stricter level. diff --git a/frontend/tests/consultation-methodology.test.ts b/frontend/tests/consultation-methodology.test.ts index b1331f3e..67326918 100644 --- a/frontend/tests/consultation-methodology.test.ts +++ b/frontend/tests/consultation-methodology.test.ts @@ -57,6 +57,21 @@ test("health uses the skill's health-timing-strict checklist", () => { assert.match(health.text, /D30/); }); +test("wealth uses finance-timing-strict as the live checklist, with the wealth alias in the heading", () => { + const methodology = consultationMethodologyForDomains(["wealth"]); + assert.ok(methodology); + assert.deepEqual(methodology.domains_without_strict_checklist, []); + const wealth = methodology.sections.find((section) => section.title.includes("finance-timing-strict")); + assert.ok(wealth, "wealth must deliver finance-timing-strict, not the retired primary name"); + assert.match(wealth.text, /wealth-timing-strict/); + assert.ok( + !methodology.sections.some((section) => ( + section.title.includes("wealth-timing-strict") && !section.title.includes("finance-timing-strict") + )), + "the old wealth route name is an alias, not a second checklist", + ); +}); + test("a multi-domain plan carries every executed route's checklist once", () => { const methodology = consultationMethodologyForDomains(["career", "timing", "career"]); assert.ok(methodology); diff --git a/frontend/tests/consultation-technique-audit.test.ts b/frontend/tests/consultation-technique-audit.test.ts index fbc7b104..0786d2e5 100644 --- a/frontend/tests/consultation-technique-audit.test.ts +++ b/frontend/tests/consultation-technique-audit.test.ts @@ -7,6 +7,7 @@ import { displayTechniqueAuditLabel, displayTechniqueAuditNote, normalizeTechniqueAuditRows, + parseTechniqueAuditStatus, resolveTechniqueAuditRows, splitSpokenAnswerAndTechniqueAudit, techniqueAuditStatusLabel, @@ -75,6 +76,10 @@ test("delivered receipt rows fill the folded table when the spoken answer has no assert.equal(displayTechniqueAuditNote("layer_not_materialized"), "本层未物化"); assert.equal(displayTechniqueAuditNote("unmapped_machine_key"), ""); assert.equal(techniqueAuditStatusLabel("blocked"), "阻塞"); + assert.equal(displayTechniqueAuditLabel("Planetary Friendship"), "行星友敌"); + assert.equal(displayTechniqueAuditLabel("Module Execution Audit"), "模块执行审计"); + assert.equal(displayTechniqueAuditNote("参数敏感、未验证"), "参数敏感、未验证"); + assert.equal(parseTechniqueAuditStatus("partial"), "executed"); const split = splitSpokenAnswerAndTechniqueAudit(spoken); assert.deepEqual(resolveTechniqueAuditRows(split, delivered), delivered); diff --git a/frontend/tests/report-interpretive-facts.test.ts b/frontend/tests/report-interpretive-facts.test.ts index c9031be8..8bc210cc 100644 --- a/frontend/tests/report-interpretive-facts.test.ts +++ b/frontend/tests/report-interpretive-facts.test.ts @@ -30,7 +30,8 @@ import { // chart.modules.guided_topics present // evidence_snapshot.timing.vimshottari / .convergence_top_domains ABSENT // evidence_snapshot.strength.sav_scores ABSENT -// evidence_snapshot.{career,relationship,finance}_narrative ABSENT +// evidence_snapshot.timing_narrative present after task 6 +// evidence_snapshot.{career,relationship,finance}_narrative ABSENT on the consultation API pack // The narrative payloads and the sign-keyed SAV fallback are still covered here // because the engine's own `_build_ai_prompt_pack` emits them on other routes; // the extraction layer must read either shape without inventing data. @@ -121,6 +122,7 @@ type FixtureOptions = Readonly<{ financeNarrative?: boolean; pollutedNames?: boolean; structuredVargas?: boolean; + timingReady?: boolean; }>; function workflowFixture(options: FixtureOptions = {}): JsonRecord { @@ -182,7 +184,36 @@ function workflowFixture(options: FixtureOptions = {}): JsonRecord { mahadasha: { lord: "Rahu", start: "2011-11-11", end: "2029-11-11" }, antardasha: { lord: "Sun", start: "2026-05-30", end: "2027-04-24" }, }, + pratyantar_dasha_timeline: { + status: "ready", + current: { lord: "Sun", start: "2026-05-30", end: "2026-07-12" }, + next: { lord: "Moon", start: "2026-07-12", end: "2026-08-20" }, + }, }; + modules.pratyantar_dasha_timeline = { + status: "ready", + current: { lord: "Sun", start: "2026-05-30", end: "2026-07-12" }, + next: { lord: "Moon", start: "2026-07-12", end: "2026-08-20" }, + }; + modules.planetary_friendship = { + status: "parameter_sensitive", + rows: [ + { + planet: "Sun", + great_friends: ["Moon"], + friends: ["Mars"], + neutral: ["Mercury"], + enemies: ["Venus"], + great_enemies: ["Saturn"], + }, + ], + }; + if (options.timingReady) { + modules.narayana_dasha = { + periods: [{ lord: "Aries", start: "2026-01-01", end: "2027-01-01" }], + }; + modules.transits = { status: "executed", boundary: "observation windows" }; + } modules.guided_topics = [ { id: "wealth_risk", @@ -237,6 +268,12 @@ function workflowFixture(options: FixtureOptions = {}): JsonRecord { }, } : {}), + timing_narrative: { + headline: "应期主干已接入大运与第二时轴,日级窗口仍是候选。", + strengths: ["当前大运为 Rahu,应期主干围绕这条 dasha。"], + risks: ["外部日级窗口未闭环。"], + boundaries: ["候选窗口,不是已核验事件日。"], + }, }, }; } @@ -554,6 +591,10 @@ test("section filtering keeps the universal interpretive layer and trims the res [1, 10], ); assert.deepEqual(generalBundle.themeNarrativeSeeds.map((seed) => seed.theme), ["general"]); + assert.equal(wealthBundle.interpretiveFacts.planetaryFriendship.length, 1); + assert.equal(generalBundle.interpretiveFacts.planetaryFriendship.length, 1); + assert.equal(wealthBundle.interpretiveFacts.pratyantarTimeline, null); + assert.equal(generalBundle.interpretiveFacts.pratyantarTimeline, null); }); test("an empty interpretive block still finalizes and hashes", () => { @@ -566,5 +607,50 @@ test("an empty interpretive block still finalizes and hashes", () => { themeNarrativeSeeds: [], }); assert.equal(stripped.interpretiveFacts.savTotal, null); + assert.deepEqual(stripped.interpretiveFacts.planetaryFriendship, []); + assert.equal(stripped.interpretiveFacts.pratyantarTimeline, null); assert.notEqual(stripped.bundleHash, bundle.bundleHash, "the hash covers the new fields"); }); + +test("planetary friendship is a grade table and pratyantar stays on the timing chapter", () => { + const bundle = buildBundle(); + assert.deepEqual(bundle.interpretiveFacts.planetaryFriendship, [{ + planet: "Sun", + greatFriends: ["Moon"], + friends: ["Mars"], + neutral: ["Mercury"], + enemies: ["Venus"], + greatEnemies: ["Saturn"], + }]); + assert.equal(bundle.interpretiveFacts.pratyantarTimeline?.current?.lord, "Sun"); + assert.equal(bundle.interpretiveFacts.pratyantarTimeline?.next?.lord, "Moon"); + const twice = buildBundle(); + assert.equal(twice.bundleHash, bundle.bundleHash); + + const workflow = workflowFixture({ timingReady: true }); + const timing = buildReportEvidenceBundleV2({ + workflows: [{ theme: "timing", workflow }], + subject: { displayName: "冒烟用户", birthTimeStatus: "reported", birthPlaceLabel: "冒烟市" }, + requestedThemes: ["timing"], + reportType: "personal_thematic", + presentationMode: "default", + skillSnapshot: { + name: "jyotish-personal-report", + version: "1.0.0", + sha256: "a".repeat(64), + sourceCommit: null, + }, + }); + const card = timing.claimCards.find((entry) => entry.theme === "timing"); + assert.ok(card, "timing theme must write a claim card"); + assert.match(card.conclusion, /2026-05-30/); + assert.match(card.timingBoundary ?? "", /2026-05-30/); + assert.match(card.timingBoundary ?? "", /候选窗口/); + const seed = timing.themeNarrativeSeeds.find((seed) => seed.theme === "timing"); + assert.ok(seed, "timing_narrative must seed the timing chapter"); + assert.match(seed.headline, /应期/); + const plan = buildPersonalReportSectionPlan(timing, "standard"); + const timingSection = plan.sections.find((section) => section.theme === "timing")!; + const filtered = filterReportEvidenceBundleForSection(timing, timingSection); + assert.equal(filtered.interpretiveFacts.pratyantarTimeline?.current?.lord, "Sun"); +}); diff --git a/scripts/consultation_engine_field_bridge.py b/scripts/consultation_engine_field_bridge.py new file mode 100644 index 00000000..8943cc4e --- /dev/null +++ b/scripts/consultation_engine_field_bridge.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Attach merged-engine fields to the commercial consultation path. + +Task 6 of TASK-upstream-sync-20260903: the three-way merge left +planetary_friendship, timing_narrative, module_execution_audit, and +pratyantar_dasha_timeline on the engine. The web consultation workflow does +not call cmd_full_reading, so those keys never reached /api/consultation_workflow +until this module copies a compact, allowlisted subset onto the chart and +response. Import-missing research helpers stay blocked. No nadi/classical +loci, no birth-data files. +""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +_HEADLINE_MAX = 300 +_LINE_MAX = 400 +_LIST_MAX = 8 +ASHTOTTARI_PARTIAL_NOTE = "参数敏感、未验证" + + +def _clip_text(value: Any, limit: int) -> str: + text = " ".join(str(value or "").split()) + if len(text) <= limit: + return text + return text[: max(0, limit - 1)].rstrip() + "…" + + +def _clip_lines(values: Any) -> list[str]: + if not isinstance(values, list): + return [] + out: list[str] = [] + for item in values: + text = _clip_text(item, _LINE_MAX) + if text: + out.append(text) + if len(out) >= _LIST_MAX: + break + return out + + +def _load_engine(): + scripts_dir = str(Path(__file__).resolve().parent) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + try: + from scripts import jyotish_engine as engine + except ModuleNotFoundError: # pragma: no cover - script execution + import jyotish_engine as engine + return engine + + +def _modules_for_timing(modules: dict | None) -> dict[str, Any]: + view = dict(modules) if isinstance(modules, dict) else {} + dasha = dict(view.get("dasha") or {}) if isinstance(view.get("dasha"), dict) else {} + current = dasha.get("current_dasha") if isinstance(dasha.get("current_dasha"), dict) else {} + if not current.get("mahadasha"): + sub = view.get("dasha_sub_periods") if isinstance(view.get("dasha_sub_periods"), dict) else {} + running = sub.get("current") if isinstance(sub.get("current"), dict) else {} + maha = running.get("mahadasha") if isinstance(running.get("mahadasha"), dict) else {} + lord = maha.get("lord") + if lord: + dasha["current_dasha"] = {"mahadasha": lord} + view["dasha"] = dasha + return view + + +def compact_timing_narrative(modules: dict | None) -> dict[str, Any]: + """Length-capped copy of the engine timing seed for the chat prompt pack.""" + try: + engine = _load_engine() + payload = engine._build_timing_narrative_payload(_modules_for_timing(modules)) + except Exception as exc: + return {"status": "blocked", "reason": f"timing_narrative_unavailable:{exc.__class__.__name__}"} + if not isinstance(payload, dict): + return {"status": "blocked", "reason": "timing_narrative_unavailable"} + return { + "status": payload.get("status") or "parameter_sensitive", + "headline": _clip_text(payload.get("headline"), _HEADLINE_MAX), + "strengths": _clip_lines(payload.get("strengths")), + "risks": _clip_lines(payload.get("risks")), + "boundaries": _clip_lines(payload.get("boundaries")), + } + + +def compact_planetary_friendship(snapshot: dict | None) -> dict[str, Any]: + if not isinstance(snapshot, dict): + return {"status": "blocked", "reason": "planetary_friendship_unavailable", "rows": []} + rows = [] + for raw in snapshot.get("rows") or []: + if not isinstance(raw, dict) or not raw.get("planet"): + continue + compound = raw.get("compound") if isinstance(raw.get("compound"), dict) else {} + + def names(key: str) -> list[str]: + values = compound.get(key) + if not isinstance(values, list): + return [] + return [str(item) for item in values if item][:8] + + rows.append({ + "planet": str(raw["planet"]), + "great_friends": names("great_friends"), + "friends": names("friends"), + "neutral": names("neutral"), + "enemies": names("enemies"), + "great_enemies": names("great_enemies"), + }) + if len(rows) >= 9: + break + return { + "status": snapshot.get("status") or "parameter_sensitive", + "rows": rows, + "boundary": str(snapshot.get("boundary") or "local raw relationship snapshot only"), + } + + +def compact_pratyantar_timeline(sub_periods: dict | None, reference_dt: datetime) -> dict[str, Any]: + """Current and next Pratyantar boundaries for the running antardasha.""" + if not isinstance(sub_periods, dict): + return {"status": "blocked", "reason": "dasha_sub_periods_missing"} + current = sub_periods.get("current") if isinstance(sub_periods.get("current"), dict) else {} + maha = current.get("mahadasha") if isinstance(current.get("mahadasha"), dict) else {} + antar = current.get("antardasha") if isinstance(current.get("antardasha"), dict) else {} + md_lord = str(maha.get("lord") or "") + ad_lord = str(antar.get("lord") or "") + start = _parse_date(antar.get("start")) + end = _parse_date(antar.get("end")) + if not md_lord or not ad_lord or start is None or end is None or end <= start: + return {"status": "blocked", "reason": "antardasha_boundaries_unavailable"} + engine = _load_engine() + try: + timeline = engine._build_pratyantar_timeline(md_lord, ad_lord, start, end, reference_dt=reference_dt) + except Exception as exc: + return {"status": "blocked", "reason": f"pratyantar_unavailable:{exc.__class__.__name__}"} + if not isinstance(timeline, list) or not timeline: + return {"status": "blocked", "reason": "pratyantar_timeline_empty"} + current_row = next((row for row in timeline if isinstance(row, dict) and row.get("is_current")), None) + if current_row is None: + current_row = timeline[0] + next_row = None + if isinstance(current_row, dict): + try: + index = timeline.index(current_row) + next_row = timeline[index + 1] if index + 1 < len(timeline) else None + except ValueError: + next_row = None + + def boundary(row: dict | None) -> dict[str, str] | None: + if not isinstance(row, dict) or not row.get("lord"): + return None + return { + "lord": str(row["lord"]), + "start": str(row.get("start") or ""), + "end": str(row.get("end") or ""), + } + + current_boundary = boundary(current_row) + next_boundary = boundary(next_row if isinstance(next_row, dict) else None) + return { + "status": "ready", + "current": current_boundary, + "next": next_boundary, + "boundary": "candidate window inside the running antardasha; not a verified event date", + } + + +def module_execution_audit_for_chart(chart: dict | None) -> list[dict[str, Any]]: + engine = _load_engine() + modules = (chart or {}).get("modules") if isinstance((chart or {}).get("modules"), dict) else {} + packet = { + "raw_full_reading": {"modules": modules}, + "calculation_profile_id": "consultation_workflow_local", + "raw_module_usage_map": {}, + } + try: + rows = engine._build_module_execution_audit(packet) + except Exception: + return [] + if not isinstance(rows, list): + return [] + compact = [] + for row in rows: + if not isinstance(row, dict) or not row.get("module_id"): + continue + if str(row["module_id"]) == "module_execution_audit": + continue + compact.append({ + "module_id": str(row["module_id"]), + "status": str(row.get("status") or "available"), + "conclusion_use": str(row.get("conclusion_use") or "raw_appendix_only"), + "limitation": _clip_text(row.get("limitation"), _LINE_MAX), + }) + if len(compact) >= 40: + break + return compact + + +def extra_technique_audit_rows(chart: dict | None) -> list[dict[str, Any]]: + """Audit rows derived from the merged fields. Partial never reads as certainty.""" + modules = (chart or {}).get("modules") if isinstance((chart or {}).get("modules"), dict) else {} + friendship = modules.get("planetary_friendship") if isinstance(modules.get("planetary_friendship"), dict) else {} + friendship_rows = friendship.get("rows") if isinstance(friendship.get("rows"), list) else [] + friendship_status = "executed" if friendship_rows else "blocked" + audit = module_execution_audit_for_chart(chart) + return [ + { + "technique": "Planetary Friendship", + "status": friendship_status, + "system": "jyotish", + "boundary": "友敌等级表;不得当作确定性结论", + }, + { + "technique": "Ashtottari Dasha", + "status": "partial", + "system": "jyotish", + "boundary": ASHTOTTARI_PARTIAL_NOTE, + }, + { + "technique": "Module Execution Audit", + "status": "executed" if audit else "blocked", + "system": "jyotish", + "boundary": "附录可见;不得升格为确定性结论", + }, + ] + + +def attach_merged_engine_fields(chart: dict, *, reference_dt: datetime) -> dict: + """Mutate chart.modules with friendship + pratyantar. Safe on empty charts.""" + if not isinstance(chart, dict): + return chart + modules = chart.get("modules") if isinstance(chart.get("modules"), dict) else {} + chart["modules"] = modules + planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {} + if planets and not isinstance(modules.get("planetary_friendship"), dict): + try: + engine = _load_engine() + snapshot = engine._build_planetary_friendship_snapshot(planets) + modules["planetary_friendship"] = compact_planetary_friendship(snapshot) + except Exception as exc: + modules["planetary_friendship"] = { + "status": "blocked", + "reason": f"planetary_friendship_unavailable:{exc.__class__.__name__}", + "rows": [], + } + sub_periods = modules.get("dasha_sub_periods") if isinstance(modules.get("dasha_sub_periods"), dict) else None + pratyantar = compact_pratyantar_timeline(sub_periods, reference_dt) + if isinstance(sub_periods, dict): + sub_periods["pratyantar_dasha_timeline"] = pratyantar + modules["pratyantar_dasha_timeline"] = pratyantar + modules["ashtottari_dasha"] = { + "status": "parameter_sensitive", + "overlay_status": "partial", + "claim_boundary": ASHTOTTARI_PARTIAL_NOTE, + "execution_status": "not_asserted_as_deterministic", + } + modules["module_execution_audit"] = module_execution_audit_for_chart(chart) + return chart + + +def consultation_workflow_extra_fields(chart: dict | None) -> dict[str, Any]: + modules = (chart or {}).get("modules") if isinstance((chart or {}).get("modules"), dict) else {} + snapshot = (((chart or {}).get("ai_prompt_pack") or {}).get("evidence_snapshot") or {}) if isinstance(chart, dict) else {} + timing = snapshot.get("timing_narrative") if isinstance(snapshot.get("timing_narrative"), dict) else compact_timing_narrative(modules) + return { + "timing_narrative": timing, + "module_execution_audit": modules.get("module_execution_audit") or module_execution_audit_for_chart(chart), + "pratyantar_dasha_timeline": modules.get("pratyantar_dasha_timeline") or {"status": "blocked"}, + } + + +def _parse_date(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value.replace(tzinfo=None) + if isinstance(value, str) and value.strip(): + text = value.strip().replace("Z", "+00:00") + try: + if "T" in text: + return datetime.fromisoformat(text).replace(tzinfo=None) + return datetime.strptime(text[:10], "%Y-%m-%d") + except ValueError: + return None + return None diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index e5ba0026..ee356f69 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -1268,6 +1268,11 @@ def _audit_row(technique: str, status, *, system: str = 'jyotish', boundary: str return row +def _consultation_timing_narrative(modules): + from scripts.consultation_engine_field_bridge import compact_timing_narrative + return compact_timing_narrative(modules) + + def _consultation_technique_audit_table( *, chart: dict, @@ -1367,6 +1372,14 @@ def _consultation_technique_audit_table( rows.append(_audit_row('Ashtakoota matching', 'not_applicable', boundary='requires a partner chart')) rows.append(_audit_row('Adhana / Niseka', 'not_applicable', boundary='research_only; not a natal consultation layer')) rows.append(_audit_row('Muhurta election', 'not_applicable', boundary='not an electional request')) + from scripts.consultation_engine_field_bridge import extra_technique_audit_rows + for extra in extra_technique_audit_rows(chart): + rows.append(_audit_row( + str(extra.get('technique') or 'Technique'), + extra.get('status'), + system=str(extra.get('system') or 'jyotish'), + boundary=str(extra.get('boundary') or ''), + )) return rows @@ -1776,6 +1789,9 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, } diagnostics.append({'layer': 'transits', 'status': 'unavailable', 'reason': exc.__class__.__name__}) + from scripts.consultation_engine_field_bridge import attach_merged_engine_fields + + attach_merged_engine_fields(chart, reference_dt=_consultation_reference_date(body).replace(tzinfo=None)) chart['local_consultation_layers'] = { 'status': 'ready' if not diagnostics else 'partial', 'source': 'repository_local_engines', @@ -1786,6 +1802,7 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, 'dasha_sub_periods', 'ashtakavarga', 'kp_cusps', 'gulika', 'functional_benefic_malefic', 'shadbala', 'kakshya', 'yogas', 'raman_support', 'chara_dasha', 'transits', + 'planetary_friendship', ) if _consultation_audit_status(modules.get(name)) == 'executed' ], @@ -2279,6 +2296,7 @@ def execute_consultation_workflow( executed_steps.append('compute_chart') chart = _attach_local_consultation_layers(handler, chart, birth_payload, body) + chart['ai_prompt_pack'] = handler._build_chart_prompt_pack(chart) historical_backtest = {} if 'run_historical_event_backtest' in runtime_planner.get('sync_steps', []): @@ -2495,7 +2513,9 @@ def execute_consultation_workflow( timing=body.get('timing'), reference_date=_consultation_reference_date(body).date().isoformat(), ) + from scripts.consultation_engine_field_bridge import consultation_workflow_extra_fields from scripts.commercial_skill_truth import apply_commercial_skill_truth + result.update(consultation_workflow_extra_fields(chart)) return apply_commercial_skill_truth(result) @@ -7284,6 +7304,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'external_oracle_status': 'D1/D9/VedAstro longitude boundary covered; Dasha/Shadbala external absolute calibration still requires multi-source oracle expansion.', }, 'oracle_progress': oracle_progress, + 'timing_narrative': _consultation_timing_narrative(modules), }, 'retrieval_plan': { 'local_reference_docs': [ diff --git a/tests/golden/consultation_contract_keypaths_v1.json b/tests/golden/consultation_contract_keypaths_v1.json index b3cd4165..f909cc05 100644 --- a/tests/golden/consultation_contract_keypaths_v1.json +++ b/tests/golden/consultation_contract_keypaths_v1.json @@ -269,10 +269,17 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -397,6 +404,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", @@ -569,10 +582,17 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -697,6 +717,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", @@ -869,10 +895,17 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -997,6 +1030,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", @@ -1169,10 +1208,17 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -1297,6 +1343,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", @@ -1469,10 +1521,17 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -1597,6 +1656,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", @@ -1769,10 +1834,17 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -1897,6 +1969,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", @@ -2069,6 +2147,8 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "muhurta_panchanga.activity": "string", "muhurta_panchanga.activity_label": "string", @@ -2083,6 +2163,11 @@ "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -2207,6 +2292,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", @@ -2379,6 +2470,8 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "muhurta_panchanga.activity": "string", "muhurta_panchanga.activity_label": "string", @@ -2393,6 +2486,11 @@ "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -2517,6 +2615,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", @@ -2698,10 +2802,17 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -2826,6 +2937,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", @@ -3007,10 +3124,17 @@ "machine_evidence_packet.signals": "array", "machine_evidence_packet.status": "string", "mode": "string", + "module_execution_audit": "array", + "module_execution_audit[0]": "object", "muhurta_panchanga": "object", "next_questions": "array", "next_questions[0]": "object", "prashna": "object", + "pratyantar_dasha_timeline": "object", + "pratyantar_dasha_timeline.boundary": "string", + "pratyantar_dasha_timeline.current": "object", + "pratyantar_dasha_timeline.next": "object", + "pratyantar_dasha_timeline.status": "string", "question": "string", "real_case_calibration": "object", "real_case_calibration.annual_control_pilot": "object", @@ -3135,6 +3259,12 @@ "thematic_report.workflow_orchestration": "object", "themes": "array", "themes[0]": "string", + "timing_narrative": "object", + "timing_narrative.boundaries": "array", + "timing_narrative.headline": "string", + "timing_narrative.risks": "array", + "timing_narrative.status": "string", + "timing_narrative.strengths": "array", "timing_precision_contract": "object", "timing_precision_contract.boundary": "string", "timing_precision_contract.candidate_windows": "array", diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index c0c9497c..d9276e01 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -2488,6 +2488,8 @@ def test_consultation_foreground_skips_vedastro_main_entry_overview(monkeypatch) def test_consultation_foreground_times_out_vedastro_without_failing_local(monkeypatch) -> None: + import scripts.jyotish_engine # noqa: F401 — attach now imports the engine; keep that cost out of the 0.4s race. + handler = _handler() fake_chart = { 'success': True, diff --git a/tests/test_consultation_engine_field_bridge.py b/tests/test_consultation_engine_field_bridge.py new file mode 100644 index 00000000..2c17dd43 --- /dev/null +++ b/tests/test_consultation_engine_field_bridge.py @@ -0,0 +1,106 @@ +"""Task 6: merged-engine fields on the commercial consultation path. + +Fictional smoke data only. No birth-case files. +""" + +from __future__ import annotations + +from datetime import datetime + +from scripts.consultation_engine_field_bridge import ( + ASHTOTTARI_PARTIAL_NOTE, + attach_merged_engine_fields, + compact_planetary_friendship, + compact_pratyantar_timeline, + compact_timing_narrative, + extra_technique_audit_rows, +) +from scripts.jyotish_engine import _build_planetary_friendship_snapshot + + +SMOKE_PLANETS = { + "Sun": {"sign": "Capricorn"}, + "Moon": {"sign": "Sagittarius"}, + "Mars": {"sign": "Scorpio"}, + "Mercury": {"sign": "Capricorn"}, + "Jupiter": {"sign": "Gemini"}, + "Venus": {"sign": "Aquarius"}, + "Saturn": {"sign": "Capricorn"}, + "Rahu": {"sign": "Aquarius"}, + "Ketu": {"sign": "Leo"}, +} + + +def test_friendship_compact_is_grade_table_only() -> None: + snapshot = _build_planetary_friendship_snapshot(SMOKE_PLANETS) + compact = compact_planetary_friendship(snapshot) + assert compact["rows"] + first = compact["rows"][0] + assert first["planet"] == "Sun" + assert set(first) == { + "planet", "great_friends", "friends", "neutral", "enemies", "great_enemies", + } + assert "headline" not in compact + assert "markdown" not in compact + + +def test_pratyantar_returns_current_and_next_boundaries() -> None: + sub_periods = { + "current": { + "mahadasha": {"lord": "Rahu", "start": "2011-11-11", "end": "2029-11-11"}, + "antardasha": {"lord": "Sun", "start": "2026-05-30", "end": "2027-04-24"}, + } + } + timeline = compact_pratyantar_timeline(sub_periods, datetime(2026, 9, 3)) + assert timeline["status"] == "ready" + assert timeline["current"]["lord"] + assert timeline["current"]["start"] <= "2026-09-03" <= timeline["current"]["end"] + assert timeline["next"]["lord"] + assert timeline["next"]["start"] == timeline["current"]["end"] + + +def test_timing_narrative_clips_to_seed_caps() -> None: + payload = compact_timing_narrative({ + "dasha_sub_periods": { + "current": {"mahadasha": {"lord": "Rahu"}}, + } + }) + assert payload["status"] != "blocked" + assert len(payload["headline"]) <= 300 + assert len(payload["strengths"]) <= 8 + assert all(len(item) <= 400 for item in payload["strengths"]) + assert all(len(item) <= 400 for item in payload["boundaries"]) + + +def test_ashtottari_audit_row_is_parameter_sensitive_not_certainty() -> None: + rows = extra_technique_audit_rows({"modules": {}}) + by_name = {row["technique"]: row for row in rows} + assert by_name["Ashtottari Dasha"]["status"] == "partial" + assert by_name["Ashtottari Dasha"]["boundary"] == ASHTOTTARI_PARTIAL_NOTE + assert by_name["Planetary Friendship"]["status"] == "blocked" + assert by_name["Module Execution Audit"]["boundary"] == "附录可见;不得升格为确定性结论" + + +def test_attach_merged_fields_hangs_friendship_and_pratyantar() -> None: + chart = { + "planets": SMOKE_PLANETS, + "modules": { + "dasha_sub_periods": { + "current": { + "mahadasha": {"lord": "Rahu", "start": "2011-11-11", "end": "2029-11-11"}, + "antardasha": {"lord": "Sun", "start": "2026-05-30", "end": "2027-04-24"}, + } + } + }, + } + attach_merged_engine_fields(chart, reference_dt=datetime(2026, 9, 3)) + friendship = chart["modules"]["planetary_friendship"] + assert friendship["rows"] + pratyantar = chart["modules"]["pratyantar_dasha_timeline"] + assert pratyantar["status"] == "ready" + nested = chart["modules"]["dasha_sub_periods"]["pratyantar_dasha_timeline"] + assert nested["current"]["lord"] == pratyantar["current"]["lord"] + audit_rows = extra_technique_audit_rows(chart) + friendship_row = next(row for row in audit_rows if row["technique"] == "Planetary Friendship") + assert friendship_row["status"] == "executed" + assert "确定性" in friendship_row["boundary"]