From 8466e3a71cdec7ffce1951f1f8299df487dd3c80 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Mon, 29 Jun 2026 03:54:45 +0800 Subject: [PATCH] Land relationship narrative and dynamic hook workflow --- jyotish-app/export.js | 43 +++ jyotish-app/main.js | 129 ++++++- scripts/jyotish_api_server.py | 126 +++++++ scripts/jyotish_engine.py | 175 +++++++++- scripts/life_stage_hook.py | 156 +++++++++ tests/test_cli_smoke.py | 14 + .../test_mcp_strict_workflow_relationship.py | 316 +++++++++++++++++- 7 files changed, 946 insertions(+), 13 deletions(-) create mode 100644 scripts/life_stage_hook.py diff --git a/jyotish-app/export.js b/jyotish-app/export.js index 565d44f5..e033efbe 100644 --- a/jyotish-app/export.js +++ b/jyotish-app/export.js @@ -394,6 +394,7 @@ function _buildHTMLReport(chartData, extras) { const validation = extras.validation || {}; const audit = extras.audit || {}; const workflows = extras.workflows || chartData._client_workflows || {}; + const relationshipNarrative = extras.relationship_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.relationship_narrative || chartData?.relationship_narrative || null; const generatedAt = new Date().toLocaleString('zh-CN'); return ` @@ -463,6 +464,12 @@ function _buildHTMLReport(chartData, extras) { .relationship-list { background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 9px; break-inside: avoid; } .relationship-list strong { color: var(--accent); } .relationship-list ul { margin: 6px 0 0; padding-left: 18px; color: var(--muted); } + .relationship-caution { + background: #fff8ed; border: 1px solid #f3d19c; border-left: 4px solid #c67a00; + border-radius: 6px; padding: 10px 12px; margin: 10px 0 12px; break-inside: avoid; + } + .relationship-caution strong { display: block; color: #7a4b00; margin-bottom: 4px; } + .relationship-caution span { display: block; color: #7a4b00; font-size: 12px; } .relationship-boundary { background: #fff8ed; border: 1px solid #ead7b8; border-radius: 6px; padding: 9px; margin-top: 8px; } .relationship-boundary span { display: block; color: var(--muted); font-size: 12px; } .calibration-status { @@ -582,6 +589,8 @@ function _buildHTMLReport(chartData, extras) { ` : '

当前导出未附带 Dasha 时间线。

'} + ${_relationshipStrictNarrativeSection(relationshipNarrative)} + ${_workflowReportSection(workflows)} ${_calibrationStatusSection(DASHA_SHADBALA_EXPORT_CALIBRATION_STATUS)} @@ -609,6 +618,40 @@ function _buildHTMLReport(chartData, extras) { `; } +function _relationshipStrictNarrativeSection(narrative) { + if (!narrative || typeof narrative !== 'object') return ''; + const strengths = Array.isArray(narrative.strengths) ? narrative.strengths : []; + const risks = Array.isArray(narrative.risks) ? narrative.risks : []; + const boundaries = Array.isArray(narrative.boundaries) ? narrative.boundaries : []; + const markdown = typeof narrative.markdown === 'string' ? narrative.markdown : ''; + const compactMarkdown = markdown + .replace(/^###\s+/gm, '') + .replace(/^\-\s+/gm, '') + .replace(/\n+/g, ' ') + .trim(); + const showCaution = ( + risks.some(item => String(item).includes('不能误读成接近结婚')) + || boundaries.some(item => String(item).includes('不等于法律婚姻')) + ); + return `
+

婚恋严格裁决

+
+
+ ${_h(narrative.headline || '婚恋 strict narrative 已接入导出主链。')} + 本段直接消费 relationship strict evidence,把 D9、UL、dual dasha 与 synastry taxonomy 的边界写进用户可见正文。 +
+
strict narrative
+
+ ${showCaution ? `
防误判提示当前公开化/关系可见度候选不能被误读成接近法律婚姻;若 core marriage promise、dual dasha 或 external timing 仍未收敛,必须继续降置信度并保持 context-only 解释。
` : ''} +
+ ${_relationshipReportList('支持证据', strengths)} + ${_relationshipReportList('需要观察', risks)} + ${_relationshipReportList('边界条件', boundaries)} +
+ ${compactMarkdown ? `
${_h(compactMarkdown)}
` : ''} +
`; +} + function _calibrationStatusSection(status) { return `

高级技法校准状态

diff --git a/jyotish-app/main.js b/jyotish-app/main.js index 9fccfc3a..7b29fbcf 100644 --- a/jyotish-app/main.js +++ b/jyotish-app/main.js @@ -1788,6 +1788,7 @@ function renderTrustCenterPanel() { const pwa = getPWAStatus(); const terminology = getTerminologyModeOption(); const runtime = getRuntimeHealthStatus(); + const vedastro = getVedAstroStatus(); const statusMessage = getTrustCenterStatusMessage(pwa, runtime); return `
@@ -1802,6 +1803,7 @@ function renderTrustCenterPanel() { ${renderTrustStatus('术语', terminology.shortLabel, terminology.note)} ${renderTrustStatus('API', '127.0.0.1:5200', '本地 API 服务;不需要外部账号')} ${renderTrustStatus('运行体检', runtime.label, runtime.note)} + ${renderVedAstroStatus(vedastro)} ${renderRuntimeHealthPanel(runtime)} ${renderValidationTransparencyPanel()} @@ -2220,6 +2222,54 @@ function renderTrustStatus(label, value, note) { `; } +function getVedAstroStatus() { + const status = window.__jyotishVedAstroStatus; + if (!status) { + return { + label: '未检查', + note: '运行健康检查后读取 /api/vedastro/status;需要 VEDASTRO_API_ENDPOINT 才会连接外部雷达。', + tone: 'pending', + }; + } + if (status.status === 'checking') { + return { + label: '检查中', + note: '正在读取 /api/vedastro/status。', + tone: 'pending', + }; + } + if (status.status === 'live_ready') { + return { + label: 'live-ready', + note: `${status.endpoint_host || 'configured'} · VEDASTRO_ENABLE_NETWORK 已启用;外部证据仍只进 secondary context。`, + tone: 'ok', + }; + } + if (status.status === 'network_execution_disabled') { + return { + label: '已配置/禁用网络', + note: `${status.endpoint_host || 'endpoint configured'} · 需显式启用 VEDASTRO_ENABLE_NETWORK。`, + tone: 'warn', + }; + } + if (status.status === 'service_endpoint_not_configured') { + return { + label: '未配置', + note: '未设置 VEDASTRO_API_ENDPOINT;本地引擎继续可用,VedAstro 外部雷达保持 blocked。', + tone: 'pending', + }; + } + return { + label: '需处理', + note: status.error || status.boundary || 'VedAstro 外部雷达状态不可用。', + tone: 'warn', + }; +} + +function renderVedAstroStatus(status = getVedAstroStatus()) { + return renderTrustStatus('VedAstro 外部雷达', status.label, status.note); +} + function getRuntimeHealthStatus() { const health = window.__jyotishRuntimeHealth; if (!health) { @@ -2385,13 +2435,26 @@ async function runTrustCenterHealthCheck() { ['PWA 安装壳', getPWAStatus().label, getPWAStatus().note], ['本地 API 服务', '检查中', '正在请求 /api/health'], ['Technique catalog', '等待中', 'API 在线后检查能力目录'], + ['VedAstro 外部雷达', '检查中', '正在请求 /api/vedastro/status'], ], }; + window.__jyotishVedAstroStatus = { status: 'checking' }; if (status) status.textContent = '正在运行健康检查...'; renderAll(); try { const api = await window.JyotishAPI.getAPIHealth(); const audit = await window.JyotishAPI.getCapabilityAudit(); + let vedastroStatus; + try { + vedastroStatus = await window.JyotishAPI.getVedAstroStatus(); + } catch (error) { + vedastroStatus = { + status: 'unavailable', + error: error?.message || '/api/vedastro/status 不可用', + }; + } + window.__jyotishVedAstroStatus = vedastroStatus; + const vedastro = getVedAstroStatus(); const surfaces = audit?.surfaces || {}; const registry = audit?.registry || {}; window.__jyotishRuntimeHealth = { @@ -2406,11 +2469,17 @@ async function runTrustCenterHealthCheck() { ['Swiss Ephemeris', api.swisseph_available ? (api.swisseph_version || 'available') : 'unavailable', '当前本地 API 的星历运行时状态'], ['Ayanamsa', api.ayanamsa_default || 'lahiri', '当前健康检查声明的默认 sidereal 基准'], ['Technique catalog', `${registry.technique_count || 0} techniques`, `${surfaces.api_endpoint_count || 0} API endpoints 可被前端发现`], + ['VedAstro 外部雷达', vedastro.label, vedastro.note], ['Desktop path', 'PWA now', 'Pake shell 可快速包 URL;Tauri sidecar 等 API 生命周期和签名策略确定后再落地。'], ], }; if (status) status.textContent = '健康检查通过:本地 API 服务、能力目录和 PWA 安装壳状态已记录。'; } catch (error) { + window.__jyotishVedAstroStatus = { + status: 'unavailable', + error: '本地 API 未连接时无法读取 /api/vedastro/status。', + }; + const vedastro = getVedAstroStatus(); window.__jyotishRuntimeHealth = { status: 'warn', checkedAt, @@ -2418,6 +2487,7 @@ async function runTrustCenterHealthCheck() { details: [ ['PWA 安装壳', getPWAStatus().label, getPWAStatus().note], ['本地 API 服务', '未连接', error?.message || '请确认本地 API 服务正在 127.0.0.1:5200 运行'], + ['VedAstro 外部雷达', vedastro.label, vedastro.note], ['启动路径', '普通用户启动路径', '按 README 先启动网页服务,再启动本地 API 服务。'], ['Packaging preflight', '待检查', '运行 python3 scripts/desktop_packaging_preflight.py 获取桌面包装前置结果。'], ], @@ -5364,18 +5434,40 @@ function buildRelationshipReportTemplate(data = {}, deep = null) { const hasDeep = Boolean(deep?.selfD9 || deep?.partnerD9 || kuja || dasha); const spouseStatus = deep?.spouseStatus || null; const ulDkTiming = deep?.ulDkTiming || null; + const strictNarrative = data.ai_prompt_pack?.evidence_snapshot?.relationship_narrative || data.relationship_narrative || deep?.relationshipNarrative || null; + const strictMarkdown = typeof strictNarrative?.markdown === 'string' ? strictNarrative.markdown : ''; + const strictSecondaryContext = strictMarkdown.includes('public_formalization_candidate') + || (Array.isArray(strictNarrative?.strengths) && strictNarrative.strengths.some(item => String(item).includes('public_formalization_candidate'))) + || (Array.isArray(strictNarrative?.boundaries) && strictNarrative.boundaries.some(item => String(item).includes('public_formalization_candidate'))); + const hasPublicFormalizationCandidate = strictSecondaryContext; + const hasConflictWarning = ( + Array.isArray(strictNarrative?.risks) + && strictNarrative.risks.some(item => String(item).includes('不能误读成接近结婚')) + ) || ( + Array.isArray(strictNarrative?.boundaries) + && strictNarrative.boundaries.some(item => String(item).includes('不等于法律婚姻')) + ); const approvedFlag = data.is_match_approved ?? data.is_approved; - const status = percentage >= 78 && approvedFlag !== false - ? 'strong' - : percentage >= 58 && approvedFlag !== false - ? 'workable' - : 'needs_context'; - const statusLabel = { + const conservativePublicFormalizationStatus = hasPublicFormalizationCandidate && hasConflictWarning + ? 'needs_context' + : null; + const status = hasPublicFormalizationCandidate && hasConflictWarning ? 'needs_context' + : conservativePublicFormalizationStatus + || (percentage >= 78 && approvedFlag !== false + ? 'strong' + : percentage >= 58 && approvedFlag !== false + ? 'workable' + : 'needs_context'); + const statusLabel = hasPublicFormalizationCandidate && hasConflictWarning + ? '公开化候选,不等于婚姻逼近' + : { strong: '高兼容,仍需完整复核', workable: '可推进观察', needs_context: '需谨慎综合判断', }[status]; - const headline = status === 'strong' + const headline = hasPublicFormalizationCandidate && hasConflictWarning + ? '公开化候选浮现,但婚姻承诺与时机仍需保守复核。' + : status === 'strong' ? 'Ashtakoot 基础匹配较强,适合进入 D9、Kuja 与现实互动复核。' : status === 'workable' ? '基础匹配有可用支撑,但关键风险位需要逐项确认。' @@ -5385,6 +5477,10 @@ function buildRelationshipReportTemplate(data = {}, deep = null) { if (dasha?.level === 'strong' || dasha?.level === 'supportive') strengths.push(dasha.note || dasha.label); collectULDKTimingStrengths(ulDkTiming).forEach(item => strengths.push(item)); collectSpouseStatusStrengths(spouseStatus).forEach(item => strengths.push(item)); + if (Array.isArray(strictNarrative?.strengths)) strictNarrative.strengths.forEach(item => strengths.push(item)); + if (hasPublicFormalizationCandidate) { + strengths.push('public_formalization_candidate 说明当前更偏向公开化/关系可见度候选,而不是法律婚姻本身。'); + } if (!strengths.length) strengths.push('尚未出现足够稳定的强项,建议先补完整出生盘与 D9 复核。'); const risks = weakKutas.map(item => `${item.name} ${item.score}/${item.max}:需重点观察${relationshipKutaMeaning(item.name)}。`); if (kuja?.verdict === 'hard' || kuja?.verdict === 'watch') risks.push(kuja.note || kuja.label); @@ -5392,6 +5488,10 @@ function buildRelationshipReportTemplate(data = {}, deep = null) { if ((data.exceptions || []).length) risks.push(`存在缓解条件:${(data.exceptions || []).join(';')}`); collectULDKTimingRisks(ulDkTiming).forEach(item => risks.push(item)); collectSpouseStatusRisks(spouseStatus).forEach(item => risks.push(item)); + if (Array.isArray(strictNarrative?.risks)) strictNarrative.risks.forEach(item => risks.push(item)); + if (hasPublicFormalizationCandidate) { + risks.push('当前即便存在合盘支持与公开化候选,也不能误读成接近结婚;若 weak core promise、dual dasha 或 external timing 未收敛,仍应保持保守。'); + } if (!hasDeep) risks.push('当前只完成基础 Ashtakoot,缺少 D9、Kuja Dosha 与 Dasha 同步证据。'); if (!risks.length) risks.push('未见明显红旗,但仍需结合双方完整星盘与现实互动。'); const evidence = [ @@ -5405,7 +5505,7 @@ function buildRelationshipReportTemplate(data = {}, deep = null) { return { status, statusLabel, - headline, + headline: strictNarrative?.headline || headline, percentage, evidence, strongestKutas, @@ -5416,11 +5516,17 @@ function buildRelationshipReportTemplate(data = {}, deep = null) { '先确认双方出生时间精度,再复核 D9 与 7宫/7主。', '把低分 Kuta 对应到现实互动议题,不用单一分数替代沟通。', '若 Kuja 或 Dasha 出现压力,优先安排边界、节奏和冲突处理观察期。', + ...(hasPublicFormalizationCandidate ? [ + '若当前更偏向 public_formalization_candidate,请把它理解为关系公开化候选,而不是婚姻逼近。', + '先不要把高 Ashtakoot 分数翻译成婚姻逼近,应先复核 promise、dual dasha 与 external timing。', + ] : []), ], boundaries: [ '合盘报告只提供传统 Jyotish 证据,不替代个人选择。', '总分通过不等于关系必然稳定;总分偏低也不等于关系不可经营。', - ], + ...(hasPublicFormalizationCandidate ? ['public_formalization_candidate 只表示公开化候选,不得越权抬升 legal_marriage,也不能误读成接近结婚。'] : []), + ...((Array.isArray(strictNarrative?.boundaries) ? strictNarrative.boundaries : [])), + ].slice(0, 5), }; } @@ -7226,12 +7332,17 @@ function buildExportExtras(sourceChart) { const audit = computeAudit(planets, ascendant, av, validation); const actionableContext = computeActionableContext(planets, ascendant); const jaimini = _buildJaiminiModule(karaka, charaDasha, karakamsha); + const relationshipNarrative = sourceChart?.ai_prompt_pack?.evidence_snapshot?.relationship_narrative + || sourceChart?.relationship_narrative + || sourceChart?.modules?.relationship_strict_evidence?.user_narrative + || null; return { dasha, yogas, vargas, aspects, jaimini, nakshatraAdvanced, argala, tajika, shadbala: sb, ashtakavarga: _buildAVModule(av), panchanga, validation, audit, actionableContext, + relationship_narrative: relationshipNarrative, provenance: sourceChart._client_audit?.provenance, workflows: buildWorkflowExportExtras(sourceChart), }; diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index ee925234..c9893de2 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -183,6 +183,50 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if origin in allowed: self.send_header('Access-Control-Allow-Origin', origin) + def _vedastro_status(self): + adapter = _load_local_module('vedastro_service_adapter') + endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip() + network_flag = os.environ.get('VEDASTRO_ENABLE_NETWORK', '').strip().lower() + network_enabled = network_flag in {'1', 'true', 'yes'} + parsed = urlparse(endpoint) if endpoint else None + configured = bool(endpoint) + if not configured: + status = 'service_endpoint_not_configured' + elif not network_enabled: + status = 'network_execution_disabled' + else: + status = 'live_ready' + artifact_dir = getattr(adapter, 'ARTIFACT_DIR', None) + latest_artifact = None + if artifact_dir and os.path.isdir(artifact_dir): + artifacts = sorted( + (os.path.join(artifact_dir, name) for name in os.listdir(artifact_dir) if name.endswith('.json')), + key=lambda path: os.path.getmtime(path), + reverse=True, + ) + if artifacts: + latest_artifact = os.path.relpath(artifacts[0], REPO_ROOT) + return { + 'adapter': 'vedastro_service_adapter', + 'backend': 'vedastro_service_adapter_candidate', + 'status': status, + 'configured': configured, + 'network_enabled': network_enabled, + 'endpoint_host': parsed.netloc if parsed else None, + 'required_env': { + 'endpoint': 'VEDASTRO_API_ENDPOINT', + 'network': 'VEDASTRO_ENABLE_NETWORK', + 'api_key_optional': 'VEDASTRO_API_KEY', + }, + 'live_profile': 'vedastro-live', + 'transport': 'http_json_service_boundary', + 'range_scan_role': adapter.VEDASTRO_CALCULATION_COVERAGE['range_scan_role'], + 'official_events_builder_methods': adapter.VEDASTRO_CALCULATION_COVERAGE['official_events_builder_methods'], + 'artifact_dir': 'scratch/local/vedastro_adapter', + 'latest_artifact': latest_artifact, + 'boundary': 'VedAstro is optional external timing evidence; local Jyotish gates remain authoritative.', + } + def do_OPTIONS(self): self._json({}) @@ -213,6 +257,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): self._json(self._capability_audit()) elif path == '/api/technique_catalog': self._json(self._technique_catalog()) + elif path == '/api/vedastro/status': + self._json(self._vedastro_status()) elif path == '/api/real_case_revalidation': self._json(self._real_case_revalidation()) else: @@ -484,6 +530,65 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return html[:body_close.start()] + summary + html[body_close.start():] return html + summary + def _inject_relationship_narrative_summary(self, html, narrative): + if not isinstance(narrative, dict): + return html + headline = narrative.get('headline') + if not headline: + return html + + def _escape(value): + return html_lib.escape(str(value or '')) + + def _list_html(items): + if not isinstance(items, list) or not items: + return '
  • 暂无补充。
  • ' + return ''.join(f'
  • {_escape(item)}
  • ' for item in items[:6]) + + risks = narrative.get("risks") + boundaries = narrative.get("boundaries") + caution_block = '' + if ( + isinstance(risks, list) + and any('不能误读成接近结婚' in str(item) for item in risks) + ) or ( + isinstance(boundaries, list) + and any('不等于法律婚姻' in str(item) for item in boundaries) + ): + caution_block = ( + '
    ' + 'Caution' + '' + '当前公开化/关系可见度候选不能被误读成接近法律婚姻;若 core marriage promise、dual dasha 或 external timing 仍未收敛,' + '必须继续降置信度并保持 context-only 解释。' + '' + '
    ' + ) + + summary = ( + '
    ' + '

    Relationship Strict Narrative

    ' + f'

    {_escape(headline)}

    ' + f'{caution_block}' + '
    ' + '
    Strengths
      ' + f'{_list_html(narrative.get("strengths"))}
    ' + '
    Risks
      ' + f'{_list_html(narrative.get("risks"))}
    ' + '
    Boundaries
      ' + f'{_list_html(narrative.get("boundaries"))}
    ' + '
    ' + '
    ' + ) + body_close = re.search(r'', html, re.IGNORECASE) + if body_close: + return html[:body_close.start()] + summary + html[body_close.start():] + return html + summary + def _artifact_base64(self, path): size = os.path.getsize(path) if size > MAX_REPORT_BASE64_BYTES: @@ -552,6 +657,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): html, body.get('functional_benefic_malefic'), ) + html = self._inject_relationship_narrative_summary( + html, + body.get('relationship_narrative'), + ) fmt = body.get('format', 'html') if fmt not in {'html', 'pdf'}: raise BadRequest('format must be html or pdf') @@ -1050,6 +1159,23 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): source='full_reading.modules.vivah_saham', details=vivah.get('vivah_saham') if isinstance(vivah.get('vivah_saham'), dict) else vivah, )) + strict_relationship = full_modules.get('relationship_strict_evidence') if isinstance(full_modules, dict) else {} + user_narrative = strict_relationship.get('user_narrative') if isinstance(strict_relationship, dict) else {} + if isinstance(user_narrative, dict) and user_narrative.get('markdown'): + items.append(self._theme_evidence( + 'Relationship-strict-narrative', + 'Strict', + user_narrative.get('markdown'), + 'neutral', + 'strong', + source='full_reading.modules.relationship_strict_evidence.user_narrative', + details={ + 'headline': user_narrative.get('headline'), + 'strengths': user_narrative.get('strengths', [])[:3], + 'risks': user_narrative.get('risks', [])[:3], + 'boundaries': user_narrative.get('boundaries', [])[:3], + }, + )) return items def _derived_career_evidence(self, chart_data, context): diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 08a122f9..afe02343 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -42,9 +42,11 @@ import os import csv import math import sqlite3 +import importlib.util from datetime import datetime, timedelta from typing import Dict, List from tabulate import tabulate +from life_stage_hook import generate_life_stage_hooks from ayanamsa_utils import ( AYANAMSA_DISPLAY_NAMES, @@ -58,6 +60,7 @@ from ayanamsa_utils import ( # 路径常量 # ============================================================================ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(SCRIPT_DIR) HOME_DIR = os.path.expanduser('~') CLAW_DIR = os.path.join(HOME_DIR, 'WorkBuddy', 'Claw') DB_PATH = os.path.join(CLAW_DIR, 'vedic_astrology_validation.db') @@ -378,7 +381,7 @@ def _get_temporary_relationship(planet1, planet2, planets_data): """Calculate Temporary Friendship (Tatkalika Maitri).""" if not planets_data or planet1 not in planets_data or planet2 not in planets_data: return 'NEUTRAL' - + p1_sign = planets_data[planet1].get('sign') p2_sign = planets_data[planet2].get('sign') if not p1_sign or not p2_sign: @@ -387,7 +390,7 @@ def _get_temporary_relationship(planet1, planet2, planets_data): idx1 = SIGNS.index(p1_sign) idx2 = SIGNS.index(p2_sign) distance = (idx2 - idx1) % 12 + 1 - + # 2, 3, 4, 10, 11, 12 from planet are temporary friends if distance in [2, 3, 4, 10, 11, 12]: return 'FRIEND' @@ -884,6 +887,7 @@ def _build_technique_audit_table(functional_layer, oracle_progress, modules): ashtakavarga = modules.get('ashtakavarga') if isinstance(modules, dict) else {} vimsopaka = modules.get('vimsopaka') if isinstance(modules, dict) else {} dasa_convergence = modules.get('dasa_convergence') if isinstance(modules, dict) else {} + relationship = modules.get('relationship_strict_evidence') if isinstance(modules, dict) else {} rows = [ { @@ -939,9 +943,131 @@ def _build_technique_audit_table(functional_layer, oracle_progress, modules): f"Vimsopaka status={vimsopaka.get('status') if isinstance(vimsopaka, dict) else None}。" ), }) + + event_judgement = relationship.get('event_judgement') if isinstance(relationship, dict) else {} + secondary_context = event_judgement.get('secondary_context') if isinstance(event_judgement, dict) else [] + synastry_context = [item for item in (secondary_context or []) if isinstance(item, str) and item.startswith('synastry_')] + rows.append({ + 'technique': 'Relationship Synastry Taxonomy', + 'status': 'used' if synastry_context else 'blocked', + 'source': 'relationship strict workflow + synastry_relationship_bridge_v1', + 'note': ( + f"relationship secondary_context 中的 synastry 语义={synastry_context}; " + "compatibility support 表示匹配/延续性支持;" + "protective kuta support 表示防护型 Kuta 清洁度支持;" + "若 dual dasha / external timing / marriage convergence 冲突," + "不得把这些支持越权解释成 legal marriage 的高置信度落地。" + ), + }) return rows +def _build_relationship_narrative_payload(relationship_strict): + if not isinstance(relationship_strict, dict) or not relationship_strict: + return { + 'headline': '婚恋严格裁决证据尚未完成,当前不能生成高严谨关系叙事。', + 'strengths': [], + 'risks': ['缺少 relationship strict workflow 的核心证据,婚恋正文需降级。'], + 'boundaries': [ + '未完成 D1 + D9 + UL + dual dasha 交叉前,不得把单一关系信号写成高置信度婚姻结论。', + ], + 'markdown': ( + "### 婚恋严格裁决\n" + "- 当前缺少 relationship strict evidence,无法生成高严谨婚恋 narrative。\n" + "- 在 D1、D9、UL、Vimshottari 与 Narayana 未齐备前,应标记为 blocked 或降低置信度。" + ), + } + + event_judgement = relationship_strict.get('event_judgement') if isinstance(relationship_strict, dict) else {} + present = relationship_strict.get('present_evidence') if isinstance(relationship_strict, dict) else {} + missing = relationship_strict.get('missing_evidence') or [] + secondary_context = event_judgement.get('secondary_context') if isinstance(event_judgement, dict) else [] + secondary_context = secondary_context if isinstance(secondary_context, list) else [] + confidence_cap = relationship_strict.get('confidence_cap') or event_judgement.get('confidence_cap') or 'unknown' + dominant_label = event_judgement.get('dominant_label') if isinstance(event_judgement, dict) else None + synastry = present.get('synastry_relationship_support') if isinstance(present, dict) else {} + synastry_signals = synastry.get('signals') if isinstance(synastry, dict) else [] + synastry_signals = synastry_signals if isinstance(synastry_signals, list) else [] + + strengths = [] + risks = [] + boundaries = [] + + if dominant_label == 'legal_marriage': + strengths.append('本轮严格裁决已把婚恋主标签抬到 legal_marriage,但仍需尊重时机与现实承诺层。') + elif dominant_label == 'public_formalization': + strengths.append('当前更偏向 public_formalization,表示关系可见度/公开化支持强于法律婚姻落地。') + elif 'public_formalization_candidate' in secondary_context: + strengths.append('当前更接近 public_formalization_candidate,表示公开化/关系可见度候选正在增强,但仍未达到法律婚姻落地。') + + if 'jaimini_support' in secondary_context: + strengths.append('Jaimini 桥接已提供配偶征象支持,DK/UL 线索可用于补强婚恋叙事。') + if 'ul_support' in secondary_context: + strengths.append('Upapada Lagna 已进入严格证据,可作为关系承诺与婚姻叙事的辅助锚点。') + if 'synastry_support' in secondary_context: + strengths.append('合盘支持已进入婚恋主链,但它只说明关系兼容度有帮助,不能单独决定婚姻落地。') + if 'synastry_compatibility_support' in secondary_context: + strengths.append('protective kuta / compatibility support 说明部分 Kuta 与关系延续性维度较干净。') + if 'synastry_protective_kuta_support' in secondary_context: + strengths.append('protective kuta support 已被识别,可作为关系稳定性的次级支持语义。') + if 'synastry_exception_mitigated' in secondary_context: + strengths.append('存在 exception mitigation,说明部分 Dosha/不利匹配在传统规则里有缓解条件。') + + if confidence_cap in {'low', 'blocked'}: + risks.append('当前 confidence cap 偏低,dual dasha / external timing / marriage convergence 至少有一层存在冲突或不足。') + if 'public_formalization_candidate' in secondary_context: + risks.append('当前虽更接近 public_formalization_candidate,但在 timing conflict 未解除前,不能误读成接近结婚。') + if missing: + risks.append(f"仍缺少关键层:{', '.join(str(item) for item in missing[:4])}。") + if 'virodhargala_obstruction' in secondary_context: + risks.append('第七宫 Argala 出现阻滞,关系推进可能伴随现实阻力或时间延后。') + if 'dignity_high_friction' in secondary_context: + risks.append('相关婚恋行星尊贵度摩擦较高,关系推进时更容易出现磨损与反复确认。') + if 'shadbala_component_gap' in secondary_context: + risks.append('Shadbala 六分量还存在缺口,关系强弱结论需继续保守处理。') + + boundaries.append('婚恋高严谨模式至少需要 D1、D9、UL、Vimshottari 与 Narayana dual dasha 同时在场。') + boundaries.append('protective kuta support、Mahendra、Stree Deergha 等合盘细信号只能辅助,不得越权抬升 legal_marriage。') + boundaries.append('若 dual dasha、external timing 或 marriage convergence 冲突,必须明确降置信度,而不是把关系窗口包装成婚姻必然落地。') + if 'public_formalization_candidate' in secondary_context: + boundaries.append('public_formalization_candidate 只表示公开化候选,不等于法律婚姻,不能越权替代 legal_marriage。') + if synastry_signals: + boundaries.append(f"当前 synastry taxonomy 已命中 {', '.join(synastry_signals[:5])},但这些信号仍从属于 secondary-context。") + + if not strengths: + strengths.append('当前婚恋 strict workflow 主要提供边界与缺口提示,尚未形成足够稳定的正向落地支持。') + if not risks: + risks.append('未见强烈负面冲突,但仍需用现实事件、D9 和 dual dasha 做最后复核。') + + headline = ( + '婚恋严格裁决已接入 synastry taxonomy,可把合盘支持翻译成次级关系语义。' + if 'synastry_support' in secondary_context + else '婚恋严格裁决已接入主链,但当前更依赖本命、D9 与时机层,而非合盘辅助。' + ) + + markdown_lines = [ + '### 婚恋严格裁决', + f"- headline: {headline}", + f"- dominant_label: {dominant_label or 'none'}", + f"- confidence_cap: {confidence_cap}", + f"- secondary_context: {secondary_context}", + '- strengths:', + *[f" - {item}" for item in strengths], + '- risks:', + *[f" - {item}" for item in risks], + '- boundaries:', + *[f" - {item}" for item in boundaries], + ] + + return { + 'headline': headline, + 'strengths': strengths, + 'risks': risks, + 'boundaries': boundaries, + 'markdown': "\n".join(markdown_lines), + } + + def _build_ai_prompt_pack(report): """Build a compact, evidence-first prompt pack for downstream AI/RAG reading.""" modules = report.get('modules', {}) if isinstance(report, dict) else {} @@ -960,6 +1086,7 @@ def _build_ai_prompt_pack(report): functional_layer = _functional_benefic_malefic_snapshot(planets, chart.get('ascendant', {})) oracle_progress = _oracle_progress_snapshot() technique_audit_table = _build_technique_audit_table(functional_layer, oracle_progress, modules) + relationship_narrative = _build_relationship_narrative_payload(modules.get('relationship_strict_evidence')) shadbala_ranking = [] for planet_name, pdata in sorted( @@ -1031,6 +1158,7 @@ def _build_ai_prompt_pack(report): 'oracle_progress': oracle_progress, 'functional_benefic_malefic': functional_layer, 'technique_audit_table': technique_audit_table, + 'relationship_narrative': relationship_narrative, } prompt_lines = [ @@ -1068,6 +1196,25 @@ def _build_ai_prompt_pack(report): } +def _load_relationship_strict_collector(): + try: + from mcp_server import _collect_strict_evidence as collector + return collector + except Exception: + mcp_path = os.path.join(ROOT_DIR, 'mcp_server.py') + if not os.path.exists(mcp_path): + raise + spec = importlib.util.spec_from_file_location("jyotish_root_mcp_server", mcp_path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load mcp_server from {mcp_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + collector = getattr(module, "_collect_strict_evidence", None) + if collector is None: + raise ImportError("mcp_server._collect_strict_evidence not found") + return collector + + # ============================================================================ # 公共星盘计算(供 chart/shadbala/ashtakavarga 共用,v3.4提取) # ============================================================================ @@ -4739,6 +4886,20 @@ def cmd_full_reading(args): elapsed = round(time.time() - t0, 2) module_count = len(report['modules']) error_count = len(report['errors']) + + # ── 生成动态引导 (Dynamic Hooks) ── + try: + report['dynamic_hooks'] = generate_life_stage_hooks( + planets=report['modules'].get('chart', {}).get('planets', {}), + asc_sign=report['modules'].get('chart', {}).get('ascendant', {}).get('sign', ''), + asc_idx=report['modules'].get('chart', {}).get('ascendant', {}).get('sign_idx', 0), + current_dasha=report['modules'].get('dasha', {}).get('current_dasha', {}), + narayana_dasha=report['modules'].get('narayana_dasha', {}) + ) + except Exception as e: + report['dynamic_hooks'] = [] + report['errors'].append(f"hook_engine: {e}") + report['summary'] = { 'elapsed_seconds': elapsed, 'modules_computed': module_count, @@ -4746,6 +4907,16 @@ def cmd_full_reading(args): 'status': 'complete' if error_count == 0 else f'{error_count} errors', 'next_step': '⭐ v6.1.6: full-reading 已输出 transit_multi_reference(四参考点) + dasa_convergence(五系统交叉) + yogini_dasha + ashtottari_dasha + kalachakra_dasha + d9_navamsa_expanded。AI必须使用四参考点分析Transit,Dasa预测必须标注多系统收敛等级。', } + + try: + relationship_strict_collector = _load_relationship_strict_collector() + report['modules']['relationship_strict_evidence'] = relationship_strict_collector('relationship', report) + report['modules']['relationship_strict_evidence']['user_narrative'] = _build_relationship_narrative_payload( + report['modules']['relationship_strict_evidence'] + ) + except Exception as e: + report['errors'].append(f"relationship-strict-evidence: {e}") + report['ai_prompt_pack'] = _build_ai_prompt_pack(report) return report diff --git a/scripts/life_stage_hook.py b/scripts/life_stage_hook.py new file mode 100644 index 00000000..478637b7 --- /dev/null +++ b/scripts/life_stage_hook.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +Dynamic Life-Stage Hook Engine +Generates context-aware prompt suggestions based on the user's current Dasha and Transits. +""" + +from typing import Any, Dict, List + +# Core mapping for planets/houses to life themes +PLANET_THEMES = { + "Sun": ["career", "status", "authority", "father"], + "Moon": ["mind", "mother", "property", "emotional well-being"], + "Mars": ["property", "energy", "courage", "siblings", "conflicts"], + "Mercury": ["business", "communication", "intellect", "skills"], + "Jupiter": ["wealth", "children", "expansion", "luck", "wisdom"], + "Venus": ["marriage", "relationships", "luxury", "comfort"], + "Saturn": ["career", "delays", "discipline", "hard work", "karma"], + "Rahu": ["foreign affairs", "sudden events", "technology", "obsession"], + "Ketu": ["spirituality", "losses", "detachment", "isolation"] +} + +HOUSE_THEMES = { + 1: ["health", "self-development", "life path"], + 2: ["wealth", "savings", "family"], + 3: ["courage", "short trips", "communication"], + 4: ["property", "mother", "home", "peace"], + 5: ["investments", "children", "creativity"], + 6: ["health issues", "debts", "enemies", "daily work"], + 7: ["marriage", "business partnerships", "public relations"], + 8: ["hidden matters", "sudden gains/losses", "research", "crises"], + 9: ["fortune", "higher education", "long travel", "spirituality"], + 10: ["career advancement", "public status", "leadership"], + 11: ["gains", "networks", "profits", "elder siblings"], + 12: ["foreign lands", "expenses", "isolation", "spirituality"] +} + +def _get_lord_houses(planet: str, asc_idx: int) -> List[int]: + """Return the houses owned by the given planet for the given ascendant index.""" + SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] + SIGN_LORDS = { + "Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon", + "Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars", + "Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter" + } + owned = [] + for h in range(1, 13): + sign = SIGNS[(asc_idx + h - 1) % 12] + if SIGN_LORDS.get(sign) == planet: + owned.append(h) + return owned + +def generate_life_stage_hooks( + planets: Dict[str, Any], + asc_sign: str, + asc_idx: int, + current_dasha: Dict[str, Any], + narayana_dasha: Dict[str, Any] +) -> List[Dict[str, str]]: + """ + Generate top 3 prompt hooks based on the current life stage. + """ + hooks = [] + + # 1. Antardasha (Sub-period) Hook + ad_lord = None + if isinstance(current_dasha, dict) and "antardasha" in current_dasha: + ad = current_dasha.get("antardasha") + if isinstance(ad, dict): + ad_lord = ad.get("lord") + elif isinstance(current_dasha, dict): + ad_lord = current_dasha.get("lord") # Fallback to MD lord + + if ad_lord: + owned_houses = _get_lord_houses(ad_lord, asc_idx) + core_themes = [] + for h in owned_houses: + core_themes.extend(HOUSE_THEMES.get(h, [])) + + if not core_themes: + core_themes = PLANET_THEMES.get(ad_lord, []) + + if "career advancement" in core_themes or "wealth" in core_themes or ad_lord in ["Sun", "Jupiter"]: + hooks.append({ + "type": "career_wealth", + "trigger": f"Current Antardasha Lord ({ad_lord})", + "question": f"🔮 测一测我在当前「{ad_lord}运」期间,是否有实质性的财务跃迁或事业升职机会?", + "rationale": f"系统检测到你正处于 {ad_lord} 主导的运势周期,该星体掌管你的核心资源/事业宫位,近期极易触发财务或职级变动。" + }) + elif "marriage" in core_themes or "business partnerships" in core_themes or ad_lord in ["Venus", "Moon"]: + hooks.append({ + "type": "relationship", + "trigger": f"Current Antardasha Lord ({ad_lord})", + "question": f"🔮 我的正缘大概会在什么时间点出现?当前运势对我的婚恋/合伙关系有什么影响?", + "rationale": f"系统检测到你正处于 {ad_lord} 运势周期,极易触发亲密关系或重要合伙人的变动,建议深度扫描婚恋应期。" + }) + elif "foreign lands" in core_themes or "hidden matters" in core_themes or "health issues" in core_themes or ad_lord in ["Ketu", "Rahu"]: + hooks.append({ + "type": "transition_healing", + "trigger": f"Current Antardasha Lord ({ad_lord})", + "question": f"🔮 我近期感到强烈的内耗/变动倾向,是否适合出国、换环境或进行重大人生断舍离?", + "rationale": f"当前 {ad_lord} 运势激活了隐秘/变动宫位,容易带来精神内耗或海外发展的契机,需要诊断当前的卡点。" + }) + else: + # Fallback hook for Antardasha + hooks.append({ + "type": "general_ad", + "trigger": f"Current Antardasha Lord ({ad_lord})", + "question": f"🔮 当前「{ad_lord}运」对我接下来的 1-2 年有什么本质性的影响?", + "rationale": f"你目前处于 {ad_lord} 掌管的次级运势中,深度解读该星体能帮你把握近期的核心节奏。" + }) + + # 2. Narayana Dasha Hook + if isinstance(narayana_dasha, dict): + nd_obj = narayana_dasha.get("current_dasha") + nd_sign = None + if isinstance(nd_obj, dict): + nd_sign = nd_obj.get("md", {}).get("sign") + elif isinstance(nd_obj, str): + nd_sign = nd_obj + + if nd_sign: + hooks.append({ + "type": "macro_trend", + "trigger": f"Narayana Dasha ({nd_sign})", + "question": f"🔮 我在当前的「{nd_sign}星座大运」中,最应该把精力聚焦在哪个领域才能利益最大化?", + "rationale": f"Jaimini 系统的 {nd_sign} 大运主轴已确认。顺应星座大运的能量流动,能帮你找到未来几年的阻力最小路径。" + }) + + # 3. Upcoming Transit (Gochar) Mock Hook (Requires actual ephemeris in full implementation) + # For now, we inject a generic but highly actionable transit hook. + hooks.append({ + "type": "transit_alert", + "trigger": "Upcoming Major Transit", + "question": "🔮 未来半年内,木星或土星的换座/过宫,会给我带来哪些具体的机遇或危机?", + "rationale": "流年大星(木/土)的轨迹往往是触发本命盘事件的最后一把钥匙,提前观测能帮你避坑或抓红利。" + }) + + # Ensure we return exactly 3 top hooks + # Remove duplicates by type + seen_types = set() + unique_hooks = [] + for h in hooks: + if h["type"] not in seen_types: + unique_hooks.append(h) + seen_types.add(h["type"]) + + # Always provide a fallback general reading option + unique_hooks.append({ + "type": "general_reading", + "trigger": "User Preference", + "question": "🔮 跳过引导,我想查看完整的十年人生起伏与本命深度体检图谱。", + "rationale": "生成最全面的静态命运基调与大运概览。" + }) + + return unique_hooks[:3] diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index ded9e57b..1aba2d05 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -299,6 +299,20 @@ def test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack() -> None: assert strength_rows assert strength_rows[0]["status"] == "used" assert "Shadbala" in strength_rows[0]["note"] + relationship_synastry_rows = [row for row in audit_table if row["technique"] == "Relationship Synastry Taxonomy"] + assert relationship_synastry_rows + assert relationship_synastry_rows[0]["status"] in {"used", "blocked"} + assert "relationship secondary_context" in relationship_synastry_rows[0]["note"] + assert "compatibility support" in relationship_synastry_rows[0]["note"] + assert "protective kuta support" in relationship_synastry_rows[0]["note"] + relationship_narrative = prompt_pack["evidence_snapshot"]["relationship_narrative"] + assert relationship_narrative["headline"] + assert isinstance(relationship_narrative["strengths"], list) + assert isinstance(relationship_narrative["risks"], list) + assert isinstance(relationship_narrative["boundaries"], list) + assert relationship_narrative["markdown"] + assert "D9" in "".join(relationship_narrative["boundaries"]) + assert "dual dasha" in relationship_narrative["markdown"] oracle_progress = prompt_pack["evidence_snapshot"]["oracle_progress"] assert oracle_progress["scope"] == "external_oracle_evidence_validation" assert oracle_progress["valid_packets"] >= 4 diff --git a/tests/test_mcp_strict_workflow_relationship.py b/tests/test_mcp_strict_workflow_relationship.py index 2436be5b..4508ac3f 100644 --- a/tests/test_mcp_strict_workflow_relationship.py +++ b/tests/test_mcp_strict_workflow_relationship.py @@ -3,8 +3,16 @@ from __future__ import annotations +import sys +from pathlib import Path + from mcp_server import _collect_strict_evidence +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import jyotish_engine # noqa: E402 + def _base_relationship_result() -> dict: return { @@ -50,6 +58,7 @@ def test_relationship_jaimini_bridge_lifts_legal_marriage_label() -> None: "jaimini_support", "ul_support", "virodhargala_obstruction", + "vedastro_range_scan_missing", ] @@ -70,9 +79,62 @@ def test_relationship_jaimini_bridge_stays_context_only_when_d9_missing() -> Non "jaimini_support", "ul_support", "virodhargala_obstruction", + "vedastro_range_scan_missing", ] +def test_relationship_jaimini_bridge_cannot_lift_legal_marriage_when_narayana_is_missing() -> None: + result = _base_relationship_result() + del result["modules"]["narayana_dasha"] + + strict = _collect_strict_evidence("relationship", result) + + assert strict["present_evidence"]["jaimini_marriage_support"] == { + "level": "moderate", + "signals": ["darakaraka_active", "dk_7h_link"], + "source": "jaimini_bridge_v1", + } + assert strict["event_judgement"]["dominant_label"] is None + assert "jaimini_support" in strict["event_judgement"]["secondary_context"] + assert "public_formalization_candidate" not in strict["event_judgement"]["secondary_context"] + + +def test_relationship_jaimini_bridge_uses_public_formalization_as_context_only_when_marriage_gate_is_open_but_label_support_missing() -> None: + result = _base_relationship_result() + del result["modules"]["vivah_saham"] + del result["modules"]["dasa_convergence"] + result["modules"]["external_activation"] = { + "evidence_ledger": [ + { + "source": "vedastro_service_adapter_candidate", + "operation": "range_scan", + "domain": "marriage", + "event_id": "public_relationship_window", + "signal_key": "public_relationship_window", + "score": 78, + } + ] + } + result["modules"]["synastry"] = { + "total_score": 28.0, + "max_score": 36.0, + "is_approved": True, + "additional_kutas": { + "Mahendra": "good", + "Vedha": "good", + "Rajju": {"result": "good", "group": None, "effect": ""}, + }, + } + + strict = _collect_strict_evidence("relationship", result) + + assert strict["event_judgement"]["dominant_label"] is None + assert "jaimini_support" in strict["event_judgement"]["secondary_context"] + assert "external_activation_support" in strict["event_judgement"]["secondary_context"] + assert "synastry_support" in strict["event_judgement"]["secondary_context"] + assert "public_formalization_candidate" in strict["event_judgement"]["secondary_context"] + + def test_relationship_argala_bridge_uses_seventh_house_as_modifier_only() -> None: result = _base_relationship_result() @@ -135,7 +197,16 @@ def test_relationship_synastry_bridge_adds_context_and_small_score_bump_without_ assert strict["present_evidence"]["synastry_relationship_support"] == { "level": "supportive", "source": "synastry_relationship_bridge_v1", - "signals": ["ashtakoot_approved", "ashtakoot_high_score", "kuta_exception_clean"], + "signals": [ + "ashtakoot_approved", + "ashtakoot_high_score", + "mahendra_support", + "stree_deergha_support", + "vedha_clean", + "rajju_clean", + "bad_constellations_clean", + "kuta_exception_clean", + ], "total_score": 29.0, "approved": True, } @@ -188,11 +259,252 @@ def test_relationship_synastry_bridge_understands_nested_bad_constellations_and_ assert strict["present_evidence"]["synastry_relationship_support"] == { "level": "moderate", "source": "synastry_relationship_bridge_v1", - "signals": ["ashtakoot_approved", "exception_mitigated_match", "kuta_exception_clean"], + "signals": [ + "ashtakoot_approved", + "exception_mitigated_match", + "mahendra_support", + "stree_deergha_support", + "vedha_clean", + "rajju_clean", + "bad_constellations_clean", + "kuta_exception_clean", + ], "total_score": 18.0, "approved": True, } assert "synastry_support" in strict["event_judgement"]["secondary_context"] + assert "synastry_compatibility_support" in strict["event_judgement"]["secondary_context"] + assert "synastry_protective_kuta_support" in strict["event_judgement"]["secondary_context"] + assert "synastry_exception_mitigated" in strict["event_judgement"]["secondary_context"] + assert "synastry_kuta_exception_clean" in strict["event_judgement"]["secondary_context"] + assert "mahendra_support" in strict["event_judgement"]["secondary_context"] + assert "stree_deergha_support" in strict["event_judgement"]["secondary_context"] + assert "vedha_clean" in strict["event_judgement"]["secondary_context"] + assert "rajju_clean" in strict["event_judgement"]["secondary_context"] + assert "bad_constellations_clean" in strict["event_judgement"]["secondary_context"] + + +def test_relationship_synastry_exception_alone_cannot_create_false_lift_or_clean_kuta_context() -> None: + result = _base_relationship_result() + del result["modules"]["varga_full"]["D9_Navamsa"] + del result["modules"]["special_lagnas"]["Upapada_Lagna"] + del result["modules"]["vivah_saham"] + del result["modules"]["narayana_dasha"] + result["modules"]["synastry"] = { + "total_score": 14.0, + "max_score": 36.0, + "is_approved": False, + "exceptions": ["Nadi exception discussed but not fully mitigated."], + "additional_kutas": { + "Mahendra": "good", + "StreeDeergha": "good", + "Vedha": "bad", + "Rajju": {"result": "bad", "group": "same", "effect": "dosha"}, + "BadConstellations": {"result": "bad", "issues": ["vedha clash"]}, + }, + } + + strict = _collect_strict_evidence("relationship", result) + + assert strict["present_evidence"]["synastry_relationship_support"] == { + "level": "none", + "source": "synastry_relationship_bridge_v1", + "signals": [ + "exception_mitigated_match", + "mahendra_support", + "stree_deergha_support", + ], + "total_score": 14.0, + "approved": False, + } + assert strict["event_judgement"]["dominant_label"] is None + assert "synastry_support" not in strict["event_judgement"]["secondary_context"] + assert "synastry_compatibility_support" not in strict["event_judgement"]["secondary_context"] + assert "synastry_protective_kuta_support" not in strict["event_judgement"]["secondary_context"] + assert "synastry_exception_mitigated" not in strict["event_judgement"]["secondary_context"] + assert "synastry_kuta_exception_clean" not in strict["event_judgement"]["secondary_context"] + assert "mahendra_support" not in strict["event_judgement"]["secondary_context"] + assert "rajju_clean" not in strict["event_judgement"]["secondary_context"] + + +def test_relationship_good_kutas_cannot_override_weak_core_marriage_promise() -> None: + result = _base_relationship_result() + del result["modules"]["vivah_saham"] + del result["modules"]["dasa_convergence"] + result["modules"]["jaimini"] = { + "darakaraka": {"planet": "Venus", "house": 3}, + } + result["modules"]["synastry"] = { + "total_score": 31.0, + "max_score": 36.0, + "is_approved": True, + "additional_kutas": { + "Mahendra": "good", + "StreeDeergha": "good", + "Vedha": "good", + "Rajju": {"result": "good", "group": None, "effect": ""}, + "BadConstellations": {"result": "good", "issues": []}, + }, + } + + strict = _collect_strict_evidence("relationship", result) + + assert strict["present_evidence"]["jaimini_marriage_support"] == { + "level": "weak", + "signals": ["darakaraka_active"], + "source": "jaimini_bridge_v1", + } + assert strict["present_evidence"]["synastry_relationship_support"]["level"] == "supportive" + assert strict["event_judgement"]["dominant_label"] is None + assert "synastry_support" in strict["event_judgement"]["secondary_context"] + assert "synastry_compatibility_support" in strict["event_judgement"]["secondary_context"] + assert "synastry_protective_kuta_support" in strict["event_judgement"]["secondary_context"] + assert "jaimini_support" not in strict["event_judgement"]["secondary_context"] + + +def test_relationship_protective_kutas_do_not_block_confidence_downgrade_when_timing_layers_conflict() -> None: + result = _base_relationship_result() + result["modules"]["synastry"] = { + "total_score": 31.0, + "max_score": 36.0, + "is_approved": True, + "additional_kutas": { + "Mahendra": "good", + "StreeDeergha": "good", + "Vedha": "good", + "Rajju": {"result": "good", "group": None, "effect": ""}, + "BadConstellations": {"result": "good", "issues": []}, + }, + } + result["modules"]["external_activation"] = { + "evidence_ledger": [ + { + "source": "vedastro_service_adapter_candidate", + "operation": "range_scan", + "domain": "career", + "event_id": "career_window_only", + "signal_key": "career_only_external_window", + "score": 74, + } + ] + } + result["modules"]["dasa_convergence"] = { + "domain_activations": { + "career_status": {"convergence_level": "L4", "probability": "70-85%"} + } + } + + strict = _collect_strict_evidence("relationship", result) + + assert "synastry_protective_kuta_support" in strict["event_judgement"]["secondary_context"] + assert strict["event_judgement"]["dominant_label"] == "legal_marriage" + assert strict["confidence_cap"] == "low" + + +def test_relationship_narrative_payload_turns_synastry_taxonomy_into_user_readable_boundary() -> None: + strict = _collect_strict_evidence("relationship", _base_relationship_result()) + strict["present_evidence"]["synastry_relationship_support"] = { + "level": "supportive", + "source": "synastry_relationship_bridge_v1", + "signals": [ + "ashtakoot_approved", + "mahendra_support", + "stree_deergha_support", + "vedha_clean", + "rajju_clean", + ], + "total_score": 29.0, + "approved": True, + } + strict["event_judgement"]["secondary_context"] = strict["event_judgement"]["secondary_context"] + [ + "synastry_support", + "synastry_compatibility_support", + "synastry_protective_kuta_support", + ] + strict["confidence_cap"] = "low" + + payload = jyotish_engine._build_relationship_narrative_payload(strict) + + assert "婚恋" in payload["headline"] + assert any("合盘" in item for item in payload["strengths"]) + assert any("protective kuta" in item.lower() for item in payload["strengths"]) + assert any("dual dasha" in item.lower() for item in payload["risks"]) + assert any("legal_marriage" in item for item in payload["boundaries"]) + assert "D9" in payload["markdown"] + assert "dual dasha" in payload["markdown"] + + +def test_relationship_narrative_payload_exposes_public_formalization_candidate_as_context_only() -> None: + strict = _collect_strict_evidence("relationship", _base_relationship_result()) + strict["event_judgement"]["dominant_label"] = None + strict["event_judgement"]["secondary_context"] = [ + "darakaraka_active", + "jaimini_support", + "ul_support", + "external_activation_support", + "public_formalization_candidate", + ] + strict["present_evidence"].pop("vivah_saham", None) + strict["present_evidence"].pop("marriage_convergence", None) + strict["confidence_cap"] = "low" + + payload = jyotish_engine._build_relationship_narrative_payload(strict) + + assert any("public_formalization" in item for item in payload["strengths"]) + assert any("不等于法律婚姻" in item for item in payload["boundaries"]) + assert "public_formalization_candidate" in payload["markdown"] + assert "legal_marriage" in payload["markdown"] + + +def test_relationship_narrative_payload_for_public_formalization_candidate_with_timing_conflict_explicitly_warns_not_to_misread_as_near_marriage() -> None: + strict = _collect_strict_evidence("relationship", _base_relationship_result()) + strict["event_judgement"]["dominant_label"] = None + strict["event_judgement"]["secondary_context"] = [ + "darakaraka_active", + "jaimini_support", + "ul_support", + "external_activation_support", + "public_formalization_candidate", + ] + strict["present_evidence"].pop("vivah_saham", None) + strict["present_evidence"].pop("marriage_convergence", None) + strict["confidence_cap"] = "low" + + payload = jyotish_engine._build_relationship_narrative_payload(strict) + + assert any("public_formalization_candidate" in item for item in payload["strengths"]) + assert any("不能误读成接近结婚" in item for item in payload["risks"]) + assert any("不等于法律婚姻" in item for item in payload["boundaries"]) + assert "不能误读成接近结婚" in payload["markdown"] + + +def test_relationship_narrative_payload_does_not_translate_public_formalization_candidate_plus_synastry_support_plus_weak_core_promise_into_marriage_approach() -> None: + strict = _collect_strict_evidence("relationship", _base_relationship_result()) + strict["event_judgement"]["dominant_label"] = None + strict["event_judgement"]["secondary_context"] = [ + "darakaraka_active", + "ul_support", + "synastry_support", + "synastry_compatibility_support", + "public_formalization_candidate", + ] + strict["present_evidence"]["jaimini_marriage_support"] = { + "level": "weak", + "signals": ["darakaraka_active"], + "source": "jaimini_bridge_v1", + } + strict["present_evidence"].pop("vivah_saham", None) + strict["present_evidence"].pop("marriage_convergence", None) + strict["confidence_cap"] = "low" + + payload = jyotish_engine._build_relationship_narrative_payload(strict) + + assert any("合盘支持已进入婚恋主链" in item for item in payload["strengths"]) + assert any("公开化/关系可见度候选" in item for item in payload["strengths"]) + assert any("不能误读成接近结婚" in item for item in payload["risks"]) + assert any("不得越权抬升 legal_marriage" in item for item in payload["boundaries"]) + assert "public_formalization_candidate" in payload["markdown"] + assert "legal_marriage" in payload["markdown"] def test_relationship_dignity_guardrail_ignores_non_relevant_planets() -> None: