feat(reports): add professional reference export

This commit is contained in:
Jesse_Chen
2026-09-04 00:19:28 +08:00
parent c2f23131f7
commit 3b09bbbee0
11 changed files with 715 additions and 48 deletions
+5
View File
@@ -335,6 +335,11 @@ Text release is paced, not animated: the frame buffer commits at most once per a
- **States:** default, hover, active, focus, disabled, loading, fallback notice.
- **Visibility:** the initial cards, one per consultation domain, remain visible while the user types a custom question. Clicking a card starts that consultation immediately instead of filling the composer; the cards leave once the session receives its first user message.
### Personal report centre
- A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records.
- Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow.
### Product entrypoint card
- **Structure:** the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy. On two-column viewports a compact action label and arrow sit at the trailing edge; on a stacked homepage they sit under the supporting line, leading-aligned, so a short label like “深入看今日” does not float to the opposite corner from “开始新的生时校正”.
@@ -0,0 +1,128 @@
import { NextResponse } from "next/server";
import {
checkSameOrigin,
resolveAllowedReportOrigins,
} from "@/lib/personal-report-entitlement";
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
import {
ACCOUNT_BIRTH_SELECT,
globalBirthProfileFromAccountRow,
} from "@/lib/server-owned-birth-profile";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
type RouteContext = { params: Promise<{ reportId: string }> };
type ProfessionalReferenceResponse = Readonly<{
format?: unknown;
markdown?: unknown;
}>;
function birthPayload(row: unknown): Record<string, unknown> | null {
const profile = globalBirthProfileFromAccountRow(row);
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(profile.date ?? "");
const timeMatch = /^(\d{2}):(\d{2})$/.exec(profile.time ?? "");
if (
!dateMatch
|| !timeMatch
|| profile.latitude === null
|| profile.longitude === null
|| profile.timezoneOffset === null
) {
return null;
}
return {
year: Number.parseInt(dateMatch[1], 10),
month: Number.parseInt(dateMatch[2], 10),
day: Number.parseInt(dateMatch[3], 10),
hour: Number.parseInt(timeMatch[1], 10),
minute: Number.parseInt(timeMatch[2], 10),
lat: profile.latitude,
lon: profile.longitude,
tz: profile.timezoneOffset,
ayanamsa: profile.ayanamsa,
format: "markdown",
packs: ["full"],
};
}
function upstreamError(status: number, retryAfter: string | null) {
return NextResponse.json(
{ error: status === 429 ? "专业参考版生成繁忙,请稍后重试" : "专业参考版暂时无法生成" },
{
status,
headers: retryAfter ? { "Retry-After": retryAfter } : undefined,
},
);
}
export async function POST(request: Request, context: RouteContext) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const originDecision = checkSameOrigin(
request.url,
request.headers.get("origin"),
resolveAllowedReportOrigins(process.env),
request.headers,
);
if (!originDecision.ok) {
return NextResponse.json({ error: "跨站请求已拒绝" }, { status: 403 });
}
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json({ error: "报告不存在" }, { status: 404 });
}
const report = await createSupabasePersonalReportService(supabase).getOwnedById(user.id, reportId);
if (!report) {
return NextResponse.json({ error: "报告不存在" }, { status: 404 });
}
if (report.status !== "ready") {
return NextResponse.json({ error: "报告尚未完成" }, { status: 409 });
}
const { data: profileRow, error: profileError } = await supabase
.from("profiles")
.select(ACCOUNT_BIRTH_SELECT)
.eq("id", user.id)
.maybeSingle();
const payload = profileError ? null : birthPayload(profileRow);
if (!payload) {
return NextResponse.json({ error: "出生资料不完整" }, { status: 422 });
}
const upstream = await fetch(`${jyotishApiBase}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(payload),
cache: "no-store",
});
if (!upstream.ok) {
return upstreamError(upstream.status, upstream.headers.get("retry-after"));
}
const result = await upstream.json().catch(() => null) as ProfessionalReferenceResponse | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
return upstreamError(502, null);
}
return NextResponse.json({ format: "markdown", markdown: result.markdown });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置" }, { status: 503 });
}
console.error("professional_report_reference_failed", error instanceof Error ? error.name : "UnknownError");
return NextResponse.json({ error: "专业参考版暂时无法生成" }, { status: 500 });
}
}
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
const LIST_POLL_INTERVAL_MS = 3000;
@@ -85,6 +86,8 @@ function StatusIcon({ status }: { status: ReportListItem["status"] }) {
export function PersonalReportCenter() {
const [state, setState] = useState<CenterState>({ phase: "loading", reports: [] });
const [exportingReportId, setExportingReportId] = useState<string | null>(null);
const [exportError, setExportError] = useState<{ reportId: string; message: string } | null>(null);
const cancelled = useRef(false);
const load = useCallback(async (showLoading = false) => {
@@ -136,6 +139,31 @@ export function PersonalReportCenter() {
[state.reports],
);
const downloadProfessionalReference = useCallback(async (reportId: string) => {
setExportingReportId(reportId);
setExportError(null);
try {
const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const result: unknown = await response.json().catch(() => null);
const payload = result && typeof result === "object" ? result as Record<string, unknown> : {};
if (!response.ok || payload.format !== "markdown" || typeof payload.markdown !== "string") {
throw new Error(typeof payload.error === "string" ? payload.error : "专业参考版暂时无法生成");
}
downloadMarkdownReport("个人专业参考版", payload.markdown);
} catch (error) {
setExportError({
reportId,
message: error instanceof Error ? error.message : "专业参考版暂时无法生成",
});
} finally {
setExportingReportId(null);
}
}, []);
if (state.phase === "unauthorized") {
return (
<main className="report-center-shell report-center-message">
@@ -201,9 +229,25 @@ export function PersonalReportCenter() {
<small>{formatDate(report.createdAt)} · {report.depth} · {report.themes.join(" / ") || "综合主题"}</small>
</div>
{report.status === "ready" ? (
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
查看报告
</Button>
<div className="flex flex-wrap items-center justify-end gap-2 max-[720px]:justify-start">
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
查看报告
</Button>
<Button
type="button"
variant="ghost"
disabled={exportingReportId === report.id}
onClick={() => void downloadProfessionalReference(report.id)}
>
{exportingReportId === report.id ? <InlineSpinner size={14} /> : null}
专业参考版(导出)
</Button>
{exportError?.reportId === report.id ? (
<span className="basis-full text-right text-sm text-destructive max-[720px]:text-left" role="alert">
{exportError.message}
</span>
) : null}
</div>
) : report.status === "generating" ? (
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="ghost">
查看进度
@@ -279,3 +279,21 @@ test("legacy consultation Markdown export is untouched and still works", () => {
assert.match(pageSource, /consultation-report-export/);
assert.doesNotMatch(pageSource, /consultationReportMarkdown[\s\S]{0,200}生成个人报告/);
});
test("ready reports expose the professional Markdown reference export only in the ready branch", () => {
assert.match(reportCenterSource, /专业参考版(导出)/);
assert.match(reportCenterSource, /professional-reference/);
assert.match(reportCenterSource, /downloadMarkdownReport\("个人专业参考版", payload\.markdown\)/);
const readyActionStart = reportCenterSource.indexOf(
'{report.status === "ready" ? (',
);
const generatingActionStart = reportCenterSource.indexOf(
') : report.status === "generating" ? (',
readyActionStart,
);
assert.ok(readyActionStart >= 0 && generatingActionStart > readyActionStart);
const readyBranch = reportCenterSource.slice(readyActionStart, generatingActionStart);
assert.match(readyBranch, /专业参考版(导出)/);
assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /专业参考版(导出)/);
});
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const routeSource = readFileSync(
new URL("../src/app/api/reports/[reportId]/professional-reference/route.ts", import.meta.url),
"utf8",
);
test("professional reference route authenticates, checks origin, owner and ready status", () => {
assert.match(routeSource, /createServerSupabaseClient/);
assert.match(routeSource, /supabase\.auth\.getUser\(\)/);
assert.match(routeSource, /checkSameOrigin/);
assert.match(routeSource, /resolveAllowedReportOrigins/);
assert.match(routeSource, /getOwnedById\(user\.id, reportId\)/);
assert.match(routeSource, /report\.status !== "ready"/);
assert.match(routeSource, /status: 401/);
assert.match(routeSource, /status: 403/);
assert.match(routeSource, /status: 404/);
assert.match(routeSource, /status: 409/);
});
test("birth data stays server-owned and the route calls only the public Python export", () => {
assert.match(routeSource, /\.from\("profiles"\)/);
assert.match(routeSource, /select\(ACCOUNT_BIRTH_SELECT\)/);
assert.match(routeSource, /globalBirthProfileFromAccountRow/);
assert.match(routeSource, /\/api\/professional_report_reference/);
assert.match(routeSource, /format: "markdown"/);
assert.match(routeSource, /packs: \["full"\]/);
assert.doesNotMatch(routeSource, /request\.json\(/);
assert.doesNotMatch(routeSource, /mastra|writer|billing|personal_report_sections/i);
assert.doesNotMatch(routeSource, /\.insert\(|\.update\(|\.delete\(/);
});
test("busy upstream responses preserve 429 and Retry-After", () => {
assert.match(routeSource, /upstream\.status/);
assert.match(routeSource, /upstream\.headers\.get\("retry-after"\)/);
assert.match(routeSource, /"Retry-After": retryAfter/);
});
+1
View File
@@ -30,6 +30,7 @@ HEAVY_COMPUTE_PATHS = frozenset(
"/api/high_rigor_workflow",
"/api/consultation_workflow",
"/api/professional_reading",
"/api/professional_report_reference",
"/api/vedastro/range_scan",
"/api/vedastro_gateway/run",
"/api/thematic_report",
+7
View File
@@ -3515,6 +3515,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
elif path == '/api/professional_reading':
result = self._compute_professional_reading(body)
self._json(result)
elif path == '/api/professional_report_reference':
reference_module = _load_local_module('professional_report_reference')
try:
result = reference_module.build_professional_report_reference(self, body)
except reference_module.ProfessionalReportReferenceInputError as exc:
raise BadRequest(str(exc)) from exc
self._json(result)
elif path == '/api/import_chart':
result = self._import_chart_text(body)
self._json(result)
+157 -45
View File
@@ -9592,7 +9592,10 @@ def render_pl9_markdown(packet: dict) -> str:
lines.extend(['', render_pl9_continuation_prompt()])
return '\n'.join(lines).strip() + '\n'
markdown = '\n'.join(lines).strip() + '\n'
if isinstance(packet.get('reader_engine_boundary_notice'), dict):
return sanitize_professional_report_reference_markdown(markdown)
return markdown
def write_pl9_pdf(packet: dict, output_path: str | None = None) -> dict:
@@ -16906,54 +16909,163 @@ def _attach_default_chart_identity(packet: dict) -> dict:
return packet
_PROFESSIONAL_REPORT_REFERENCE_OMITTED_KEYS = frozenset({
'raw_full_reading',
'fixture_path',
'source_path',
'artifact_path',
'module_path',
'source_crop',
'source_file',
'source_module',
'internal_module',
'implementation_path',
})
_PROFESSIONAL_REPORT_REFERENCE_INTERNAL_VALUE = re.compile(
r'(?:'
r'(?:^|[\\/])(?:Users|home|private|tmp)[\\/]'
r'|[A-Za-z]:\\\\'
r'|(?:^|[\\s`\"\'])scripts?[\\/]'
r'|\.py(?:::|\b)'
r'|\braw_full_reading\b'
r'|\bjyotish_engine\.'
r'|\b[a-zA-Z_][a-zA-Z0-9_]*_(?:engine|service|adapter|contract|producer)\.[A-Za-z_]'
r')'
)
_PROFESSIONAL_REPORT_REFERENCE_INTERNAL_TEXT = re.compile(
r'(?:'
r'\braw_full_reading(?:\.[A-Za-z0-9_]+)*'
r'|\bscripts?[\\/][A-Za-z0-9_.\\/-]+'
r'|\b[A-Za-z_][A-Za-z0-9_]*\.py::[A-Za-z_][A-Za-z0-9_.]*'
r'|\bjyotish_engine\.[A-Za-z_][A-Za-z0-9_.]*'
r'|\b[a-zA-Z_][a-zA-Z0-9_]*_(?:engine|service|adapter|contract|producer)\.[A-Za-z_][A-Za-z0-9_.]*'
r'|/(?:Users|home|private|tmp)/[^\s`\"\'|)\]]+'
r'|[A-Za-z]:\\[^\s`\"\'|)\]]+'
r')'
)
def sanitize_professional_report_reference_markdown(markdown: str) -> str:
"""Redact renderer-authored implementation references from public Markdown."""
return _PROFESSIONAL_REPORT_REFERENCE_INTERNAL_TEXT.sub(
'internal_reference_omitted',
str(markdown),
)
def _professional_report_reference_boundary_notice() -> dict:
return {
'schema': 'jyotish.reader_engine_boundary_notice.v1',
'status': 'declared',
'primary_text_zh': (
'本参考版汇集本地 Jyotish 计算、已接入的外部引擎回放与资料对照。'
'不同引擎的岁差、节点、宫制、分盘和大运变体可能产生差异;'
'冲突、参数敏感或未验证项目会保留边界标签,不合并成单一确定结论。'
),
'primary_text_en': (
'This reference export combines local Jyotish calculations with available external-engine '
'replays and source comparisons. Engine-specific settings can differ, so conflicts and '
'unverified variants remain explicitly labeled rather than being collapsed into one claim.'
),
'display_rule': '以本报告标注的 executed / blocked / parameter_sensitive 状态为准。',
}
def sanitize_professional_report_reference(value, *, field_name=None):
"""Remove runtime implementation details from the public reference export."""
normalized_field = str(field_name or '').strip().lower()
if normalized_field in _PROFESSIONAL_REPORT_REFERENCE_OMITTED_KEYS or normalized_field.endswith('_path'):
return None
if value is None or isinstance(value, (int, float, bool)):
return value
if isinstance(value, str):
if _PROFESSIONAL_REPORT_REFERENCE_INTERNAL_VALUE.search(value):
return 'internal_reference_omitted'
return value
if isinstance(value, dict):
sanitized = {}
for key, item in value.items():
key_text = str(key)
normalized_key = key_text.strip().lower()
if normalized_key in _PROFESSIONAL_REPORT_REFERENCE_OMITTED_KEYS or normalized_key.endswith('_path'):
continue
sanitized_item = sanitize_professional_report_reference(item, field_name=key_text)
if sanitized_item is not None:
sanitized[key_text] = sanitized_item
return sanitized
if isinstance(value, (list, tuple, set)):
return [
sanitized
for item in value
if (sanitized := sanitize_professional_report_reference(item)) is not None
]
rendered = str(value)
if _PROFESSIONAL_REPORT_REFERENCE_INTERNAL_VALUE.search(rendered):
return 'internal_reference_omitted'
return rendered
def build_professional_report_reference_packet(
full_reading: dict,
args,
selected_pack_ids: list[str] | None = None,
) -> dict:
"""Build the public, non-writer professional reference packet for CLI and API."""
packet = build_pl9_style_export_packet(full_reading, include_raw=False)
packet = attach_calculation_profile(packet, args)
packet = _attach_annual_tajika_pack(packet, args)
packet = _attach_report_governance_contracts(packet, args)
packet = _attach_base_charts_pack(packet)
packet = _attach_visual_chart_pack(packet, args)
selected = _apply_pl9_pack_selection(packet, selected_pack_ids or [])
final_packet = _attach_full_report_pack(selected, args)
final_packet = _attach_startrack_language_bridge(final_packet, args)
final_packet = attach_calculation_profile(final_packet, args)
final_packet = _attach_default_chart_identity(final_packet)
final_packet = _attach_report_governance_contracts(final_packet, args)
final_packet['reader_engine_boundary_notice'] = _professional_report_reference_boundary_notice()
final_packet['generated_at'] = datetime.utcnow().replace(microsecond=0).isoformat() + 'Z'
if final_packet.get('selected_report_scope') == 'full':
evaluate_full_report = _try_attr_import('full_report_quality_gate', 'evaluate_full_report')
if evaluate_full_report is None:
final_packet['report_quality_gate'] = {
'status': 'blocked',
'reason': 'full_report_quality_gate_absent',
}
else:
public_quality_input = sanitize_professional_report_reference(final_packet)
final_packet['report_quality_gate'] = evaluate_full_report(
public_quality_input,
render_pl9_markdown(public_quality_input),
)
build_shared_full_report_authority = _try_attr_import(
'shared_full_report_authority', 'build_shared_full_report_authority'
)
if build_shared_full_report_authority is None:
final_packet['shared_full_report_authority'] = {
'schema_version': 'jyotish.shared_full_report_authority.v1',
'status': 'blocked',
'reason': 'shared_full_report_authority_absent',
'read_only': True,
}
else:
final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet)
return sanitize_professional_report_reference(final_packet)
def cmd_pl9_export(args):
full = cmd_full_reading(args)
# PL9 is the research/authority export surface: never drop the raw reading
# behind an opt-in flag. The legacy flag remains accepted for CLI compatibility.
packet = build_pl9_style_export_packet(full, include_raw=True)
try:
packet = attach_calculation_profile(packet, args)
packet = _attach_annual_tajika_pack(packet, args)
packet = _attach_report_governance_contracts(packet, args)
packet = _attach_base_charts_pack(packet)
packet = _attach_visual_chart_pack(packet, args)
selected = _apply_pl9_pack_selection(packet, _normalize_pl9_pack_selection(args))
final_packet = _attach_full_report_pack(selected, args)
final_packet = _attach_startrack_language_bridge(final_packet, args)
final_packet = attach_calculation_profile(final_packet, args)
final_packet = _attach_default_chart_identity(final_packet)
final_packet = _attach_report_governance_contracts(final_packet, args)
# Added after result hashing so delivery provenance cannot alter chart computation.
final_packet['generated_at'] = datetime.utcnow().replace(microsecond=0).isoformat() + 'Z'
if final_packet.get('selected_report_scope') == 'full':
evaluate_full_report = _try_attr_import("full_report_quality_gate", "evaluate_full_report")
if evaluate_full_report is None:
final_packet['report_quality_gate'] = {
'status': 'blocked',
'reason': 'full_report_quality_gate_absent',
}
else:
reader_markdown_for_quality = render_pl9_markdown(final_packet)
final_packet['report_quality_gate'] = evaluate_full_report(
final_packet,
reader_markdown_for_quality,
)
build_shared_full_report_authority = _try_attr_import(
"shared_full_report_authority", "build_shared_full_report_authority"
)
if build_shared_full_report_authority is None:
final_packet['shared_full_report_authority'] = {
'schema_version': 'jyotish.shared_full_report_authority.v1',
'status': 'blocked',
'reason': 'shared_full_report_authority_absent',
'read_only': True,
}
else:
final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet)
return _build_response_envelope('pl9-export', final_packet, args=args, execution_status='executed')
return build_professional_report_reference_packet(
full,
args,
_normalize_pl9_pack_selection(args),
)
except ValueError as exc:
return {
'scope': 'pl9_export_pack_selection',
'scope': 'professional_report_reference_pack_selection',
'status': 'blocked',
'reason': str(exc),
}
@@ -17273,7 +17385,7 @@ def main():
p.add_argument('--transit-date', default=None, help='Transit真实过境参考日期 YYYY-MM-DD(默认跟随--today或今天)')
p.add_argument('--target-year', type=int, default=None, help='太阳返照盘目标年份(默认不计算 Varshaphala)')
p.add_argument('--profile-stages', action='store_true', help='输出 full-reading 粗粒度阶段耗时,并在 summary 中附带 stage timings')
p.add_argument('--include-raw', action='store_true', help='兼容参数:PL9 导出始终附带原始 full-reading 完整输出')
p.add_argument('--include-raw', action='store_true', help='兼容参数:专业参考版始终省略 raw full-reading 与内部实现信息')
p.add_argument('--format', choices=['json', 'markdown', 'pdf', 'authority'], default='json', help='导出格式:json / markdown / pdf / authority')
p.add_argument('--output', default=None, help='pdf 导出目标路径(仅 --format pdf 生效)')
p.add_argument('--archive-dir', default=None, help='显式指定可复现执行归档目录;不指定时不落盘归档')
+102
View File
@@ -0,0 +1,102 @@
"""Public professional-reference export assembly for the Jyotish API.
This boundary performs no writing-agent, persistence, billing, or telemetry work.
It validates the public export request, reuses the handler's one full-reading
calculation, and delegates packet assembly/rendering to ``jyotish_engine``.
"""
from __future__ import annotations
from importlib import import_module
from types import SimpleNamespace
from typing import Any
class ProfessionalReportReferenceInputError(ValueError):
"""The professional-reference request is outside the public contract."""
def _normalize_format(value: Any) -> str:
if value is None:
return "json"
if not isinstance(value, str):
raise ProfessionalReportReferenceInputError("format must be json or markdown")
normalized = value.strip().lower()
if normalized not in {"json", "markdown"}:
raise ProfessionalReportReferenceInputError("format must be json or markdown")
return normalized
def _normalize_packs(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
raw_items = value.split(",")
elif isinstance(value, list):
if any(not isinstance(item, str) for item in value):
raise ProfessionalReportReferenceInputError("packs must contain only strings")
raw_items = value
else:
raise ProfessionalReportReferenceInputError("packs must be a string or array of strings")
selected: list[str] = []
seen: set[str] = set()
for item in raw_items:
pack_id = item.strip()
if pack_id and pack_id not in seen:
selected.append(pack_id)
seen.add(pack_id)
return selected
def _load_engine():
try:
return import_module("scripts.jyotish_engine")
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
return import_module("jyotish_engine")
def _export_args(birth: dict[str, Any]) -> SimpleNamespace:
normalized_birth = {
**birth,
"hour": int(birth["hour"]),
"minute": int(birth["minute"]),
"second": int(birth.get("second", 0)),
}
return SimpleNamespace(
**normalized_birth,
age=None,
target_year=None,
visual_chart_observations=None,
startrack_language_bridge=False,
)
def build_professional_report_reference(handler, body: dict[str, Any], *, engine=None) -> dict[str, Any]:
"""Build one JSON or Markdown response from one reused full-reading result."""
if not isinstance(body, dict):
raise ProfessionalReportReferenceInputError("request body must be an object")
output_format = _normalize_format(body.get("format"))
packs = _normalize_packs(body.get("packs"))
birth = handler._high_rigor_birth_payload(body)
full_reading = handler._compute_full_reading_for_thematic(birth)
resolved_engine = engine or _load_engine()
try:
packet = resolved_engine.build_professional_report_reference_packet(
full_reading,
_export_args(birth),
packs,
)
except ValueError as exc:
raise ProfessionalReportReferenceInputError(str(exc)) from exc
if output_format == "markdown":
return {
"format": "markdown",
"markdown": resolved_engine.render_pl9_markdown(packet),
}
return {
"format": "json",
"report": packet,
}
+1
View File
@@ -108,6 +108,7 @@ def test_health_and_light_paths_are_not_gated() -> None:
assert not is_heavy_compute_path("/api/high_rigor_workflow/jobs/abc")
assert not is_heavy_compute_path("/api/rectification/v5/versions")
assert is_heavy_compute_path("/api/high_rigor_workflow")
assert is_heavy_compute_path("/api/professional_report_reference")
assert is_heavy_compute_path("/api/rectification/sensitivity_scan")
assert is_heavy_compute_path("/api/vedastro_gateway/jobs/job1/run")
assert "/api/chart" not in HEAVY_COMPUTE_PATHS
@@ -0,0 +1,210 @@
"""Professional-reference export API and public-output contracts."""
from __future__ import annotations
import json
from io import BytesIO
import pytest
from scripts.jyotish_api_server import (
DEFAULT_ALLOWED_HOSTS,
DEFAULT_ALLOWED_ORIGINS,
BadRequest,
JyotishAPIHandler,
)
from scripts.jyotish_engine import (
_professional_report_reference_boundary_notice,
render_pl9_markdown,
sanitize_professional_report_reference,
sanitize_professional_report_reference_markdown,
)
from scripts.professional_report_reference import (
ProfessionalReportReferenceInputError,
build_professional_report_reference,
)
BIRTH = {
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"lat": 39.9042,
"lon": 116.4074,
"tz": 8,
"ayanamsa": "raman",
}
class _ReferenceHandler:
def __init__(self) -> None:
self.full_reading_calls = 0
def _high_rigor_birth_payload(self, body): # noqa: ANN001
return {**BIRTH, "hour": 12.0, "minute": 0.0, "second": 0.0, "node_mode": "mean", "today": None, "transit_date": None}
def _compute_full_reading_for_thematic(self, body): # noqa: ANN001
self.full_reading_calls += 1
return {"modules": {"chart": {"status": "executed"}}, "birth": body}
class _ReferenceEngine:
def __init__(self, *, invalid_pack: bool = False) -> None:
self.invalid_pack = invalid_pack
self.calls = []
def build_professional_report_reference_packet(self, full_reading, args, packs): # noqa: ANN001
assert isinstance(args.hour, int)
assert isinstance(args.minute, int)
assert isinstance(args.second, int)
self.calls.append((full_reading, args, packs))
if self.invalid_pack:
raise ValueError("Unknown professional report pack: secret")
return {
"schema": "pl9_style_professional_export_v1",
"reader_engine_boundary_notice": _professional_report_reference_boundary_notice(),
"selected_report_pack_ids": packs or ["full"],
}
def render_pl9_markdown(self, packet): # noqa: ANN001
return render_pl9_markdown(packet)
def test_json_and_markdown_reuse_one_full_reading_and_normalize_packs() -> None:
handler = _ReferenceHandler()
engine = _ReferenceEngine()
json_result = build_professional_report_reference(
handler,
{**BIRTH, "format": "json", "packs": ["base", "base", "timing"]},
engine=engine,
)
assert json_result["format"] == "json"
assert json_result["report"]["selected_report_pack_ids"] == ["base", "timing"]
assert handler.full_reading_calls == 1
markdown_result = build_professional_report_reference(
handler,
{**BIRTH, "format": "markdown", "packs": "full"},
engine=engine,
)
assert handler.full_reading_calls == 2
assert markdown_result["format"] == "markdown"
assert "## 多引擎口径说明" in markdown_result["markdown"]
assert engine.calls[-1][2] == ["full"]
def test_public_markdown_sanitizer_removes_renderer_authored_internal_references() -> None:
unsafe = (
"| audit | `raw_full_reading.modules.private_engine` |\n"
"source=scripts/private_engine.py::compute path=/Users/example/report.json "
"call=jyotish_engine.cmd_full_reading\n"
)
sanitized = sanitize_professional_report_reference_markdown(unsafe)
assert "internal_reference_omitted" in sanitized
assert "raw_full_reading" not in sanitized
assert "scripts/" not in sanitized
assert ".py::" not in sanitized
assert "/Users/" not in sanitized
assert "jyotish_engine." not in sanitized
def test_public_sanitizer_removes_raw_paths_and_internal_python_references() -> None:
unsafe = {
"raw_full_reading": {"modules": {"private": True}},
"fixture_path": "/Users/example/references/oracle.json",
"nested": {
"source_path": "/private/tmp/capture.json",
"reason": "scripts/yoga_engine.py::YogaContext failed",
"callable": "jyotish_engine.cmd_full_reading",
"plain": "parameter_sensitive",
},
}
sanitized = sanitize_professional_report_reference(unsafe)
rendered = json.dumps(sanitized, ensure_ascii=False)
assert "raw_full_reading" not in rendered
assert "fixture_path" not in rendered
assert "/Users/" not in rendered
assert "scripts/" not in rendered
assert ".py::" not in rendered
assert "jyotish_engine." not in rendered
assert sanitized["nested"]["plain"] == "parameter_sensitive"
assert sanitized["nested"]["reason"] == "internal_reference_omitted"
@pytest.mark.parametrize(
"body, message",
[
({**BIRTH, "format": "pdf"}, "format must be json or markdown"),
({**BIRTH, "packs": {"full": True}}, "packs must be a string or array"),
({**BIRTH, "packs": ["full", 1]}, "packs must contain only strings"),
],
)
def test_invalid_format_or_packs_fail_before_full_reading(body, message) -> None: # noqa: ANN001
handler = _ReferenceHandler()
with pytest.raises(ProfessionalReportReferenceInputError, match=message):
build_professional_report_reference(handler, body, engine=_ReferenceEngine())
assert handler.full_reading_calls == 0
def test_unknown_pack_becomes_public_input_error() -> None:
handler = _ReferenceHandler()
with pytest.raises(ProfessionalReportReferenceInputError, match="Unknown professional report pack"):
build_professional_report_reference(
handler,
{**BIRTH, "packs": ["secret"]},
engine=_ReferenceEngine(invalid_pack=True),
)
assert handler.full_reading_calls == 1
def test_missing_birth_fields_use_consultation_strict_validation() -> None:
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
with pytest.raises(BadRequest, match="missing birth fields: minute"):
build_professional_report_reference(handler, {**BIRTH, "minute": None}, engine=_ReferenceEngine())
class _FakeHeaders(dict):
def get(self, key, default=None): # noqa: ANN001
return super().get(key, default)
class _FakeServer:
allowed_origins = DEFAULT_ALLOWED_ORIGINS
allowed_hosts = DEFAULT_ALLOWED_HOSTS
class _PostCaptureHandler(JyotishAPIHandler):
def __init__(self, payload: dict) -> None:
raw = json.dumps(payload).encode("utf-8")
self.headers = _FakeHeaders({"Content-Length": str(len(raw)), "Content-Type": "application/json"})
self.server = _FakeServer()
self.path = "/api/professional_report_reference"
self.rfile = BytesIO(raw)
self.wfile = BytesIO()
self.status_code = None
self.response_headers = []
self.client_address = ("test-professional-reference", 0)
def send_response(self, code, message=None): # noqa: ANN001
self.status_code = code
def send_header(self, key, value): # noqa: ANN001
self.response_headers.append((key, value))
def end_headers(self):
return None
def payload(self) -> dict:
return json.loads(self.wfile.getvalue().decode("utf-8"))
def test_http_endpoint_maps_invalid_format_to_bad_request() -> None:
handler = _PostCaptureHandler({**BIRTH, "format": "pdf"})
handler.do_POST()
assert handler.status_code == 400
assert handler.payload()["error_code"] == "ERR_BAD_REQUEST"