fix(health): complete consultation evidence contract

This commit is contained in:
Jesse_Chen
2026-09-04 03:20:29 +08:00
parent cf6c8a9c36
commit 837bd0820d
4 changed files with 185 additions and 4 deletions
@@ -754,6 +754,90 @@ test("generatePersonalReport returns a ready document that passes the server par
assert.equal(reparsed.ok, true);
});
test("generatePersonalReport keeps health writer output non-medical and parameter-sensitive", async () => {
const base = bundleV2Fixture();
const d1 = base.charts.find((chart) => chart.id === "D1");
const chartTemplate = base.charts.find((chart) => chart.id === "D10");
assert.ok(d1);
assert.ok(chartTemplate);
const techniques = ["D1", "D6", "D8", "D30", "Vimshottari"] as const;
const techniqueIds = techniques.map((name) => `ev-tech-${name.toLowerCase()}`);
const bundle = finalizeReportEvidenceBundleV2({
...withoutBundleHash(base),
requestedThemes: ["health_pressure"],
charts: [
d1,
...(["D6", "D8", "D30"] as const).map((id) => ({
...chartTemplate,
id,
title: `${id} health divisional chart`,
})),
],
claimCards: [{
id: "ev-claim-health-pressure",
theme: "health_pressure",
section: "健康压力(非医疗)",
conclusion: "D6/D8/D30 与 Vimshottari 只支持非医疗的压力与恢复节奏观察。",
supportingFacts: techniqueIds.map((evidenceRef, index) => ({
id: `ev-fact-health-${index + 1}`,
label: evidenceRef,
value: "该层已执行并仅用于非医疗叙事。",
evidenceRef,
status: "verified" as const,
})),
counterFacts: [],
executedTechniqueRefs: techniqueIds,
assertionLevel: "parameter_sensitive",
timingBoundary: null,
verificationQuestions: [],
}],
blockedSections: [],
executionLedger: techniqueIds.map((id, index) => ({
id,
technique: techniques[index],
status: "verified" as const,
executed: true,
note: "健康主题最低证据已执行",
})),
evidenceRefs: techniqueIds.map((id, index) => ({
id,
technique: techniques[index],
status: "verified" as const,
})),
});
const calls = { count: 0 };
const result = await generatePersonalReport({
reportId: "22222222-2222-4222-8222-222222222222",
bundle,
depth: "standard",
agent: fakeAgent(agentOutput({
thematicNarrative: [{
id: "theme-health_pressure",
theme: "health_pressure",
title: "健康压力(非医疗)",
narrative: "本段仅描述压力负荷与恢复节奏,不构成医疗诊断或治疗建议。",
actions: ["如有持续不适,请咨询合格医疗专业人员。"],
caveats: ["非医疗边界:占星内容不能替代专业诊疗。"],
claimStatus: "parameter_sensitive",
evidenceRefs: techniqueIds,
}],
}), calls),
now: () => new Date("2026-09-03T00:00:00.000Z"),
});
assert.equal(calls.count, 1);
assert.equal(bundle.claimCards[0].assertionLevel, "parameter_sensitive");
assert.equal(result.status, "ready");
if (result.status !== "ready") return;
const parsed = safeParseServerReportDocument(result.document);
assert.equal(parsed.ok, true);
if (!parsed.ok) return;
const health = parsed.document.thematicNarrative.find((section) => section.id === "theme-health_pressure");
assert.ok(health);
assert.match(`${health.narrative} ${health.caveats.join(" ")}`, /非医疗|不构成医疗/);
assert.equal(health.claimStatus, "parameter_sensitive");
});
test("generatePersonalReport fails with report_guard_rejected on guard rejection", async () => {
const bundle = bundleV2Fixture();
const result = await generatePersonalReport({
+1 -1
View File
@@ -1847,7 +1847,7 @@ _ROUTE_REQUIRED_LAYERS = {
'career': ('D1', 'D10', 'A10', 'dasha_boundaries', 'narayana_dasha'),
'marriage': ('D1', 'D9', 'UL', 'A7', 'dasha_boundaries'),
'wealth': ('D1', 'D2', 'D11', 'ashtakavarga', 'dasha_boundaries'),
'health': ('D1', 'D6', 'D8', 'D9', 'dasha_boundaries'),
'health': ('D1', 'D6', 'D8', 'D30', 'dasha_boundaries'),
'education': ('D1', 'D9', 'D24', 'dasha_boundaries'),
'migration': ('D1', 'D4', 'D12', 'dasha_boundaries'),
'family': ('D1', 'D7', 'D9', 'D12', 'dasha_boundaries'),
+20 -3
View File
@@ -951,6 +951,11 @@ class UnifiedConsultationOrchestrator:
or official.get("raw")
)
official_state = self._vedastro_cloud_state(vedastro_official)
route_name = str(
(route_packet or {}).get("question_type")
or (route_packet or {}).get("primary_theme")
or "general"
)
raw_response_section = (
self._section(raw_response, "vedastro_official.raw_response")
if official_state == "official_verified"
@@ -1012,6 +1017,20 @@ class UnifiedConsultationOrchestrator:
"sudarshana": self._section(sudarshana_packet, "chart.modules.sudarshana"),
"sahams": self._section(sahams_packet, "chart.modules.sahams"),
}
if route_name == "health":
transits = modules.get("transits") if isinstance(modules.get("transits"), dict) else {}
sections["transit"] = {
"status": (
"used"
if str(transits.get("status") or "").lower() in {"executed", "used", "ready"}
else "blocked"
),
"source_path": "modules.transits",
}
sections["non_medical_boundary"] = {
"status": "used",
"source_path": "route_profile_contract.health.non_medical_boundary",
}
for division in FORMAL_DIVISIONS:
if division == 1:
continue
@@ -1033,9 +1052,7 @@ class UnifiedConsultationOrchestrator:
return {
"status": "complete" if not missing else "partial",
"route": dict(route_packet),
"required_sections": self.evidence_packet_required_sections(
str((route_packet or {}).get("question_type") or (route_packet or {}).get("primary_theme") or "general")
),
"required_sections": self.evidence_packet_required_sections(route_name),
"sections": sections,
"functional_benefic_malefic": functional_layer,
"signals": [item for item in signals if isinstance(item, dict)],
@@ -602,6 +602,7 @@ def _sections(**overrides: str) -> dict:
'D7': 'used',
'D8': 'used',
'D9': 'used',
'D30': 'used',
'D10': 'used',
'D11': 'used',
'D12': 'used',
@@ -724,6 +725,85 @@ def test_wealth_route_requires_the_hora_chart() -> None:
assert context['core_status'] == 'degraded'
def test_health_route_requires_d30_instead_of_d9() -> None:
context = _build_consumer_context(
question='请分析健康压力窗口',
route_packet={'question_type': 'health', 'primary_theme': 'health'},
chart={'success': True},
rectification=_confirmed_birth_time(),
machine_evidence_packet=_sections(D30='missing'),
vedastro_official={'status': 'blocked'},
)
assert context['missing_route_layers'] == ['D30']
assert context['core_status'] == 'degraded'
def test_health_workflow_keeps_three_windows_and_blocked_transit_visible(monkeypatch) -> None:
handler = _handler()
chart = _base_chart()
chart['modules']['transits'] = {
'status': 'blocked',
'reason': 'fixture_no_transit_runtime',
'boundary': 'observation windows, not guaranteed events',
}
monkeypatch.setattr(handler, '_compute_chart', lambda body: chart)
monkeypatch.setattr(handler, '_compute_rectification_gate', lambda body: {
'success': True,
'effective_accuracy': 'minute',
'lagna_boundary': {'is_sensitive': False},
'summary': {'recommended_events': [], 'warned': [], 'disabled': []},
})
monkeypatch.setattr(handler, '_compute_thematic_report', lambda body: {
'success': True,
'endpoint': 'thematic_report',
'themes': {},
})
monkeypatch.setattr(handler, '_compute_vedastro_gateway_run', lambda body: {
'scope': 'vedastro_gateway_run',
'status': 'official_blocked',
'official_closure_state': 'official_blocked',
'official_closure_reason': 'fixture_no_network',
})
result = handler._compute_consultation_workflow({
'entry_mode': 'direct_chart',
'question': '请看健康压力、风险和恢复窗口',
'theme': ['health'],
'year': _BIRTH['year'],
'month': _BIRTH['month'],
'day': _BIRTH['day'],
'hour': _BIRTH['hour'],
'minute': _BIRTH['minute'],
'lat': 39.9042,
'lon': 116.4074,
'tz': 8,
'current_date': _REFERENCE_DATE,
})
assert result['routing']['question_type'] == 'health'
assert result['unified_orchestrator']['route_profile_contract']['sections'] == [
'pressure_windows',
'event_risk_windows',
'recovery_support_windows',
'non_medical_boundary',
'verification_questions',
]
sections = result['machine_evidence_packet']['sections']
assert all(sections[layer]['status'] == 'used' for layer in ('D6', 'D8', 'D30'))
assert sections['transit']['status'] == 'blocked'
assert sections['non_medical_boundary']['status'] == 'used'
assert all(layer in result['consumer_context']['available_layers'] for layer in ('D6', 'D8', 'D30'))
assert 'health_non_medical_boundary' in result['consumer_context']['domain_boundaries']
audit = {
row['technique']: row['status']
for row in result['consumer_context']['technique_audit_table']
if isinstance(row, dict)
}
assert audit['Transits / Sade Sati'] == 'blocked'
def test_domain_boundary_follows_the_route_not_the_wording() -> None:
context = _build_consumer_context(
question=_relationship_shaped_question(),