feat: add health consultation report pipeline
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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": []}
|
||||||
|
|||||||
@@ -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': [
|
||||||
|
|||||||
@@ -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} Shadbala:total={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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user