/**
* Jyotish Export System v2.0 — 100% 完整星盘数据导出
* 覆盖 Python engine full-reading 全部13模块:
* dasha, yoga, varga_full, aspects, jaimini, nakshatra_advanced,
* argala, tajika, shadbala, ashtakavarga, validation, audit, actionable_context
*
* 导出格式: JSON / HTML / SVG / PNG
*/
import { SIGNS, SIGN_LORDS, PLANET_CN } from './jyotish-engine.js';
const DASHA_SHADBALA_EXPORT_CALIBRATION_STATUS = {
id: 'dasha_shadbala',
title: 'Dasha/Shadbala Calibration Status',
queue: 'external_oracle_collection_queue',
validator: 'external_oracle_evidence_validation',
ready_for_calibration: 0,
valid_packets: 0,
production_tuning_allowed: false,
confidence_boundary: 'D1/D9/SAV 高可信;大运起点和 Shadbala 绝对值仍在外部 evidence validator 校准中。',
user_facing_rule: '不得把大运起点或 Shadbala 绝对值说成已完成外部校准。',
status_labels: [
'ready_for_calibration: 0',
'valid_packets: 0',
'production_tuning_allowed: false',
],
};
// ============================================================================
// JSON 导出 — 完整星盘数据 (100% coverage)
// ============================================================================
export function exportJSON(chartData, extras = {}) {
const { planets, ascendant, birth_info } = chartData;
const payload = {
meta: {
app: 'Jyotish Web App',
version: '4.4',
export_date: new Date().toISOString(),
ayanamsa: 'Lahiri (Chitrapaksha)',
provenance: extras.provenance || chartData?._client_audit?.provenance || null,
calculation_settings: extras.provenance?.calculationSettings || chartData?._calculation_settings || null,
terminology_mode: extras.provenance?.terminologyMode || chartData?._terminology_mode || null,
calibration_status: {
dasha_shadbala: DASHA_SHADBALA_EXPORT_CALIBRATION_STATUS,
},
},
birth_info,
ascendant: _cleanAscendant(ascendant),
};
// ── 1. Planets (完整字段) ──
payload.planets = {};
for (const [name, p] of Object.entries(planets)) {
if (p.error) continue;
payload.planets[name] = {
sign: p.sign, sign_cn: p.sign_cn,
degree: p.degree, degree_in_sign: p.degree_in_sign,
house: p.house, status: p.status || '',
retrograde: p.retrograde || false,
speed: p.speed || 0,
combust: p.combust || false,
combust_degree: p.combust_degree || null,
nakshatra: p.nakshatra, nakshatra_pada: p.nakshatra_pada,
nakshatra_lord: p.nakshatra_lord,
};
}
// ── 2. Dasha ──
if (extras.dasha) payload.modules = payload.modules || {};
if (extras.dasha) payload.modules.dasha = extras.dasha;
// ── 3. Yoga ──
if (extras.yogas) {
payload.modules = payload.modules || {};
const ascSign = ascendant?.sign || '';
const kendraLords = _getKendraLords(ascSign);
const trikonaLords = _getTrikonaLords(ascSign);
payload.modules.yoga = {
ascendant: ascSign,
planets_analyzed: Object.keys(payload.planets).length,
kendra_lords: kendraLords,
trikona_lords: trikonaLords,
yogas_detected: extras.yogas.length,
yogas: extras.yogas.map(y => ({
name: y.name || '',
name_cn: y.name_cn || '',
combination: y.combination || '',
effects: y.effects || [],
strength: y.strength || '',
})),
};
}
// ── 4. Varga Full ──
if (extras.vargas) {
payload.modules = payload.modules || {};
payload.modules.varga_full = extras.vargas;
}
// ── 5. Aspects ──
if (extras.aspects) {
payload.modules = payload.modules || {};
payload.modules.aspects = extras.aspects;
}
// ── 6. Jaimini ──
if (extras.jaimini) {
payload.modules = payload.modules || {};
payload.modules.jaimini = extras.jaimini;
}
// ── 7. Nakshatra Advanced ──
if (extras.nakshatraAdvanced) {
payload.modules = payload.modules || {};
payload.modules.nakshatra_advanced = extras.nakshatraAdvanced;
}
// ── 8. Argala ──
if (extras.argala) {
payload.modules = payload.modules || {};
payload.modules.argala = extras.argala;
}
// ── 9. Tajika ──
if (extras.tajika) {
payload.modules = payload.modules || {};
payload.modules.tajika = extras.tajika;
}
// ── 10. Shadbala ──
if (extras.shadbala) {
payload.modules = payload.modules || {};
payload.modules.shadbala = extras.shadbala;
}
// ── 11. Ashtakavarga ──
if (extras.ashtakavarga) {
payload.modules = payload.modules || {};
payload.modules.ashtakavarga = extras.ashtakavarga;
}
// ── 12. Panchanga ──
if (extras.panchanga) {
payload.modules = payload.modules || {};
payload.modules.panchanga = extras.panchanga;
}
// ── 13. Validation ──
if (extras.validation) {
payload.modules = payload.modules || {};
payload.modules.validation = extras.validation;
}
// ── 14. Audit ──
if (extras.audit) {
payload.modules = payload.modules || {};
payload.modules.audit = extras.audit;
}
// ── 15. Actionable Context ──
if (extras.actionableContext) {
payload.modules = payload.modules || {};
payload.modules.actionable_context = extras.actionableContext;
}
// ── 16. Calibration Status ──
payload.modules = payload.modules || {};
payload.modules.calibration_status = {
dasha_shadbala: DASHA_SHADBALA_EXPORT_CALIBRATION_STATUS,
};
// ── 16. User Workflows ──
if (extras.workflows) {
payload.modules = payload.modules || {};
payload.modules.workflows = extras.workflows;
}
// ── 17. Vimsopaka Semantic Summary ──
if (extras.vimsopaka_semantic_summary) {
payload.modules = payload.modules || {};
payload.modules.vimsopaka_semantic_summary = extras.vimsopaka_semantic_summary;
}
if (extras.vedastro_overview) {
payload.modules = payload.modules || {};
payload.modules.vedastro_overview = extras.vedastro_overview;
}
downloadFile(
JSON.stringify(payload, null, 2),
`jyotish-chart-${birth_info?.date || 'export'}.json`,
'application/json'
);
}
// ============================================================================
// HTML 报告导出 — 可直接打开/打印的单文件报告
// ============================================================================
export function exportHTMLReport(chartData, extras = {}) {
if (!chartData) return;
const birth = chartData.birth_info || {};
const html = _buildHTMLReport(chartData, extras);
downloadFile(
html,
`jyotish-report-${_safeFilename(birth.date || 'chart')}.html`,
'text/html;charset=utf-8'
);
}
// ============================================================================
// PDF 报告导出 — 复用后端 report_builder.py 的 Playwright PDF 流水线
// ============================================================================
export async function exportPDFReport(chartData, extras = {}) {
if (!chartData) return null;
const birth = chartData.birth_info || {};
const name = `jyotish-report-${_safeFilename(birth.date || birth.name || 'chart')}`;
const html = _buildHTMLReport(chartData, extras);
const api = window.JyotishAPI?.generateReportArtifact;
if (!api) {
const downloaded_filename = `${name}.html`;
downloadFile(html, downloaded_filename, 'text/html;charset=utf-8');
return {
success: false,
fallback: 'html',
report_artifact_fallback: true,
fallback_reason: 'report_artifact API unavailable',
error: 'report_artifact API unavailable',
artifact_status: 'local_html_fallback_ready',
primary_artifact: 'html',
download_filename: downloaded_filename,
download_mime: 'text/html;charset=utf-8',
user_message: '本地 API 未连接,已改为生成 HTML 报告。',
downloaded_filename,
delivery: {
artifact_status: 'local_html_fallback_ready',
format: 'html',
filename: downloaded_filename,
mime: 'text/html;charset=utf-8',
fallback: true,
fallback_reason: 'report_artifact API unavailable',
user_message: '本地 API 未连接,已改为生成 HTML 报告。',
next_action: '可直接打开,或用浏览器打印为 PDF',
},
};
}
const result = await api({ format: 'pdf', name, html });
let downloaded_filename = '';
if (result?.pdf_base64) {
downloaded_filename = result.download_filename || result.delivery?.filename || result.pdf_filename || `${name}.pdf`;
downloadBase64File(result.pdf_base64, downloaded_filename, result.download_mime || result.delivery?.mime || result.mime || 'application/pdf');
} else if (result?.html_base64) {
downloaded_filename = result.download_filename || result.delivery?.filename || result.html_filename || `${name}.html`;
downloadBase64File(result.html_base64, downloaded_filename, result.download_mime || result.delivery?.mime || 'text/html;charset=utf-8');
} else {
downloaded_filename = `${name}.html`;
downloadFile(html, downloaded_filename, 'text/html;charset=utf-8');
}
return {
...result,
downloaded_filename,
report_artifact_fallback: result?.fallback === 'html' || result?.delivery?.fallback === true,
fallback_reason: result?.fallback_reason || result?.delivery?.fallback_reason || result?.pdf_error || result?.message || '',
artifact_status: formatReportArtifactStatus({ ...result, downloaded_filename }),
};
}
export function formatReportArtifactStatus(result = {}) {
const delivery = result.delivery || {};
const filename = result.downloaded_filename || result.download_filename || delivery.filename || result.pdf_filename || result.html_filename || '报告文件';
const message = result.user_message || delivery.user_message || '';
if (result.pdf_base64 || delivery.format === 'pdf') {
return `${message || 'PDF 报告已生成并开始下载。'}已下载:${filename}。`;
}
if (result.report_artifact_fallback || result.fallback === 'html' || delivery.fallback) {
const reason = result.fallback_reason || delivery.fallback_reason || result.error || 'PDF 渲染器不可用';
return `${message || '后端已生成 HTML 报告。'}已下载:${filename}。${reason}。可直接打开,或用浏览器打印为 PDF。`;
}
if (result.html_base64 || delivery.format === 'html') {
return `${message || '后端已生成 HTML 报告。'}已下载:${filename}。可直接打开,或用浏览器打印为 PDF。`;
}
return `报告已开始下载。已下载:${filename}。`;
}
// ============================================================================
// SVG 导出
// ============================================================================
export function exportSVG(chartElement, filename = 'jyotish-chart') {
if (!chartElement) return;
const svg = chartElement.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
const styleEl = document.createElementNS('http://www.w3.org/2000/svg', 'style');
styleEl.textContent = `text{font-family:'Noto Sans SC','Segoe UI',sans-serif;}.chart-planet-text{font-size:10px;fill:#333;}.chart-sign-text{font-size:9px;fill:#666;}.chart-asc-marker{font-size:11px;fill:#6d28d9;font-weight:700;}rect{stroke:#ccc;stroke-width:0.5;}`;
clone.insertBefore(styleEl, clone.firstChild);
downloadFile(new XMLSerializer().serializeToString(clone), `${filename}.svg`, 'image/svg+xml');
}
// ============================================================================
// PNG 导出
// ============================================================================
export async function exportPNG(chartElement, filename = 'jyotish-chart', scale = 2) {
if (!chartElement) return;
const svg = chartElement.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const w = parseFloat(svg.getAttribute('width')) || 400;
const h = parseFloat(svg.getAttribute('height')) || 400;
clone.setAttribute('width', w);
clone.setAttribute('height', h);
const styleEl = document.createElementNS('http://www.w3.org/2000/svg', 'style');
styleEl.textContent = `text{font-family:'Noto Sans SC','Segoe UI',sans-serif;fill:#333;}.chart-planet-text{font-size:10px;}.chart-sign-text{font-size:9px;fill:#666;}.chart-asc-marker{font-size:11px;fill:#6d28d9;font-weight:700;}rect{stroke:#ccc;stroke-width:0.5;}`;
clone.insertBefore(styleEl, clone.firstChild);
const svgStr = new XMLSerializer().serializeToString(clone);
const img = new Image();
const canvas = document.createElement('canvas');
canvas.width = w * scale; canvas.height = h * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
return new Promise((resolve) => {
img.onload = () => {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob(blob => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${filename}.png`; a.click();
URL.revokeObjectURL(url);
}
resolve();
}, 'image/png');
};
img.onerror = () => resolve();
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));
});
}
// ============================================================================
// 工具函数
// ============================================================================
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function downloadBase64File(base64, filename, mimeType) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function _cleanAscendant(asc) {
if (!asc) return {};
return {
sign: asc.sign, sign_cn: asc.sign_cn,
degree: asc.degree, degree_in_sign: asc.degree_in_sign,
lord: asc.lord || SIGN_LORDS[asc.sign],
};
}
function _getKendraLords(ascSign) {
const ai = SIGNS.indexOf(ascSign);
if (ai < 0) return [];
const kendra = [1, 4, 7, 10];
return [...new Set(kendra.map(h => SIGN_LORDS[SIGNS[(ai + h - 1) % 12]]))];
}
function _getTrikonaLords(ascSign) {
const ai = SIGNS.indexOf(ascSign);
if (ai < 0) return [];
const trikona = [5, 9];
return [...new Set(trikona.map(h => SIGN_LORDS[SIGNS[(ai + h - 1) % 12]]))];
}
function _buildHTMLReport(chartData, extras) {
const birth = chartData.birth_info || {};
const asc = _cleanAscendant(chartData.ascendant || {});
const provenance = extras.provenance || chartData?._client_audit?.provenance || {};
const panchanga = extras.panchanga || {};
const tithiLord = chartData.tithi_lord_analysis || {};
const yogas = Array.isArray(extras.yogas) ? extras.yogas : [];
const dashaPeriods = Array.isArray(extras.dasha?.periods) ? extras.dasha.periods : [];
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 careerNarrative = extras.career_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.career_narrative || chartData?.career_narrative || null;
const financeNarrative = extras.finance_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.finance_narrative || chartData?.finance_narrative || null;
const vimsopakaSemanticSummary = extras.vimsopaka_semantic_summary || chartData?.ai_prompt_pack?.evidence_snapshot?.vimsopaka_semantic_summary || chartData?.vimsopaka_semantic_summary || null;
const functionalBeneficMalefic = extras.functional_benefic_malefic || chartData?.ai_prompt_pack?.evidence_snapshot?.functional_benefic_malefic || chartData?.functional_benefic_malefic || null;
const vedastroOverview = extras.vedastro_overview || chartData?.ai_prompt_pack?.evidence_snapshot?.vedastro_overview || chartData?.vedastro_overview || null;
const techniqueAuditTable = extras.technique_audit_table || chartData?.ai_prompt_pack?.evidence_snapshot?.technique_audit_table || chartData?.technique_audit_table || null;
const generatedAt = new Date().toLocaleString('zh-CN');
return `
Jyotish Report - ${_h(birth.name || birth.date || 'Chart')}
出生资料与计算参数
${_kv('日期时间', `${birth.date || '-'} ${birth.time || ''} ${birth.tz || ''}`)}
${_kv('地点坐标', `${birth.lat ?? '-'}, ${birth.lon ?? '-'}`)}
${_kv('上升', `${asc.sign_cn || asc.sign || '-'} ${_fmt(asc.degree_in_sign ?? asc.degree)}°`)}
${_kv('Ayanamsa', provenance.ayanamsa || 'Lahiri')}
${_kv('Node', provenance.nodeMode || '-')}
${_kv('House', provenance.houseSystem || '-')}
${_kv('Sunrise', provenance.sunrisePolicy || '-')}
${_kv('Geocoder', provenance.geocoderPolicy || '-')}
${_kv('Ephemeris Backend', provenance.ephemerisBackend || '-')}
${_kv('Terminology', provenance.terminologyMode || '-')}
${_kv('Yoga 口径', provenance.yogaVariant || '-')}
${_kv('Jaimini 口径', provenance.jaiminiKarakaVariant || '-')}
${_kv('KP 口径', provenance.kpSignificatorVariant || '-')}
${_kv('Ashtakavarga 口径', provenance.ashtakavargaVariant || '-')}
${_kv('Shadbala 口径', provenance.shadbalaVariant || '-')}
${_kv('Dasha 口径', provenance.dashaReference || '-')}
${_kv('术语模式', provenance.terminologyLabel || provenance.terminologyMode || '-')}
${_kv('星历/引擎', provenance.engine || 'Jyotish Web App')}
${_kv('盘式', provenance.chartStyle || '-')}
Panchanga 与 Tithi Lord
Birth-time Panchanga
${_table([
['Vara', panchanga.vara || '-'],
['Tithi', `${panchanga.tithi?.number || '-'} · ${panchanga.tithi?.name || '-'}`],
['Paksha', panchanga.tithi?.paksha || '-'],
['Karana', panchanga.karana?.name || panchanga.karana || '-'],
['Yoga', `${panchanga.yoga?.number || '-'} · ${panchanga.yoga?.name || '-'}`],
])}
Tithi Lord Analysis
${_table([
['Tithi Lord', tithiLord.tithi_lord || '-'],
['分数', tithiLord.tithi_score == null ? '-' : `${Math.round(Number(tithiLord.tithi_score) * 100)}%`],
['位置', `${tithiLord.lord_sign || '-'} H${tithiLord.lord_house || '-'}`],
['关系风格', tithiLord.relationship_style || tithiLord.emotional_pattern || '-'],
])}
行星位置
| 行星 | 星座 | 度数 | 宫位 | 星宿 | 状态 |
${_planetRows(chartData.planets || {})}
Yoga 摘要
${yogas.length ? `${yogas.slice(0, 16).map(y => `
${_h(y.name_cn || y.name || 'Yoga')}
${_h(y.combination || y.strength || '-')}
`).join('')}
` : '当前导出未检测到 Yoga 摘要。
'}
${yogas.length > 16 ? `另有 ${yogas.length - 16} 条 Yoga 已保留在 JSON 导出中。
` : ''}
Dasha 摘要
${dashaPeriods.length ? `
| 周期 | 开始 | 结束 | 层级/说明 |
${dashaPeriods.slice(0, 12).map(p => `
| ${_h(p.lord || p.planet || p.name || '-')} |
${_h(p.start || '-')} |
${_h(p.end || '-')} |
${_h(p.level || p.type || p.precision || '-')} |
`).join('')}
` : '当前导出未附带 Dasha 时间线。
'}
${_functionalRoleSummarySection(functionalBeneficMalefic)}
${_vimsopakaSemanticSummarySection(vimsopakaSemanticSummary)}
${_vedastroOverviewSection(vedastroOverview)}
${_techniqueAuditTableSection(techniqueAuditTable)}
${_strictNarrativeSection('事业严格裁决', 'career', careerNarrative, '本段直接消费 career strict evidence,把本命 promise、dual dasha、官方时间窗与结构阻力写进用户可见正文。')}
${_strictNarrativeSection('财富严格裁决', 'finance', financeNarrative, '本段直接消费 finance strict evidence,把收入兑现、现金流动作、结构摩擦与时间边界写进用户可见正文。')}
${_relationshipStrictNarrativeSection(relationshipNarrative)}
${_workflowReportSection(workflows)}
${_calibrationStatusSection(DASHA_SHADBALA_EXPORT_CALIBRATION_STATUS)}
校验与审计
${_kv('Validation', validation.status || validation.overall || '-')}
${_kv('Audit', audit.overall || audit.status || '-')}
${_kv('Provenance', provenance.exportStatus || 'HTML/JSON/SVG/PNG export available')}
65/65 capability registry
Panchanga preview
Tithi Lord connected
可浏览器打印 PDF
`;
}
function _functionalRoleSummarySection(snapshot) {
if (!snapshot || typeof snapshot !== 'object') return '';
if (snapshot.status === 'blocked' || snapshot.status === 'not_used') return '';
const benefics = Array.isArray(snapshot.functional_benefics) ? snapshot.functional_benefics : [];
const malefics = Array.isArray(snapshot.functional_malefics) ? snapshot.functional_malefics : [];
const neutrals = Array.isArray(snapshot.functional_neutrals) ? snapshot.functional_neutrals : [];
const yogakarakas = Array.isArray(snapshot.yogakarakas) ? snapshot.yogakarakas : [];
return `
Functional Benefic/Malefic
${_table([
['状态', snapshot.status || 'used'],
['Ascendant', snapshot.ascendant || snapshot.asc_sign || '-'],
['Functional Benefics', benefics.join('、') || '—'],
['Functional Malefics', malefics.join('、') || '—'],
['Functional Neutrals', neutrals.join('、') || '—'],
['Yogakarakas', yogakarakas.join('、') || '—'],
['Confidence Impact', snapshot.effect_on_confidence || '已进入高严谨裁决链。'],
])}
`;
}
function _vimsopakaSemanticSummarySection(summary) {
if (!summary || typeof summary !== 'object') return '';
const highlights = Array.isArray(summary.highlights) ? summary.highlights : [];
const warnings = Array.isArray(summary.warnings) ? summary.warnings : [];
const status = summary.status || 'blocked';
return `
Vimsopaka 语义摘要
将 Vimsopaka dignity 结果转成用户可见语义,避免只剩分数没有解释边界。
${_table([
['状态', status],
['积极语义', highlights.length ? highlights.join(';') : '—'],
['风险语义', warnings.length ? warnings.join(';') : '—'],
])}
`;
}
function _techniqueAuditTableSection(rows) {
if (!Array.isArray(rows) || !rows.length) return '';
const formattedRows = rows.map(row => ([
row.technique || '-',
row.status || '-',
row.note || '-',
]));
return `
Technique Audit Table
${_table([['Technique', 'Status', 'Note'], ...formattedRows])}
`;
}
function _vedastroOverviewSection(overview) {
if (!overview || typeof overview !== 'object') return '';
const domainStatuses = overview.domain_statuses && typeof overview.domain_statuses === 'object'
? Object.entries(overview.domain_statuses).map(([domain, status]) => `${domain}:${status}`).join(';')
: '—';
const topEvents = overview.top_events_by_domain && typeof overview.top_events_by_domain === 'object'
? Object.entries(overview.top_events_by_domain).map(([domain, payload]) => {
const signal = payload?.signal_label || payload?.event_id || '—';
const start = payload?.start || '—';
return `${domain}:${signal} @ ${start}`;
}).join(';')
: '—';
return `
VedAstro 概览证据
这是主入口自动附带的 VedAstro 单日概览,用来补充外部雷达视角;它是 overview only,不替代长周期精扫。
${_table([
['状态', overview.status || 'blocked'],
['Source', overview.source || 'vedastro_service_adapter_candidate'],
['Ingestion', overview.ingestion_profile || '-'],
['Scope', overview.search_scope || 'single_day_overview'],
['Reference Date', overview.reference_date || '-'],
['Event Count', overview.event_count == null ? '0' : String(overview.event_count)],
['Domain Statuses', domainStatuses || '—'],
['Top Events', topEvents || '—'],
['Boundary', overview.boundary_note || 'overview only;不替代长周期精扫。'],
])}
`;
}
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 _strictNarrativeSection(title, domainClass, narrative, subtitle) {
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();
return `
${_h(title)}
${_h(narrative.headline || `${title}已接入导出主链。`)}
${_h(subtitle || 'strict narrative 已接入导出主链。')}
strict narrative
${_relationshipReportList('支持证据', strengths)}
${_relationshipReportList('需要观察', risks)}
${_relationshipReportList('边界条件', boundaries)}
${compactMarkdown ? `${_h(compactMarkdown)}
` : ''}
`;
}
function _calibrationStatusSection(status) {
return `
高级技法校准状态
${_h(status.title)}
${_h(status.status_labels.join(';'))}
${_h(status.validator)} · ${_h(status.queue)}
${_h(status.confidence_boundary)}
${_h(status.user_facing_rule)}
`;
}
function _workflowReportSection(workflows = {}) {
const sections = [
_kpWorkflowReport(workflows.kp),
_prashnaWorkflowReport(workflows.prashna),
_synastryWorkflowReport(workflows.synastry),
_caseLibraryWorkflowReport(workflows.case_library),
].filter(Boolean).join('');
if (!sections) {
return `
用户工作流结果
当前报告未附带 KP、Prashna 或合盘运行结果。运行对应 Tab 后再次导出,报告会自动带入最新结果。
`;
}
return ``;
}
function _caseLibraryWorkflowReport(caseLibrary) {
if (!caseLibrary) return '';
const pairs = Array.isArray(caseLibrary.synastry_pairs) ? caseLibrary.synastry_pairs : [];
const prashnas = Array.isArray(caseLibrary.prashna_cases) ? caseLibrary.prashna_cases : [];
if (!pairs.length && !prashnas.length) return '';
const pairRows = pairs.slice(0, 6).map(record => [
record.label || record.id || '未命名配对',
`${record.score?.total ?? 0} / ${record.score?.max ?? 36}`,
record.verdict || '-',
_dateLabel(record.updatedAt || record.generatedAt),
]);
const prashnaRows = prashnas.slice(0, 6).map(record => [
record.question_text || record.label || record.question_type || '未命名问事',
record.conclusion || '-',
record.confidence || '-',
_dateLabel(record.updatedAt || record.generatedAt),
]);
return `
保存案例库 ${pairs.length} 配对 / ${prashnas.length} 问事
${pairRows.length ? `最近配对
${_table([['案例', '分数', '判断', '更新时间'], ...pairRows])}` : ''}
${prashnaRows.length ? `最近问事
${_table([['问题', '结论', '置信度', '更新时间'], ...prashnaRows])}` : ''}
`;
}
function _kpWorkflowReport(kp) {
if (!kp) return '';
const houseCards = (kp.focus_houses || []).map(house => {
const data = kp.houses?.[house] || {};
const sig = data.significators || {};
const lords = data.kp_lords || {};
return `
${_h(house)}宫 · ${_h(data.sign || '-')}
Sub ${_h(lords.sub_lord || '-')}
A ${_h(_list(sig.A))}
B ${_h(_list(sig.B))}
`;
}).join('');
return `
KP Sublord ${_h(kp.label || kp.focus || '-')}
${_h(kp.note || 'KP 事件判断摘要。')}
${houseCards || '
暂无 KP 宫位摘要。
'}
`;
}
function _prashnaWorkflowReport(prashna) {
if (!prashna) return '';
const answer = prashna.kp_answer_v2 || prashna.kp_answer || {};
const summary = prashna.summary || {};
const chart = prashna.prashna_chart || {};
const timing = prashna.timing || {};
const conclusion = summary.conclusion || answer.kp_answer || 'MAYBE — 需要更多信息确认';
return `
Prashna 问事 ${_h(prashna.question_type || 'general')}
${prashna.question_text ? `
问题:${_h(prashna.question_text)}
` : ''}
${_table([
['结论', conclusion],
['置信度', summary.confidence || answer.confidence || '-'],
['问事上升', `${chart.asc_sign || '-'} ${chart.asc_degree ?? '-'}°`],
['问题宫', `${answer.primary_house || summary.primary_house || '-'}宫`],
['Sub Lord', answer.kp_sub_lord || '-'],
['时机', `${timing.score ?? '-'} / 100 · ${timing.rating || '-'}`],
['下一步', summary.next_action || timing.recommendation || '结合现实证据复核。'],
])}
`;
}
function _synastryWorkflowReport(synastry) {
if (!synastry) return '';
const score = `${synastry.total_score ?? 0} / ${synastry.max_score ?? 36}`;
const percent = Number(synastry.match_percentage);
const status = synastry.is_match_approved ? '匹配通过' : '需要谨慎综合判断';
const deep = synastry.deep || {};
const relationshipReport = synastry.relationship_report || deep.relationshipReport || {};
const spouseStatus = deep.spouseStatus || {};
const ulDkTiming = deep.ulDkTiming || {};
const evidenceRows = Array.isArray(relationshipReport.evidence)
? relationshipReport.evidence.map(item => [item.label || '-', item.value || '-', item.note || '-'])
: [];
const evidenceCards = Array.isArray(relationshipReport.evidence)
? relationshipReport.evidence.map(item => `
${_h(item.label || '-')}
${_h(item.value || '-')}
${_h(item.note || '-')}
`).join('')
: '';
const spouseRows = ['self', 'partner']
.map(key => spouseStatus[key] ? [
key === 'self' ? '本人' : '对方',
spouseStatus[key].verdict || '-',
`${spouseStatus[key].score ?? '-'} / 5`,
Array.isArray(spouseStatus[key].evidence) ? spouseStatus[key].evidence.slice(0, 2).join(';') : '-',
] : null)
.filter(Boolean);
const spouseCards = ['self', 'partner']
.map(key => spouseStatus[key] ? `
${key === 'self' ? '本人' : '对方'} spouse-status
${_h(spouseStatus[key].verdict || '-')}
${_h(spouseStatus[key].score ?? '-')} / 5 · ${_h((spouseStatus[key].evidence || []).slice(0, 2).join(';') || '-')}
` : '')
.join('');
const ulDkCards = ['self', 'partner']
.map(key => ulDkTiming[key] ? `
${key === 'self' ? '本人' : '对方'} UL/DK timing
${_h(ulDkTiming[key].summary || '-')}
${_h((ulDkTiming[key].triggers || []).slice(0, 2).join(';') || (ulDkTiming[key].evidence || []).slice(0, 2).join(';') || '-')}
` : '')
.join('');
const comparison = deep.comparison || {};
const axisCards = Array.isArray(comparison.axes)
? comparison.axes.slice(0, 4).map(axis => `
${_h(axis.label || '-')}
${_h(axis.relation?.label || '-')}
本人 ${_h(axis.self?.label || '-')} / 对方 ${_h(axis.partner?.label || '-')}
`).join('')
: '';
const comparisonRows = Array.isArray(comparison.rows)
? comparison.rows.slice(0, 9).map(row => [
row.label || row.planet || '-',
row.self?.label || '-',
row.partner?.label || '-',
row.relation?.label || '-',
`对方落本人${row.partnerOverlayHouse || '-'}宫 / 本人落对方${row.selfOverlayHouse || '-'}宫`,
])
: [];
const midpointCards = Array.isArray(comparison.compositeStyle)
? comparison.compositeStyle.map(item => `
${_h(item.label || '-')} midpoint
${_h(item.sign || '-')} ${_h(_fmt(item.degree))}°
${_h(item.note || '-')}
`).join('')
: '';
const scoreCards = Object.entries(synastry.scores || {}).slice(0, 8).map(([name, value]) => `
${_h(name)}${_h(value)} / ${_h(_kutaMax(name))}
`).join('');
return `
合盘 Synastry ${_h(synastry.mode || 'synastry')}
${_h(relationshipReport.headline || '关系报告需要结合完整星盘、D9、Kuja 与 Dasha 复核。')}
总分 ${_h(score)}${Number.isFinite(percent) ? ` · ${_h(percent.toFixed(1))}%` : ''}
${_h(relationshipReport.statusLabel || status)}
${_table([
['总分', `${score}${Number.isFinite(percent) ? ` · ${percent.toFixed(1)}%` : ''}`],
['状态', status],
['对方摘要', deep.partnerSummary ? `${deep.partnerSummary.ascendant || '-'} · Moon ${deep.partnerSummary.moon || '-'}` : '-'],
['Dasha 同步', deep.dasha?.note || '-'],
['UL/DK 时机', ['self', 'partner'].map(key => ulDkTiming[key]?.summary).filter(Boolean).join(' / ') || '-'],
['Kuja', deep.kuja?.note || '-'],
['D9', deep.d9 ? `本人 ${deep.d9.self?.score ?? '-'} / 对方 ${deep.d9.partner?.score ?? '-'}` : '-'],
])}
${evidenceCards ? `
关系证据卡
${evidenceCards}
` : (evidenceRows.length ? _table([['证据', '结果', '说明'], ...evidenceRows]) : '')}
${axisCards ? `
双人轴线比较 bi-wheel
${axisCards}
` : ''}
${comparisonRows.length ? _table([['行星', '本人', '对方', '互动', 'Overlay'], ...comparisonRows], 'comparison-print-table') : ''}
${midpointCards ? `
Composite-style Midpoints
${midpointCards}
` : ''}
${ulDkCards ? `
UL/DK 与关系时机 Jaimini · Dasha trigger
${ulDkCards}
` : ''}
${spouseCards ? `
配偶/婚后成长 Yoga spouse_status_yoga.py
${spouseCards}
` : (spouseRows.length ? _table([['对象', '判断', '得分', '证据'], ...spouseRows]) : '')}
${_relationshipReportList('支持证据', relationshipReport.strengths)}
${_relationshipReportList('需要观察', relationshipReport.risks)}
${_relationshipReportList('下一步', relationshipReport.nextSteps)}
${scoreCards || '
暂无 Kuta 分项。
'}
${_relationshipBoundary(relationshipReport.boundaries)}
`;
}
function _relationshipReportBullets(label, items) {
if (!Array.isArray(items) || !items.length) return '';
return `${_h(label)}:${_h(items.join(';'))}
`;
}
function _relationshipReportList(label, items) {
if (!Array.isArray(items) || !items.length) return '';
return `${_h(label)}${items.map(item => `- ${_h(item)}
`).join('')}
`;
}
function _relationshipBoundary(items) {
if (!Array.isArray(items) || !items.length) return '';
return `${items.map(item => `${_h(item)}`).join('')}
`;
}
function _planetRows(planets) {
const rows = Object.entries(planets)
.filter(([, p]) => p && !p.error)
.map(([name, p]) => {
const degree = p.degree_in_sign ?? (Number.isFinite(Number(p.lon ?? p.degree)) ? Number(p.lon ?? p.degree) % 30 : p.degree);
return `
| ${_h(PLANET_CN[name] || name)} |
${_h(p.sign_cn || p.sign || '-')} |
${_h(_fmt(degree))}° |
H${_h(p.house || '-')} |
${_h(p.nakshatra || '-')} ${p.nakshatra_pada ? `p${_h(p.nakshatra_pada)}` : ''} |
${_h(p.status || '')}${p.retrograde ? ' · R' : ''}${p.combust ? ' · combust' : ''} |
`;
});
return rows.join('') || '| 无行星数据 |
';
}
function _kv(label, value) {
return `${_h(label)}${_h(value || '-')}
`;
}
function _table(rows, className = '') {
const classAttr = className ? ` class="${_h(className)}"` : '';
return `${rows.map(row => {
const cells = Array.isArray(row) ? row : [row];
if (cells.length <= 2) {
return `| ${_h(cells[0])} | ${_h(cells[1])} |
`;
}
return `${cells.map((cell, index) => index === 0 ? `| ${_h(cell)} | ` : `${_h(cell)} | `).join('')}
`;
}).join('')}
`;
}
function _list(value) {
if (Array.isArray(value)) return value.length ? value.join('、') : '-';
if (value === null || value === undefined || value === '') return '-';
return String(value);
}
function _kutaMax(name) {
return {
Varna: 1,
Vashya: 2,
Tara: 3,
Yoni: 4,
GrahaMaitri: 5,
Gana: 6,
Bhakoot: 7,
Nadi: 8,
}[name] ?? '-';
}
function _fmt(value, digits = 2) {
const n = Number(value);
return Number.isFinite(n) ? n.toFixed(digits) : '-';
}
function _dateLabel(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function _h(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function _safeFilename(value) {
return String(value || 'chart').replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'chart';
}