feat: add health consultation report pipeline

This commit is contained in:
Jesse_Chen
2026-09-03 23:55:14 +08:00
parent 0be6fcfb4c
commit c2f23131f7
17 changed files with 277 additions and 25 deletions
@@ -20,7 +20,7 @@ import { useRouter } from "next/navigation";
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing"] as const; export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing", "health"] as const;
export interface PersonalReportCreateRequest { export interface PersonalReportCreateRequest {
requestId: string; requestId: string;
+1 -1
View File
@@ -45,7 +45,7 @@ export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const;
export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const; export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
export const REPORT_ACTION_PRIORITIES = ["now", "next", "watch"] as const; export const REPORT_ACTION_PRIORITIES = ["now", "next", "watch"] as const;
export const REPORT_DOCUMENT_V1_CHART_IDS = ["D1", "D9", "D10"] as const; export const REPORT_DOCUMENT_V1_CHART_IDS = ["D1", "D9", "D10"] as const;
export const CHART_IDS = ["D1", "D2", "D9", "D10", "D11", "D24"] as const; export const CHART_IDS = ["D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"] as const;
const claimStatusSchema = z.enum(CLAIM_STATUSES); const claimStatusSchema = z.enum(CLAIM_STATUSES);
const themeIdSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/, "invalid theme id"); const themeIdSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/, "invalid theme id");
+15 -9
View File
@@ -317,19 +317,22 @@ function readHouses(
return houses; return houses;
} }
const DOCUMENT_VARGA_CHART_IDS = ["D2", "D9", "D10", "D11", "D24"] as const; const DOCUMENT_VARGA_CHART_IDS = ["D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"] as const;
type DocumentVargaChartId = (typeof DOCUMENT_VARGA_CHART_IDS)[number]; type DocumentVargaChartId = (typeof DOCUMENT_VARGA_CHART_IDS)[number];
const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = { const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = {
D2: ["D2_Hora", "D2"], D2: ["D2_Hora", "D2"],
D6: ["D6_Shashthamsa", "D6"],
D8: ["D8_Ashtamsa", "D8"],
D9: ["D9_Navamsa", "D9"], D9: ["D9_Navamsa", "D9"],
D10: ["D10_Dasamsa", "D10"], D10: ["D10_Dasamsa", "D10"],
D11: ["D11_Rudramsa", "D11"], D11: ["D11_Rudramsa", "D11"],
D24: ["D24_Chaturvimsamsa", "D24_Siddhamsa", "D24"], D24: ["D24_Chaturvimsamsa", "D24_Siddhamsa", "D24"],
D30: ["D30_Trimsamsa", "D30"],
}; };
function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | null { function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | null {
const match = /^(D(?:24|11|10|9|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase()); const match = /^(D(?:30|24|11|10|9|8|6|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase());
return match ? match[1] as DocumentVargaChartId : null; return match ? match[1] as DocumentVargaChartId : null;
} }
@@ -797,7 +800,7 @@ const SAFE_CELESTIAL_NAMES = new Map([
].map((name) => [name, name.charAt(0).toUpperCase() + name.slice(1)])); ].map((name) => [name, name.charAt(0).toUpperCase() + name.slice(1)]));
const SAFE_WORKFLOW_TECHNIQUES = new Map<string, string>([ const SAFE_WORKFLOW_TECHNIQUES = new Map<string, string>([
["d1", "D1"], ["d2", "D2"], ["d4", "D4"], ["d6", "D6"], ["d7", "D7"], ["d1", "D1"], ["d2", "D2"], ["d4", "D4"], ["d6", "D6"], ["d7", "D7"], ["d8", "D8"],
["d9", "D9"], ["d10", "D10"], ["d11", "D11"], ["d12", "D12"], ["d9", "D9"], ["d10", "D10"], ["d11", "D11"], ["d12", "D12"],
["d24", "D24"], ["d30", "D30"], ["a7", "A7"], ["a10", "A10"], ["d24", "D24"], ["d30", "D30"], ["a7", "A7"], ["a10", "A10"],
["ul", "UL"], ["upapada", "UL"], ["dk", "DK"], ["darakaraka", "DK"], ["ul", "UL"], ["upapada", "UL"], ["dk", "DK"], ["darakaraka", "DK"],
@@ -1479,6 +1482,7 @@ const NARRATIVE_SNAPSHOT_THEMES: Readonly<Record<string, string>> = {
career_narrative: "career", career_narrative: "career",
relationship_narrative: "marriage", relationship_narrative: "marriage",
finance_narrative: "wealth", finance_narrative: "wealth",
health_narrative: "health_pressure",
timing_narrative: "timing", timing_narrative: "timing",
}; };
@@ -1924,11 +1928,13 @@ export function buildReportEvidenceBundleV2(
const allVerified = uniqueMatched.every((receipt) => receipt.status === "verified"); const allVerified = uniqueMatched.every((receipt) => receipt.status === "verified");
// The assertionLevel derivation is unchanged: richer content must never // The assertionLevel derivation is unchanged: richer content must never
// raise certainty. Only the seed-less fallback may lower it. // raise certainty. Only the seed-less fallback may lower it.
const derivedLevel: ClaimStatus = allVerified && uniqueMatched.length >= 2 const derivedLevel: ClaimStatus = plan.theme === "health_pressure"
? "multi_system_consensus" ? "parameter_sensitive"
: allVerified : allVerified && uniqueMatched.length >= 2
? "single_system_inference" ? "multi_system_consensus"
: "parameter_sensitive"; : allVerified
? "single_system_inference"
: "parameter_sensitive";
const seed = buildThemeSeed(plan.theme); const seed = buildThemeSeed(plan.theme);
if (seed) themeNarrativeSeeds.push(seed); if (seed) themeNarrativeSeeds.push(seed);
const conclusion = seed const conclusion = seed
@@ -2614,7 +2620,7 @@ export function assembleReportDocumentV2(
const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs)); const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs));
const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs; const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs;
const chartIds = new Set(["D1", "D2", "D9", "D10", "D11", "D24"]); const chartIds = new Set(["D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"]);
const charts: ReportDocumentV2["charts"] = []; const charts: ReportDocumentV2["charts"] = [];
for (const chart of bundle.charts) { for (const chart of bundle.charts) {
if (!chartIds.has(chart.id)) continue; if (!chartIds.has(chart.id)) continue;
@@ -36,7 +36,7 @@ import type {
PersonalReportService, PersonalReportService,
} from "./personal-report-service-core"; } from "./personal-report-service-core";
const reportRequestThemes = z.enum(["career", "marriage", "wealth", "timing", "general"]); const reportRequestThemes = z.enum(["career", "marriage", "wealth", "health", "timing", "general"]);
export const personalReportCreateRequestSchema = z.object({ export const personalReportCreateRequestSchema = z.object({
requestId: z.string().uuid(), requestId: z.string().uuid(),
@@ -45,7 +45,7 @@ export const personalReportCreateRequestSchema = z.object({
reportType: z.enum(["personal_full", "personal_thematic"]), reportType: z.enum(["personal_full", "personal_thematic"]),
presentationMode: z.enum(["default", "research"]).default("default"), presentationMode: z.enum(["default", "research"]).default("default"),
depth: z.enum(["concise", "standard", "deep", "research"]).default("standard"), depth: z.enum(["concise", "standard", "deep", "research"]).default("standard"),
themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing"]), themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing", "health"]),
}).strict(); }).strict();
export type PersonalReportCreateRequest = z.infer<typeof personalReportCreateRequestSchema>; export type PersonalReportCreateRequest = z.infer<typeof personalReportCreateRequestSchema>;
@@ -266,13 +266,13 @@ const deterministicClaimBoundarySchema = z.enum([
"relationship_combinations", "relationship_combinations",
]); ]);
const allowedTechniqueNames = new Set([ const allowedTechniqueNames = new Set([
"D1", "D2", "D4", "D6", "D7", "D9", "D10", "D11", "D12", "D24", "D30", "D1", "D2", "D4", "D6", "D7", "D8", "D9", "D10", "D11", "D12", "D24", "D30",
"A7", "A10", "UL", "DK", "AmK", "A7", "A10", "UL", "DK", "AmK",
"Vimshottari", "Narayana", "Transit", "Yoga", "Ashtakavarga", "Vimshottari", "Narayana", "Transit", "Yoga", "Ashtakavarga",
"Functional Benefic/Malefic", "Planet Degrees", "House Degrees", "Functional Benefic/Malefic", "Planet Degrees", "House Degrees",
"Planetary Friendship", "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 missingTechniquePattern = /^(?:career|marriage|wealth|education|migration_home|family|health_pressure|timing|general):(?:D1|D2|D4|D6|D6\/D30|D7\/D12|D8|D9|D10|D11|D12|D24|D30|A7|A10|UL|DK|AmK|Vimshottari|Narayana|Transit|Yoga|Ashtakavarga)$/;
const techniqueSchema = z.string().max(80).refine( const techniqueSchema = z.string().max(80).refine(
(value) => allowedTechniqueNames.has(value) || missingTechniquePattern.test(value), (value) => allowedTechniqueNames.has(value) || missingTechniquePattern.test(value),
"report_bundle_technique_not_allowlisted", "report_bundle_technique_not_allowlisted",
@@ -48,7 +48,8 @@ export const THEME_INTERPRETATION_PACKS: Readonly<Record<string, string>> = {
· 代际张力写成"两代人对安全感的定义不同",并给出可操作的边界设定方式。 · 代际张力写成"两代人对安全感的定义不同",并给出可操作的边界设定方式。
禁止:不预测生育时间、性别或数量;不判断亲属健康与寿数;不建议断绝或维持任何具体亲属关系。`, 禁止:不预测生育时间、性别或数量;不判断亲属健康与寿数;不建议断绝或维持任何具体亲属关系。`,
health_pressure: `【健康压力主题·解读要点(非医疗)】 health_pressure: `非医疗边界:本章只讨论压力、负荷与恢复节奏,不构成诊断或治疗建议。
【健康压力主题·解读要点(非医疗)】
主指标:第6宫(日常负荷与恢复)、第1宫(体感与精力基线)、第8宫(长期消耗与转化)、压力相关分盘。 主指标:第6宫(日常负荷与恢复)、第1宫(体感与精力基线)、第8宫(长期消耗与转化)、压力相关分盘。
· 全篇只谈"压力来源、负荷节奏与恢复方式",不谈器官、疾病、症状或诊断。 · 全篇只谈"压力来源、负荷节奏与恢复方式",不谈器官、疾病、症状或诊断。
· 第6宫写成"每天消耗你的事情的形状",第1宫写成"精力的基线与恢复速度"。 · 第6宫写成"每天消耗你的事情的形状",第1宫写成"精力的基线与恢复速度"。
@@ -104,9 +104,12 @@ const PLAN: Readonly<Record<ReportTheme, ReportThemeEvidencePlan>> = {
section: "健康压力(非医疗)", section: "健康压力(非医疗)",
requiredTechniqueGroups: [ requiredTechniqueGroups: [
{ label: "D1", anyOf: ["D1"] }, { label: "D1", anyOf: ["D1"] },
{ label: "D6/D30", anyOf: ["D6", "D30"] }, { label: "D6", anyOf: ["D6"] },
{ label: "D8", anyOf: ["D8"] },
{ label: "D30", anyOf: ["D30"] },
{ label: "Vimshottari", anyOf: ["Vimshottari", "dasha_boundaries"] },
], ],
chartIds: ["D1", "D6", "D30"], chartIds: ["D1", "D6", "D8", "D30"],
}, },
timing: { timing: {
theme: "timing", theme: "timing",
+1 -1
View File
@@ -39,7 +39,7 @@ export const claimStatusSchema = z.enum([
]); ]);
export type ReportVargaHouses = Readonly<{ export type ReportVargaHouses = Readonly<{
id: "D2" | "D9" | "D10" | "D11" | "D24"; id: "D2" | "D6" | "D8" | "D9" | "D10" | "D11" | "D24" | "D30";
houses: readonly ReportChartHouse[]; houses: readonly ReportChartHouse[];
}>; }>;
+2 -2
View File
@@ -33,8 +33,8 @@ test("request shape: POST /api/reports body carries only report identity fields"
assert.equal(body.reportType, "personal_full"); assert.equal(body.reportType, "personal_full");
assert.equal(body.presentationMode, "default"); assert.equal(body.presentationMode, "default");
assert.equal(body.depth, "standard"); assert.equal(body.depth, "standard");
assert.deepEqual(body.themes, ["career", "marriage", "wealth", "timing"]); assert.deepEqual(body.themes, ["career", "marriage", "wealth", "timing", "health"]);
assert.deepEqual(DEFAULT_REPORT_THEMES, ["career", "marriage", "wealth", "timing"]); assert.deepEqual(DEFAULT_REPORT_THEMES, ["career", "marriage", "wealth", "timing", "health"]);
// No birth data, no chat text, no profile payload. // No birth data, no chat text, no profile payload.
assert.doesNotMatch(JSON.stringify(body), /birth|birthDate|latitude|longitude|timezone|profile|message|text/i); assert.doesNotMatch(JSON.stringify(body), /birth|birthDate|latitude|longitude|timezone|profile|message|text/i);
}); });
+41 -1
View File
@@ -33,7 +33,25 @@ function clone<T>(value: T): T {
} }
function goldenWorkflow(theme: string): JsonRecord { function goldenWorkflow(theme: string): JsonRecord {
return clone(GOLDEN.themes[theme] ?? GOLDEN.themes.career); const workflow = clone(GOLDEN.themes[theme] ?? GOLDEN.themes.career);
if (theme === "health" || theme === "health_pressure") {
const modules = chartModules(workflow);
const source = clone((modules.varga_full as JsonRecord).D24_Chaturvimsamsa);
(modules.varga_full as JsonRecord).D6_Shashthamsa = clone(source);
(modules.varga_full as JsonRecord).D8_Ashtamsa = clone(source);
(modules.varga_full as JsonRecord).D30_Trimsamsa = clone(source);
const sections = (workflow.machine_evidence_packet as JsonRecord).sections as JsonRecord;
sections.D8 = { status: "used", source_path: "modules.varga_full.D8" };
(workflow.chart as JsonRecord).ai_prompt_pack = { evidence_snapshot: {
health_narrative: {
headline: "非医疗边界:D6/D8/D30 显示压力负荷与恢复节奏需要分层观察。",
strengths: ["当前 Dasha 激活 6/8/12 宫主的部分恢复支持。"],
risks: ["相关 Shadbala 较弱时需要降低负荷。"],
boundaries: ["非医疗边界:本段不构成诊断或治疗建议。"],
},
} };
}
return workflow;
} }
function buildBundle(themes: readonly string[], workflows?: readonly JsonRecord[]) { function buildBundle(themes: readonly string[], workflows?: readonly JsonRecord[]) {
@@ -206,3 +224,25 @@ test("nine requested themes close from the same golden chart layers", () => {
assert.equal(row.status, "claim", `${row.theme} still blocked: ${row.reason}`); assert.equal(row.status, "claim", `${row.theme} still blocked: ${row.reason}`);
} }
}); });
test("health requires D1 D6 D8 D30 and Vimshottari while Transit stays optional", () => {
const full = goldenWorkflow("health");
delete chartModules(full).transits;
const bundle = buildBundle(["health"], [full]);
const card = bundle.claimCards.find((item) => item.theme === "health_pressure");
assert.ok(card);
assert.match(card.conclusion, /D6\/D8\/D30/);
assert.match(card.conclusion, /非医疗/);
assert.equal(bundle.blockedSections.some((item) => item.theme === "health_pressure"), false);
assert.equal(card.assertionLevel, "parameter_sensitive");
for (const missing of ["D6_Shashthamsa", "D8_Ashtamsa", "D30_Trimsamsa"] as const) {
const workflow = goldenWorkflow("health");
delete (chartModules(workflow).varga_full as JsonRecord)[missing];
const technique = missing.split("_")[0];
delete ((workflow.machine_evidence_packet as JsonRecord).sections as JsonRecord)[technique];
const blocked = buildBundle(["health"], [workflow]);
assert.ok(blocked.blockedSections.some((item) => item.theme === "health_pressure"));
}
});
@@ -55,6 +55,7 @@ test("packs restate the hard truth boundaries instead of relaxing them", () => {
} }
assert.ok(general.includes("不做医疗诊断")); assert.ok(general.includes("不做医疗诊断"));
const health = interpretationPackForTheme("health_pressure")!; const health = interpretationPackForTheme("health_pressure")!;
assert.ok(health.startsWith("非医疗边界:"));
assert.ok(health.includes("不做任何诊断")); assert.ok(health.includes("不做任何诊断"));
const wealth = interpretationPackForTheme("wealth")!; const wealth = interpretationPackForTheme("wealth")!;
assert.ok(wealth.includes("不推荐任何具体投资标的")); assert.ok(wealth.includes("不推荐任何具体投资标的"));
@@ -87,6 +87,24 @@ def compact_timing_narrative(modules: dict | None) -> dict[str, Any]:
} }
def compact_health_narrative(modules: dict | None, chart: dict | None = None) -> dict[str, Any]:
"""Length-capped non-medical health seed for the commercial prompt pack."""
try:
engine = _load_engine()
payload = engine._build_health_narrative_payload(modules, chart)
except Exception as exc:
return {"status": "blocked", "reason": f"health_narrative_unavailable:{exc.__class__.__name__}"}
if not isinstance(payload, dict):
return {"status": "blocked", "reason": "health_narrative_unavailable"}
return {
"status": "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]: def compact_planetary_friendship(snapshot: dict | None) -> dict[str, Any]:
if not isinstance(snapshot, dict): if not isinstance(snapshot, dict):
return {"status": "blocked", "reason": "planetary_friendship_unavailable", "rows": []} return {"status": "blocked", "reason": "planetary_friendship_unavailable", "rows": []}
+6
View File
@@ -1275,6 +1275,11 @@ def _consultation_timing_narrative(modules):
return compact_timing_narrative(modules) return compact_timing_narrative(modules)
def _consultation_health_narrative(modules, chart):
from scripts.consultation_engine_field_bridge import compact_health_narrative
return compact_health_narrative(modules, chart)
def _consultation_technique_audit_table( def _consultation_technique_audit_table(
*, *,
chart: dict, chart: dict,
@@ -7307,6 +7312,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
}, },
'oracle_progress': oracle_progress, 'oracle_progress': oracle_progress,
'timing_narrative': _consultation_timing_narrative(modules), 'timing_narrative': _consultation_timing_narrative(modules),
'health_narrative': _consultation_health_narrative(modules, chart),
}, },
'retrieval_plan': { 'retrieval_plan': {
'local_reference_docs': [ 'local_reference_docs': [
+111
View File
@@ -10518,6 +10518,115 @@ def _build_finance_narrative_payload(finance_strict):
) )
def _build_health_narrative_payload(modules, chart=None):
"""Build a non-medical health-pressure seed from allowlisted chart evidence."""
modules = modules if isinstance(modules, dict) else {}
chart = chart if isinstance(chart, dict) else {}
varga_full = modules.get('varga_full') if isinstance(modules.get('varga_full'), dict) else {}
def sign_name(row):
if not isinstance(row, dict):
return None
sign = row.get('sign')
if sign in SIGNS:
return sign
raw_idx = row.get('sign_index', row.get('sign_idx'))
try:
return SIGNS[int(raw_idx) % 12]
except (TypeError, ValueError):
return None
def division(number):
prefix = f'D{number}'
for key, value in varga_full.items():
if str(key).upper() == prefix or str(key).upper().startswith(prefix + '_'):
return value if isinstance(value, dict) else {}
return {}
ascendant = chart.get('ascendant') if isinstance(chart.get('ascendant'), dict) else {}
asc_sign = sign_name(ascendant)
if not asc_sign:
module_chart = modules.get('chart') if isinstance(modules.get('chart'), dict) else {}
ascendant = module_chart.get('ascendant') if isinstance(module_chart.get('ascendant'), dict) else {}
asc_sign = sign_name(ascendant)
health_lords = {}
if asc_sign in SIGNS:
asc_idx = SIGNS.index(asc_sign)
for house in (6, 8, 12):
health_lords[house] = SIGN_LORDS[SIGNS[(asc_idx + house - 1) % 12]]
strengths = []
risks = []
for number in (6, 8, 30):
varga = division(number)
varga_asc = sign_name(varga.get('ascendant') if isinstance(varga.get('ascendant'), dict) else varga.get('Ascendant'))
if not varga or not varga_asc:
risks.append(f'D{number} 分盘证据缺失,相关压力与恢复节奏只能保持 blocked。')
continue
planets = varga.get('planets') if isinstance(varga.get('planets'), dict) else varga
placements = []
varga_asc_idx = SIGNS.index(varga_asc)
for house, lord in health_lords.items():
row = planets.get(lord) if isinstance(planets, dict) and isinstance(planets.get(lord), dict) else {}
lord_sign = sign_name(row)
if lord_sign:
lord_house = ((SIGNS.index(lord_sign) - varga_asc_idx) % 12) + 1
placements.append(f'{house}宫主{lord}{lord_house}')
detail = ''.join(placements) if placements else '相关宫主落点待补'
strengths.append(f'D{number} 上升为 {varga_asc}{detail}')
dasha = modules.get('dasha') if isinstance(modules.get('dasha'), dict) else {}
current = dasha.get('current_dasha') if isinstance(dasha.get('current_dasha'), dict) else {}
sub_periods = modules.get('dasha_sub_periods') if isinstance(modules.get('dasha_sub_periods'), dict) else {}
running = sub_periods.get('current') if isinstance(sub_periods.get('current'), dict) else {}
def dasha_lord(*values):
for value in values:
if isinstance(value, dict):
value = value.get('lord') or value.get('planet') or value.get('name')
if isinstance(value, str) and value:
return value
return None
maha = dasha_lord(current.get('mahadasha'), current.get('lord'), running.get('mahadasha'), chart.get('dasha', {}).get('current_md') if isinstance(chart.get('dasha'), dict) else None)
antar = dasha_lord(current.get('antardasha'), running.get('antardasha'))
active = [lord for lord in (maha, antar) if lord]
activated_houses = [str(house) for house, lord in health_lords.items() if lord in active]
if active:
activation = f"当前 Dasha 激活 {'/'.join(active)}"
if activated_houses:
activation += f",其中对应本命 {'/'.join(activated_houses)} 宫主"
strengths.append(activation + '')
else:
risks.append('当前 Vimshottari 主副运未提供,6/8/12 宫主激活只能保持 blocked。')
shadbala = modules.get('shadbala') if isinstance(modules.get('shadbala'), dict) else {}
shadbala_planets = shadbala.get('planets') if isinstance(shadbala.get('planets'), dict) else {}
for lord in dict.fromkeys(health_lords.values()):
row = shadbala_planets.get(lord) if isinstance(shadbala_planets.get(lord), dict) else None
if not row:
risks.append(f'{lord} 的 Shadbala 证据缺失。')
continue
total = row.get('total_rupas', row.get('total_rupa'))
minimum = row.get('min_required')
level = row.get('strength_level') or row.get('level')
strengths.append(f'{lord} Shadbalatotal={total if total is not None else "-"}minimum={minimum if minimum is not None else "-"}level={level or "-"}')
if isinstance(total, (int, float)) and isinstance(minimum, (int, float)) and total < minimum:
risks.append(f'{lord} 的 Shadbala 低于本地最低参考值,只能作为压力负荷提示。')
available = [f'D{number}' for number in (6, 8, 30) if division(number)]
headline = f"非医疗边界:健康压力种子已接入 {('/'.join(available) if available else '分盘待补')}、6/8/12 宫主 Dasha 激活与相关 Shadbala。"
return {
'headline': headline,
'strengths': strengths[:8],
'risks': risks[:8],
'boundaries': [
'非医疗边界:本段只讨论压力、负荷与恢复节奏,不构成诊断、治疗或健康事件预测。',
'Transit 只作可选交叉层;缺失时必须显示 blocked,但不阻断 D1、D6、D8、D30 与 Vimshottari 已闭合的报告主题。',
],
}
def _build_timing_narrative_payload(modules): def _build_timing_narrative_payload(modules):
modules = modules if isinstance(modules, dict) else {} modules = modules if isinstance(modules, dict) else {}
dasha = modules.get('dasha') if isinstance(modules.get('dasha'), dict) else {} dasha = modules.get('dasha') if isinstance(modules.get('dasha'), dict) else {}
@@ -10904,6 +11013,7 @@ def _build_ai_prompt_pack(report):
career_narrative = _build_career_narrative_payload(modules.get('career_strict_evidence')) career_narrative = _build_career_narrative_payload(modules.get('career_strict_evidence'))
finance_narrative = _build_finance_narrative_payload(modules.get('finance_strict_evidence')) finance_narrative = _build_finance_narrative_payload(modules.get('finance_strict_evidence'))
timing_narrative = _build_timing_narrative_payload(modules) timing_narrative = _build_timing_narrative_payload(modules)
health_narrative = _build_health_narrative_payload(modules, chart)
vimsopaka_semantic_summary = _build_vimsopaka_semantic_summary(modules.get('vimsopaka')) vimsopaka_semantic_summary = _build_vimsopaka_semantic_summary(modules.get('vimsopaka'))
vedastro_overview = _build_vedastro_overview_payload(modules) vedastro_overview = _build_vedastro_overview_payload(modules)
vedastro_official_full_snapshot = _build_vedastro_official_full_snapshot_payload(modules) vedastro_official_full_snapshot = _build_vedastro_official_full_snapshot_payload(modules)
@@ -11092,6 +11202,7 @@ def _build_ai_prompt_pack(report):
'relationship_narrative': relationship_narrative, 'relationship_narrative': relationship_narrative,
'finance_narrative': finance_narrative, 'finance_narrative': finance_narrative,
'timing_narrative': timing_narrative, 'timing_narrative': timing_narrative,
'health_narrative': health_narrative,
'vimsopaka_semantic_summary': vimsopaka_semantic_summary, 'vimsopaka_semantic_summary': vimsopaka_semantic_summary,
} }
+1 -2
View File
@@ -195,7 +195,6 @@ class UnifiedConsultationOrchestrator:
"Shadbala", "Shadbala",
"Transit", "Transit",
"Functional Benefic/Malefic", "Functional Benefic/Malefic",
"non-medical boundary",
], ],
display_label="health", display_label="health",
), ),
@@ -281,7 +280,7 @@ class UnifiedConsultationOrchestrator:
"marriage": ["core_partner_profile", "temperament_and_compatibility", "timing_windows", "red_flags", "verification_questions"], "marriage": ["core_partner_profile", "temperament_and_compatibility", "timing_windows", "red_flags", "verification_questions"],
"career": ["career_direction", "role_and_responsibility", "income_and_recognition", "opportunity_windows", "risks_and_verification_questions"], "career": ["career_direction", "role_and_responsibility", "income_and_recognition", "opportunity_windows", "risks_and_verification_questions"],
"wealth": ["wealth_path", "income_structure", "asset_and_cashflow_pattern", "opportunity_windows", "verification_questions"], "wealth": ["wealth_path", "income_structure", "asset_and_cashflow_pattern", "opportunity_windows", "verification_questions"],
"health": ["non_medical_pattern", "pressure_factors", "protective_factors", "verification_questions"], "health": ["pressure_windows", "event_risk_windows", "recovery_support_windows", "non_medical_boundary", "verification_questions"],
"migration": ["relocation_pattern", "foreign_link", "candidate_windows", "verification_questions"], "migration": ["relocation_pattern", "foreign_link", "candidate_windows", "verification_questions"],
"family": ["family_structure", "home_and_care", "children_boundary", "verification_questions"], "family": ["family_structure", "home_and_care", "children_boundary", "verification_questions"],
"education": ["study_vs_work_fit", "exam_and_degree_path", "candidate_windows", "verification_questions"], "education": ["study_vs_work_fit", "exam_and_degree_path", "candidate_windows", "verification_questions"],
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
from scripts.jyotish_api_server import JyotishAPIHandler
from scripts.jyotish_engine import _build_ai_prompt_pack, _build_health_narrative_payload
def _varga(asc, placements):
return {
"ascendant": {"sign": asc, "sign_index": 0},
"planets": {planet: {"sign": sign} for planet, sign in placements.items()},
}
def _report():
modules = {
"chart": {"ascendant": {"sign": "Leo", "sign_idx": 4}, "planets": {}, "birth_info": {}},
"varga_full": {
"D6_Shashthamsa": _varga("Aries", {"Saturn": "Taurus", "Jupiter": "Gemini"}),
"D8_Ashtamsa": _varga("Taurus", {"Saturn": "Cancer", "Jupiter": "Leo"}),
"D30_Trimsamsa": _varga("Gemini", {"Saturn": "Virgo", "Jupiter": "Libra"}),
},
"dasha": {"current_dasha": {"mahadasha": "Saturn", "antardasha": "Jupiter"}},
"shadbala": {"planets": {
"Saturn": {"total_rupas": 6.2, "min_required": 5.0, "strength_level": "strong"},
"Jupiter": {"total_rupas": 4.1, "min_required": 5.0, "strength_level": "weak"},
"Venus": {"total_rupas": 9.9, "min_required": 5.0, "strength_level": "strong"},
}},
}
return {"chart": modules["chart"], "modules": modules, "birth_info": {}}
def test_health_narrative_uses_only_health_vargas_lords_dasha_and_related_shadbala():
report = _report()
payload = _build_health_narrative_payload(report["modules"], report["chart"])
assert set(payload) == {"headline", "strengths", "risks", "boundaries"}
joined = " ".join([payload["headline"], *payload["strengths"], *payload["risks"]])
for layer in ("D6", "D8", "D30", "Saturn", "Jupiter"):
assert layer in joined
assert "Venus" not in joined
assert "非医疗" in " ".join(payload["boundaries"])
prompt = _build_ai_prompt_pack(report)
assert prompt["evidence_snapshot"]["health_narrative"]["headline"] == payload["headline"]
def test_api_prompt_pack_keeps_compact_health_narrative():
report = _report()
chart = {**report["chart"], "modules": report["modules"], "birth": {}, "dasha": {}}
prompt = JyotishAPIHandler.__new__(JyotishAPIHandler)._build_chart_prompt_pack(chart)
seed = prompt["evidence_snapshot"]["health_narrative"]
assert seed["status"] == "parameter_sensitive"
assert "D6/D8/D30" in seed["headline"]
assert "非医疗" in " ".join(seed["boundaries"])
@@ -87,7 +87,7 @@ def test_unified_consultation_orchestrator_routes_health_questions() -> None:
assert route["question_type"] == "health" assert route["question_type"] == "health"
assert route["primary_theme"] == "health" assert route["primary_theme"] == "health"
assert "D6" in route["focus_techniques"] assert "D6" in route["focus_techniques"]
assert "non-medical boundary" in route["focus_techniques"] assert "Functional Benefic/Malefic" in route["focus_techniques"]
def test_unified_consultation_orchestrator_routes_extended_product_domains() -> None: def test_unified_consultation_orchestrator_routes_extended_product_domains() -> None:
@@ -519,3 +519,19 @@ def test_machine_evidence_packet_carries_vedastro_raw_archive_manifest() -> None
section = packet["sections"]["vedastro_official_raw_archive_manifest"] section = packet["sections"]["vedastro_official_raw_archive_manifest"]
assert section["status"] == "used" assert section["status"] == "used"
assert section["source_path"] == "vedastro_gateway.archives" assert section["source_path"] == "vedastro_gateway.archives"
def test_health_route_uses_three_window_contract_and_required_layers() -> None:
orchestrator = UnifiedConsultationOrchestrator()
route = orchestrator.resolve_route("健康压力与恢复节奏", ["health"])
assert route["focus_techniques"] == [
"D6", "D8", "D30", "Dasha", "Narayana Dasha", "Shadbala",
"Transit", "Functional Benefic/Malefic",
]
assert orchestrator.route_profile_contract("health")["sections"] == [
"pressure_windows", "event_risk_windows", "recovery_support_windows",
"non_medical_boundary", "verification_questions",
]
required = orchestrator.evidence_packet_required_sections("health")
for layer in ("D6", "D8", "D30", "narayana_dasha", "functional_benefic_malefic", "transit", "non_medical_boundary"):
assert layer in required