diff --git a/frontend/tests/personal-report-generation.test.ts b/frontend/tests/personal-report-generation.test.ts index 3b3f94cd..bb1ae94b 100644 --- a/frontend/tests/personal-report-generation.test.ts +++ b/frontend/tests/personal-report-generation.test.ts @@ -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({ diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 4733f753..c9dfdbff 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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'), diff --git a/scripts/unified_consultation_orchestrator.py b/scripts/unified_consultation_orchestrator.py index 4e353f38..27b945b1 100644 --- a/scripts/unified_consultation_orchestrator.py +++ b/scripts/unified_consultation_orchestrator.py @@ -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)], diff --git a/tests/test_consultation_consumer_context.py b/tests/test_consultation_consumer_context.py index b7c37e93..9af114ef 100644 --- a/tests/test_consultation_consumer_context.py +++ b/tests/test_consultation_consumer_context.py @@ -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(),