feat(reports): add professional reference export
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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='显式指定可复现执行归档目录;不指定时不落盘归档')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user