From dd4141a0308366556b603c205a2748299c8d02b4 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 22 Sep 2026 12:08:16 +0800 Subject: [PATCH] fix(report): project ordinary reports onto public prose only --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 16 + ...PROGRESS-report-public-content-20260922.md | 44 + .../testing/report-public-content-20260922.md | 10 + frontend/docs/VOICE.md | 4 + .../professional-reference/route.ts | 4 +- frontend/src/app/api/reports/route.ts | 8 +- .../personal-report-center.tsx | 4 +- .../personal-report/personal-report-page.tsx | 4 +- .../src/lib/consultation-report-export.ts | 27 +- .../lib/personal-report-longform-download.ts | 29 +- .../src/lib/personal-report-route-core.ts | 14 +- frontend/src/lib/report-public-projection.ts | 917 ++++++++++++++++++ .../tests/consultation-report-export.test.ts | 16 +- frontend/tests/personal-report-entry.test.ts | 12 +- ...rofessional-report-reference-route.test.ts | 6 +- .../tests/report-public-projection.test.ts | 330 +++++++ 17 files changed, 1401 insertions(+), 48 deletions(-) create mode 100644 docs/tasks/PROGRESS-report-public-content-20260922.md create mode 100644 docs/testing/report-public-content-20260922.md create mode 100644 frontend/src/lib/report-public-projection.ts create mode 100644 frontend/tests/report-public-projection.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db1b906..d49ed2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ 首屏如果一直停在载入账户,大约 13 秒后换成一句说明和「重新加载」,不再只转圈。不会自动刷新。旧浏览器上本仓断句不再使用正则后行断言。Skill 版本不变。 +## 2026-09-22 — 普通报告只导出可读正文 + +聊天导出、报告阅读和 Markdown 下载不再带出技法、工作流、评分和模型调试字段。必须保留的限制改写成白话。专业参考接口单独标明,普通下载不再走那条路。Skill 版本不变。 + ## 2026-09-21 — 侧栏当前会话是一整条高亮 选中或悬停会话时,标题和右边的「⋯」共用一块底,不再拆成两个块。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d90318f4..bb279438 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13231,3 +13231,19 @@ - 相关记录:BUG-936 - 复发自:无 - 修复版本:未发布(本分支未推送) + +## BUG-999 | 普通报告导出仍带出内部技法与工作流字段 + +- 状态:resolved(本地候选,未部署) +- 首次发现:2026-09-22 +- 最近更新:2026-09-22 +- 影响面:咨询 Markdown 导出、个人报告阅读、普通 Markdown 下载、`GET /api/reports/:id` 与报告列表摘要。聊天消息行不在本次回归里。 +- 用户现象:聊天里已经不显示内部证据徽章,但导出的报告和已存 Markdown 仍可能出现 `technique_truth`、`workflow_route`、`workflow_status`、`precise_timing`、`missing_layers`,以及评分、内部地址、密钥样式字段、模型调试和 job/attempt/provider 元数据。 +- 触发条件:从咨询会话复制或导出报告;打开或下载一份已经写好的个人长报告,包括命中旧缓存 Markdown 的情况。 +- 根因:聊天层按 BUG-011 隐藏了这些字段,报告阅读、导出和 API 没有同一套普通用户 allowlist。普通下载还把 `/professional-reference` 当成正文来源。可见性不能靠接口名字判断。 +- 修复:新增 `frontend/src/lib/report-public-projection.ts`。文档类型显式区分聊天导出、个人报告详情、普通 Markdown 下载和专业参考。普通输出只保留正文、结论、行动建议、自然语言限制、图盘围栏和安全的引擎 SVG。内部限制改写成白话,不写出内部键。阅读 API、详情分类、普通下载和列表摘要都走这层投影。专业参考响应标明 `documentKind: professional_reference`;本轮没有单独专业权限,所以它的正文同样 fail-safe 到普通投影。普通下载改为读 `GET /api/reports/:id`,不再请求专业参考。 +- 验证:`frontend/tests/report-public-projection.test.ts` 覆盖字段在与不在、旧缓存、专业参考种类、图盘围栏、HTML/script/iframe/object/embed/img、危险 URL,以及普通输出不含内部 URL、密钥和模型字段。`consultation-report-export`、`personal-report-longform-md`、报告归属与 ready 检查的测试名保留。`tsc --noEmit` 通过,`npm run lint` 0 error。没有登录态,浏览器导出未验收,见 `docs/testing/report-public-content-20260922.md`。 +- 防复发:普通报告的公开字段以 allowlist 为准,不能只靠删除若干字符串。缓存命中和已存 Markdown 必须再过投影。专业参考不得再被普通下载当作 fallback。不得放宽 Markdown 的 XSS、危险 URL、HTML 和 `jyotish-chart` 围栏合同。 +- 相关记录:BUG-011、BUG-058、BUG-188 +- 复发自:无 +- 修复版本:本分支 `fix(report): project ordinary reports onto public prose only`(未推送,未部署) diff --git a/docs/tasks/PROGRESS-report-public-content-20260922.md b/docs/tasks/PROGRESS-report-public-content-20260922.md new file mode 100644 index 00000000..2efaaef3 --- /dev/null +++ b/docs/tasks/PROGRESS-report-public-content-20260922.md @@ -0,0 +1,44 @@ +# 进度 · 普通用户报告公开内容分层(2026-09-22) + +基线:`origin/staging` `10baeb2fa865743c428806e115ac92ca9d92fb71`。分支 `codex/report-public-content-20260922`。未推送。 + +聊天消息行仍然不渲染 `techniqueTruth`、`workflowReceipt`、证据徽章和单条下载按钮(`chat-message-row.tsx` 无这些符号;`consultation-report-export.test.ts` 的「assistant answer does not expose internal report controls」仍在)。漏洞在导出和报告 API。 + +## 投影 + +- 新增 `frontend/src/lib/report-public-projection.ts`。文档类型是显式参数:`chat_export`、`personal_report_detail`、`ordinary_markdown_download`、`professional_reference`。不根据路由名决定可见性。 +- 普通 allowlist:`title`、`prose`、`conclusion`、`action`、`limitation`、`chart_fence`、`engine_svg`。 +- 内部字段不序列化:`technique_truth`、`workflow_route`、`workflow_status`、`precise_timing`、`missing_layers`、评分/权重/执行账本、内部 URL、secret、模型调试、raw tool response、job/attempt/provider。 +- 限制改写成白话,不带内部键。没有这些信号时不补限制段。 + +## 接入 + +- `consultationReportMarkdown` 只走聊天导出投影。 +- `resolveReportRead` 的 `longformMarkdown` 与返回给浏览器的 `reportDocument` 走投影。持久化行不改。 +- `classifyReportEnvelope` 在详情阅读前再投影一次,旧响应也不能直接上屏。 +- 普通下载读 `GET /api/reports/:id`,并用 `ordinaryReportDownloadMarkdown` 投影后再去掉 `jyotish-chart` 围栏。不再请求 professional-reference。 +- 列表 `card_summary` 走同一段公开正文投影。 +- professional-reference 仍是独立入口,响应 `documentKind` 为 `professional_reference`。本轮没有单独专业权限,正文 fail-safe 到普通投影,避免只靠接口名字放行内部字段。 + +## 断言变化 + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `consultation-report-export.test.ts`「exports latest consultation answer…」 | 匹配 `technique_truth: partial`、`workflow_route: career`、`precise_timing: blocked`、`missing_layers: MEVG`、`未闭环内容不得包装成确定预测` | 仍匹配标题和「先看阶段」;匹配白话限制;不匹配上述内部键、`MEVG`、`career`、`Claim boundary` | 这些内部字段不再是普通导出的预期结果 | +| `personal-report-entry.test.ts`「legacy consultation Markdown export…」 | 匹配 `workflow_route: career`、`precise_timing: blocked` | 匹配「先看阶段」和「这次说不到具体哪一天」;不匹配 `workflow_route`、`precise_timing`、`technique_truth`、`MEVG` | 同一导出合同,不能继续把泄漏当回归锁 | +| `personal-report-entry.test.ts`「ready reports expose Markdown export…」 | `longformDownloadSource` 匹配 `professional-reference` | 不匹配 `professional-reference`;匹配 `projectOrdinaryReportMarkdown` 和 `GET /api/reports/:id` | 普通下载不得再把专业参考当 fallback | +| `professional-report-reference-route.test.ts`「cached appendix returns markdown…」 | `{ format: "markdown", markdown: "# Professional reference" }` | 增加 `documentKind: "professional_reference"`,markdown 仍是该句 | 响应必须显式标成专业参考;这条夹具没有内部字段,正文保持原样 | + +`personal-report-longform-md.test.ts` 的 XSS / 表格 / `skipHtml` 断言未改。报告归属与 ready 检查的测试名未改,`{ ok: true }` 回放文档不是报告形状,原样返回。 + +## 验证 + +- `frontend`:`.\node_modules\.bin\tsc --noEmit` 退出码 0。 +- `npm run lint`:0 error,119 条既有 warning(未改;新投影文件无 warning)。 +- 定向 `npx tsx --test`:`report-public-projection` 9、`consultation-report-export` 2、`personal-report-entry` 20、`personal-report-longform-md` 7、`personal-report-view` 23、`report-chart-block` 8、`professional-report-reference-route` 4、`personal-report-api` 58,全部 fail 0。 +- 未跑全量 `npm test` 和 `next build`。 +- 无登录态,浏览器导出未验收:`docs/testing/report-public-content-20260922.md`。 + +## Bug + +BUG-999,resolved(本地候选,未部署)。相关记录 BUG-011、BUG-058、BUG-188。聊天层隐藏仍在,本条记导出/API 洞,不把旧记录改成复发。 diff --git a/docs/testing/report-public-content-20260922.md b/docs/testing/report-public-content-20260922.md new file mode 100644 index 00000000..09d02e53 --- /dev/null +++ b/docs/testing/report-public-content-20260922.md @@ -0,0 +1,10 @@ +# 普通报告公开内容:浏览器导出未在本轮验收 + +本轮只在工作树里跑了类型检查、lint 和定向 node:test。没有登录态,也没有打开浏览器。 + +下面两项不能写成通过,需要一位已登录的人在 staging 上对照一份**合成或新生成**的报告再看: + +- [ ] 打开 `/reports/`,正文里看不到 `technique_truth`、`workflow_route`、`workflow_status`、`precise_timing`、`missing_layers`、评分或模型调试字段。 +- [ ] 点「导出报告(.md)」,下载文件同样没有这些字段;`jyotish-chart` 围栏不在文件里,引擎 SVG 还在。旧报告也要抽一份看,不能只看新生成的。 + +专业参考接口不是这个按钮的下载来源。本轮没有用真实账号调用它。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index f19a4096..927ce38a 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -18,6 +18,10 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。 +## 报告里的限制说明 + +普通报告只留下人能读的正文、结论、行动建议和必要限制。不写字段名、状态码、评分、权重或执行账本。说不到日期就写「这次说不到具体哪一天。」依据没补上就写「有些依据还没补上,相关说法不能当成确定预测。」判断没闭合就写「有些判断还没闭合,不能写成确定结论。」不出现 `technique_truth`、`workflow_route` 这类键。 + ## 寒暄只回一句 普通对话里的纯打招呼、道谢、告别,只回一句简体白话,最多 20 字,不以句号结尾;不套开场形状,不写星盘或运势主张,不用客服套话、星月比喻或一串追问。本命、申报时段、无出生分钟三种模式共用。含咨询、解释前文、纠错、抱怨或不确定意图时仍按咨询处理。 diff --git a/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts b/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts index 234cdd83..18cbdfbe 100644 --- a/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts +++ b/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts @@ -5,6 +5,7 @@ import { parseLongformAppendixRow, } from "@/lib/personal-report-longform-appendix"; import { PERSONAL_REPORT_LEGACY_PLACEHOLDER } from "@/lib/personal-report-longform-copy"; +import { releaseProfessionalReference } from "@/lib/report-public-projection"; import { checkSameOrigin, resolveAllowedReportOrigins, @@ -60,7 +61,8 @@ export async function POST(request: Request, context: RouteContext) { .maybeSingle(); const cached = appendixRead.error ? null : parseLongformAppendixRow(appendixRead.data); if (cached?.status === "ready" && cached.markdown) { - return NextResponse.json({ format: "markdown", markdown: cached.markdown }); + // Kind is explicit. The path name is not a permission to return internal fields. + return NextResponse.json(releaseProfessionalReference(cached.markdown)); } return NextResponse.json( { error: PERSONAL_REPORT_LEGACY_PLACEHOLDER, code: "legacy_report" }, diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index bf87e8df..b36307c5 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -29,6 +29,7 @@ import { loadReportCandidateRange } from "@/lib/report-candidate-range"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing"; import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing"; +import { projectOrdinarySnippet } from "@/lib/report-public-projection"; import { sanitizedErrorReason } from "@/lib/safe-error-reason"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -64,6 +65,9 @@ function listReportView( failure?: ReturnType | null, ) { const row = value && typeof value === "object" ? value as Record : {}; + const cardSummary = typeof row.card_summary === "string" + ? projectOrdinarySnippet(row.card_summary).slice(0, 240) + : ""; return { id: typeof row.id === "string" ? row.id : "", requestId: typeof row.request_id === "string" ? row.request_id : "", @@ -79,9 +83,7 @@ function listReportView( completedAt: row.completed_at == null || row.completed_at === "" ? null : reportListTimestamp(row.completed_at) || null, - ...(typeof row.card_summary === "string" && row.card_summary.trim() - ? { cardSummary: row.card_summary.trim().slice(0, 240) } - : {}), + ...(cardSummary ? { cardSummary } : {}), ...(failure?.summary ? { failureSummary: failure.summary } : {}), }; } diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index e89c4df3..b5178e76 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -124,7 +124,7 @@ export function PersonalReportCenter() { [state.reports], ); - const downloadProfessionalReference = useCallback(async (report: ReportListItem) => { + const downloadOrdinaryReport = useCallback(async (report: ReportListItem) => { setExportingReportId(report.id); setExportError(null); try { @@ -222,7 +222,7 @@ export function PersonalReportCenter() { type="button" variant="ghost" disabled={exportingReportId === report.id} - onClick={() => void downloadProfessionalReference(report)} + onClick={() => void downloadOrdinaryReport(report)} > {exportingReportId === report.id ? : null} {PERSONAL_REPORT_EXPORT_LABEL} diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index 5fdbece8..b016abda 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -25,6 +25,7 @@ import { PERSONAL_REPORT_GENERATING_COPY, PERSONAL_REPORT_LEGACY_PLACEHOLDER, } from "@/lib/personal-report-longform-copy"; +import { projectOrdinaryReportMarkdown } from "@/lib/report-public-projection"; import { describeReportProgress, REPORT_PROGRESS_STALL_MS, @@ -121,7 +122,8 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor const view = json.report as Partial; switch (view.status) { case "ready": { - const markdown = typeof json.longformMarkdown === "string" ? json.longformMarkdown.trim() : ""; + const stored = typeof json.longformMarkdown === "string" ? json.longformMarkdown : ""; + const markdown = projectOrdinaryReportMarkdown(stored).trim(); if (markdown.length > 0) { return { phase: "markdown-ready", diff --git a/frontend/src/lib/consultation-report-export.ts b/frontend/src/lib/consultation-report-export.ts index 7d12ad72..501e4f72 100644 --- a/frontend/src/lib/consultation-report-export.ts +++ b/frontend/src/lib/consultation-report-export.ts @@ -1,4 +1,5 @@ import type { ChatMessage } from "./chat-message-view"; +import { projectChatExportMarkdown } from "./report-public-projection"; export function consultationReportMarkdown(input: { title: string; @@ -6,22 +7,16 @@ export function consultationReportMarkdown(input: { }) { const latestAssistant = [...input.messages].reverse().find((message) => message.role === "assistant"); const evidence = latestAssistant?.workflowReceipt; - return [ - `# ${input.title}`, - "", - "## 最新回答", - latestAssistant?.text || "暂无回答。", - "", - "## Claim boundary", - `technique_truth: ${latestAssistant?.techniqueTruth || "unknown"}`, - evidence ? `workflow_route: ${evidence.route}` : "workflow_route: unknown", - evidence ? `workflow_status: ${evidence.status}` : "workflow_status: unknown", - evidence ? `precise_timing: ${evidence.preciseTiming}` : "precise_timing: unknown", - evidence ? `missing_layers: ${evidence.missingLayers.join(" / ") || "none"}` : "missing_layers: unknown", - "", - "## Boundary", - "本报告保留证据边界;未闭环内容不得包装成确定预测。", - ].join("\n"); + // Route is an internal field. It is not passed into the ordinary export. + return projectChatExportMarkdown({ + documentKind: "chat_export", + title: input.title, + prose: latestAssistant?.text ?? "", + techniqueTruth: latestAssistant?.techniqueTruth ?? null, + workflowStatus: evidence?.status ?? null, + preciseTiming: evidence?.preciseTiming ?? null, + missingLayers: evidence?.missingLayers ?? null, + }); } export function downloadMarkdownReport(title: string, markdown: string) { diff --git a/frontend/src/lib/personal-report-longform-download.ts b/frontend/src/lib/personal-report-longform-download.ts index a757eae2..4375f522 100644 --- a/frontend/src/lib/personal-report-longform-download.ts +++ b/frontend/src/lib/personal-report-longform-download.ts @@ -2,26 +2,33 @@ import { downloadMarkdownReport } from "./consultation-report-export"; import { PERSONAL_REPORT_LEGACY_PLACEHOLDER } from "./personal-report-longform-copy"; import { personalReportMarkdownFilename } from "./personal-report-longform-outline"; import { stripReportChartBlocks } from "./report-chart-block"; +import { projectOrdinaryReportMarkdown } from "./report-public-projection"; + +/** + * Ordinary download reads GET /api/reports/:id and always projects. + * A stored or cached string is not trusted as already public. + */ +export function ordinaryReportDownloadMarkdown(markdown: string): string { + return stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown)); +} export async function requestPersonalReportLongformAppendix(reportId: string): Promise { - const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, { - method: "POST", + const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}`, { + method: "GET", credentials: "same-origin", headers: { Accept: "application/json" }, }); const result: unknown = await response.json().catch(() => null); const payload = result && typeof result === "object" ? result as Record : {}; - if ( - !response.ok - || payload.format !== "markdown" - || typeof payload.markdown !== "string" - || !payload.markdown.trim() - ) { + const markdown = typeof payload.longformMarkdown === "string" ? payload.longformMarkdown : ""; + if (!response.ok || !markdown.trim()) { throw new Error( - typeof payload.error === "string" ? payload.error : PERSONAL_REPORT_LEGACY_PLACEHOLDER, + typeof payload.error === "string" && payload.error.trim() + ? payload.error + : PERSONAL_REPORT_LEGACY_PLACEHOLDER, ); } - return payload.markdown; + return projectOrdinaryReportMarkdown(markdown); } export async function downloadPersonalReportLongformAppendix( @@ -33,6 +40,6 @@ export async function downloadPersonalReportLongformAppendix( : await requestPersonalReportLongformAppendix(reportId); downloadMarkdownReport( personalReportMarkdownFilename(options.reportDate), - stripReportChartBlocks(markdown), + ordinaryReportDownloadMarkdown(markdown), ); } diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 9a478b4a..67957717 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -34,6 +34,10 @@ import { } from "./personal-report-progress"; import type { ReportBillingPort } from "./personal-report-billing"; import { checkSameOrigin } from "./personal-report-entitlement"; +import { + projectOrdinaryReportDocument, + projectOrdinaryReportMarkdown, +} from "./report-public-projection"; import type { PersonalReportJobRecord, PersonalReportJobService } from "./personal-report-job-service-core"; import type { CreateGeneratingInput, @@ -178,7 +182,7 @@ function replayOrConflict( if (existing.status === "ready") { return { status: 200, - body: { report: reportView(existing), reportDocument: existing.reportDocument }, + body: { report: reportView(existing), reportDocument: projectOrdinaryReportDocument(existing.reportDocument) }, }; } if (existing.status === "generating") { @@ -582,7 +586,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise\s*)?(?:[-*+]\s+|\d+\.\s+)?(?:\*\*|__)?["'`]?([A-Za-z][A-Za-z0-9_-]*)["'`]?(?:\*\*|__)?\s*[:=]\s*(.*?)\s*$/; + +const SECRET_PATTERNS = [ + /\bsk-[A-Za-z0-9]{8,}\b/g, + /\bsk_(?:live|test)_[A-Za-z0-9]+\b/g, + /\bBearer\s+[A-Za-z0-9._-]{8,}\b/gi, + /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, + /\b(?:api[_-]?key|secret|password|access[_-]?token)\s*[:=]\s*\S+/gi, + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + /\bSUPABASE_SERVICE_ROLE_KEY\b/g, + /\bAUTH_SECRET\b/g, +]; + +const BARE_URL = /\b(?:https?|javascript|vbscript|data|file|blob):[^\s)<>\]]+/gi; +const WINDOWS_PATH = /\b[A-Za-z]:\\[^\s)]+/g; +const UNIX_PRIVATE_PATH = /(?:^|[\s(])\/(?:opt|var|home|Users|root|tmp|srv)\/[^\s)]*/g; + +const SVG_TAGS = new Set([ + "svg", "g", "path", "line", "circle", "rect", "polygon", "polyline", + "text", "tspan", "title", "desc", "defs", "use", "ellipse", +]); + +const DROP_WITH_CONTENT = new Set(["script", "style", "iframe", "object"]); +const DROP_TAG = new Set(["img", "embed", "script", "style", "iframe", "object"]); + +const PUBLIC_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + +export const ORDINARY_OUTPUT_LEAK_PATTERNS: readonly RegExp[] = [ + /\btechnique_truth\b/i, + /\bworkflow_route\b/i, + /\bworkflow_status\b/i, + /\bprecise_timing\b/i, + /\bmissing_layers\b/i, + /\bexecution_ledger\b/i, + /\bexecution_receipt\b/i, + /\bprovider_payload\b/i, + /\btool_call_id\b/i, + /\btool_result\b/i, + /\bmodel_debug\b/i, + /\bjob_id\b/i, + /\battempt_count\b/i, + /\bprompt_tokens\b/i, + /\bfinish_reason\b/i, + /\bsystem_prompt\b/i, + /\bparameter_sensitive\b/i, + /\bMEVG\b/, + /javascript:/i, + /vbscript:/i, + /data:text\/html/i, + /\bfile:/i, + /127\.0\.0\.1/, + /\blocalhost\b/i, + /metadata\.google\.internal/i, + /\bsk-[A-Za-z0-9]{8,}/, + /\bBearer\s+/i, + /\bSUPABASE_SERVICE_ROLE_KEY\b/, + /<\s*script\b/i, + /<\s*iframe\b/i, + /<\s*object\b/i, + /<\s*embed\b/i, + /<\s*img\b/i, +]; + +export function ordinaryOutputLeaks(text: string): string[] { + return ORDINARY_OUTPUT_LEAK_PATTERNS.filter((pattern) => pattern.test(text)).map((pattern) => pattern.source); +} + +export function isOrdinaryReportDocumentKind(kind: ReportDocumentKind): kind is OrdinaryReportDocumentKind { + return kind !== "professional_reference"; +} + +export type ReportMarkdownEnvelope = Readonly<{ + documentKind: ReportDocumentKind; + format: "markdown"; + markdown: string; +}>; + +export type ChatExportInput = Readonly<{ + documentKind?: "chat_export"; + title: string; + prose: string; + techniqueTruth?: string | null; + workflowStatus?: string | null; + preciseTiming?: string | null; + missingLayers?: readonly string[] | null; +}>; + +type Analysis = { + prose: string; + limitations: string[]; + changed: boolean; +}; + +type LineSlice = { text: string; raw: string }; + +type Block = { + type: "fence" | "heading" | "html" | "table" | "list" | "paragraph" | "blank"; + raw: string; + text: string; + level?: number; + lang?: string; + tag?: string; +}; + +export function projectChatExportMarkdown(input: ChatExportInput): string { + const analyzed = analyzeOrdinaryReportMarkdown(input.prose.trim()); + const prose = analyzed.prose.trim() || "暂无回答。"; + const limitations = orderedLimitations([ + ...analyzed.limitations, + ...limitationsFromSignals(input), + ]); + const lines = [`# ${publicTitle(input.title)}`, "", "## 最新回答", prose]; + if (limitations.length > 0) { + lines.push("", `## ${LIMITATION_HEADING}`, "", ...limitations); + } + return lines.join("\n").trim(); +} + +export function projectOrdinaryReportMarkdown(markdown: string): string { + const analyzed = analyzeOrdinaryReportMarkdown(markdown); + if (!analyzed.changed && analyzed.limitations.length === 0) return markdown; + return appendLimitations(analyzed.prose, analyzed.limitations); +} + +export function projectOrdinarySnippet(text: string): string { + return analyzeOrdinaryReportMarkdown(text).prose.trim(); +} + +export function projectReportEnvelope(input: { + documentKind: ReportDocumentKind; + markdown: string; +}): ReportMarkdownEnvelope { + return { + documentKind: input.documentKind, + format: "markdown", + markdown: projectOrdinaryReportMarkdown(input.markdown), + }; +} + +/** Explicit professional-reference envelope. Content still uses the ordinary projection. */ +export function releaseProfessionalReference(markdown: string): ReportMarkdownEnvelope { + return projectReportEnvelope({ + documentKind: "professional_reference", + markdown, + }); +} + +export function projectOrdinaryReportDocument(document: unknown): unknown { + if (!isReportShaped(document)) return document; + const limitations = new Set(); + const summary = readRecord(document.executiveSummary); + const sections = [ + ...narrativeSections(document.thematicNarrative, limitations), + ...foundationSection(document.natalFoundation, limitations), + ...phaseSection(document.currentPhase, limitations), + ]; + const actions = actionNotes(document.actionNotes, limitations); + for (const disclosure of recordList(document.blockedConflictDisclosure)) { + const reason = takeText(disclosure.reason, limitations); + if (reason) limitations.add(ORDINARY_LIMITATION_COPY.techniqueOpen); + if (Array.isArray(disclosure.missingEvidence) && disclosure.missingEvidence.length > 0) { + limitations.add(ORDINARY_LIMITATION_COPY.missingEvidence); + } + } + return { + documentKind: "personal_report_detail" as const, + headline: takeText(summary?.headline, limitations), + summary: takeText(summary?.summary, limitations), + priorities: takeTextList(summary?.priorities, limitations), + sections, + actions, + limitations: orderedLimitations(limitations), + disclaimer: takeText(document.disclaimer, limitations), + }; +} + +function limitationsFromSignals(input: ChatExportInput): string[] { + const notes: string[] = []; + const timing = normalizeToken(input.preciseTiming); + if (timing && /blocked|denied|false|unavailable|forbidden/.test(timing)) { + notes.push(ORDINARY_LIMITATION_COPY.preciseTiming); + } + if (input.missingLayers && input.missingLayers.some((layer) => layer.trim().length > 0)) { + notes.push(ORDINARY_LIMITATION_COPY.missingEvidence); + } + const truth = normalizeToken(input.techniqueTruth); + if (truth && /partial|blocked|degraded|not_applicable|not-applicable|unverified|unknown/.test(truth)) { + notes.push(ORDINARY_LIMITATION_COPY.techniqueOpen); + } + const status = normalizeToken(input.workflowStatus); + if (status && /blocked|degraded|failed|partial|error|incomplete/.test(status)) { + notes.push(ORDINARY_LIMITATION_COPY.statusLimited); + } + return notes; +} + +function analyzeOrdinaryReportMarkdown(markdown: string): Analysis { + if (!markdown) return { prose: markdown, limitations: [], changed: false }; + const blocks = parseBlocks(markdown); + const limitations = new Set(); + const kept: string[] = []; + let changed = false; + let skipUntilLevel: number | null = null; + + for (const block of blocks) { + if (block.type === "heading") { + const level = block.level ?? 1; + if (skipUntilLevel !== null && level <= skipUntilLevel) skipUntilLevel = null; + if (skipUntilLevel !== null) { + changed = true; + collectLimitations(block.raw, limitations); + continue; + } + if (isInternalHeading(block.text) || containsInternalToken(block.text)) { + skipUntilLevel = level; + changed = true; + collectLimitations(block.raw, limitations); + continue; + } + } else if (skipUntilLevel !== null) { + changed = true; + collectLimitations(block.raw, limitations); + continue; + } + + if (block.type === "fence") { + if (block.lang === "jyotish-chart" && fenceIsPublic(block.raw)) { + kept.push(block.raw); + continue; + } + changed = true; + collectLimitations(block.raw, limitations); + continue; + } + + if (block.type === "html") { + if (block.tag === "svg" && isAllowlistedSvg(block.raw)) { + kept.push(block.raw); + continue; + } + changed = true; + collectLimitations(block.raw, limitations); + continue; + } + + if (block.type === "blank") { + kept.push(block.raw); + continue; + } + + if (block.type === "table" && tableIsInternal(block.text)) { + changed = true; + collectLimitations(block.raw, limitations); + continue; + } + + const projected = projectPreservedBlock(block.raw); + if (projected.changed) changed = true; + for (const note of projected.limitations) limitations.add(note); + if (projected.text) kept.push(projected.text); + } + + if (skipUntilLevel !== null) changed = true; + const prose = changed ? collapseBlankLines(kept.join("")) : markdown; + return { prose, limitations: orderedLimitations(limitations), changed }; +} + +function projectPreservedBlock(raw: string): { text: string; changed: boolean; limitations: string[] } { + const limitations = new Set(); + const lines = splitLines(raw); + const kept: string[] = []; + let changed = false; + for (const line of lines) { + const projected = projectProseLine(line.text); + for (const note of projected.limitations) limitations.add(note); + if (!projected.keep) { + changed = true; + continue; + } + if (projected.text !== line.text) { + changed = true; + const ending = line.raw.endsWith("\r\n") ? "\r\n" : line.raw.endsWith("\n") ? "\n" : ""; + kept.push(`${projected.text}${ending}`); + continue; + } + kept.push(line.raw); + } + return { text: kept.join(""), changed, limitations: [...limitations] }; +} + +function projectProseLine(line: string): { keep: boolean; text: string; limitations: string[] } { + if (line.trim() === "") return { keep: true, text: line, limitations: [] }; + const field = FIELD_LINE.exec(line); + if (field && isInternalKey(field[1])) { + return { keep: false, text: "", limitations: notesForField(field[1], field[2] ?? "") }; + } + const tokenAt = line.search(INTERNAL_KEY_TOKEN); + const limitations = tokenAt >= 0 ? notesFromText(line.slice(tokenAt)) : []; + const visible = tokenAt >= 0 ? line.slice(0, tokenAt) : line; + const cleaned = projectInline(visible); + if (!cleaned.trim()) return { keep: false, text: "", limitations }; + if (containsInternalToken(cleaned) || ordinaryOutputLeaks(cleaned).length > 0) { + return { keep: false, text: "", limitations }; + } + return { keep: true, text: cleaned, limitations }; +} + +function projectInline(text: string): string { + let next = text.replace(//g, ""); + next = next.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_match, alt: string) => stripSecrets(stripHtml(String(alt))).trim()); + next = next.replace(/\[([^\]]*)\]\(([^)]+)\)/g, (_match, label: string, url: string) => { + const publicLabel = stripSecrets(stripHtml(String(label))).trim(); + return isPublicUrl(String(url)) ? `[${publicLabel}](${String(url).trim()})` : publicLabel; + }); + next = stripHtml(next); + next = stripSecrets(next); + next = next.replace(BARE_URL, (url) => (isPublicUrl(url) ? url : "")); + next = next.replace(WINDOWS_PATH, ""); + next = next.replace(UNIX_PRIVATE_PATH, " "); + return next.replace(/[ \t]{2,}/g, " ").replace(/[ \t]+$/g, "").replace(/^[ \t]+/g, ""); +} + +function notesForField(key: string, value: string): string[] { + const normalizedKey = normalizeKey(key); + const normalized = normalizeToken(value); + if (normalizedKey === "precise_timing" && normalized && /blocked|denied|false|unavailable|forbidden/.test(normalized)) { + return [ORDINARY_LIMITATION_COPY.preciseTiming]; + } + if (normalizedKey === "missing_layers" && normalized && !/^(none|unknown|n\/a|null|\[\]|\{\})$/.test(normalized)) { + return [ORDINARY_LIMITATION_COPY.missingEvidence]; + } + if (normalizedKey === "technique_truth" && normalized && /partial|blocked|degraded|not_applicable|unverified|unknown/.test(normalized)) { + return [ORDINARY_LIMITATION_COPY.techniqueOpen]; + } + if (normalizedKey === "workflow_status" && normalized && /blocked|degraded|failed|partial|error|incomplete/.test(normalized)) { + return [ORDINARY_LIMITATION_COPY.statusLimited]; + } + return []; +} + +function notesFromText(text: string): string[] { + const notes: string[] = []; + const field = FIELD_LINE.exec(text.trim()); + if (field) notes.push(...notesForField(field[1], field[2] ?? "")); + if (/\bmissing_layers\b|\bMEVG\b/i.test(text)) notes.push(ORDINARY_LIMITATION_COPY.missingEvidence); + if (/\bprecise_timing\b/i.test(text) && /blocked|denied|false/i.test(text)) { + notes.push(ORDINARY_LIMITATION_COPY.preciseTiming); + } + if (/\btechnique_truth\b|\bparameter_sensitive\b/i.test(text)) notes.push(ORDINARY_LIMITATION_COPY.techniqueOpen); + if (/\bworkflow_status\b/i.test(text) && /blocked|degraded|failed|partial/i.test(text)) { + notes.push(ORDINARY_LIMITATION_COPY.statusLimited); + } + return notes; +} + +function collectLimitations(text: string, into: Set) { + for (const line of text.split(/\r?\n/)) { + for (const note of projectProseLine(line).limitations) into.add(note); + for (const note of notesFromText(line)) into.add(note); + } +} + +function appendLimitations(prose: string, limitations: readonly string[]): string { + const notes = orderedLimitations(limitations).filter((note) => !prose.includes(note)); + const body = prose.replace(/\s+$/u, ""); + if (notes.length === 0) return body; + const section = [`## ${LIMITATION_HEADING}`, "", ...notes].join("\n"); + return body ? `${body}\n\n${section}` : section; +} + +function orderedLimitations(notes: Iterable): string[] { + const present = new Set(notes); + return LIMITATION_ORDER.filter((note) => present.has(note)); +} + +function publicTitle(title: string): string { + const line = projectInline(title.replace(/[\r\n]+/g, " ")).replace(/^#+\s*/, "").trim(); + return line || "咨询报告"; +} + +function isInternalKey(key: string): boolean { + return INTERNAL_KEYS.has(normalizeKey(key)); +} + +function normalizeKey(key: string): string { + return key.trim().toLowerCase().replace(/-/g, "_"); +} + +function normalizeToken(value: string | null | undefined): string { + return (value ?? "").trim().toLowerCase().replace(/^["'`[\]]+|["'`[\]]+$/g, "").replace(/-/g, "_"); +} + +function isInternalHeading(text: string): boolean { + const normalized = text.trim().toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " "); + return INTERNAL_HEADINGS.has(normalized); +} + +function containsInternalToken(text: string): boolean { + return INTERNAL_KEY_TOKEN.test(text); +} + +function fenceIsPublic(raw: string): boolean { + if (containsInternalToken(raw)) return false; + if (ordinaryOutputLeaks(raw).length > 0) return false; + if (/<\s*(?:script|iframe|object|embed|img)\b/i.test(raw)) return false; + return true; +} + +function tableIsInternal(text: string): boolean { + if (containsInternalToken(text) || ordinaryOutputLeaks(text).length > 0) return true; + const cells = text.split("|").map((cell) => cell.trim()).filter(Boolean); + return cells.some((cell) => isInternalKey(cell) || /^(?:score|weight|provider|attempt|job|model)$/i.test(cell)); +} + +function isPublicUrl(raw: string): boolean { + const value = raw.trim(); + if (value.startsWith("#") && !value.includes(":")) return true; + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (!PUBLIC_PROTOCOLS.has(url.protocol)) return false; + if (url.username || url.password) return false; + if (url.port === "5200") return false; + if (/[?&](?:api[_-]?key|token|secret|password)=/i.test(url.search)) return false; + return !isInternalHost(url.hostname); +} + +function isInternalHost(hostname: string): boolean { + const host = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if ( + host === "localhost" + || host.endsWith(".localhost") + || host.endsWith(".local") + || host.endsWith(".internal") + || host === "metadata.google.internal" + || host === "0.0.0.0" + || host === "::1" + ) { + return true; + } + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (!ipv4) return false; + const parts = ipv4.slice(1).map((part) => Number(part)); + if (parts.some((part) => part > 255)) return false; + const [a, b] = parts; + if (a === 10 || a === 127 || a === 0) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + return false; +} + +function stripSecrets(text: string): string { + let next = text; + for (const pattern of SECRET_PATTERNS) { + pattern.lastIndex = 0; + next = next.replace(pattern, ""); + } + return next; +} + +function stripHtml(input: string): string { + let output = ""; + let index = 0; + while (index < input.length) { + const start = input.indexOf("<", index); + if (start === -1) { + output += input.slice(index); + break; + } + output += input.slice(index, start); + if (input.startsWith("", start + 4); + index = end === -1 ? input.length : end + 3; + continue; + } + if (input.startsWith("", start + 2); + index = end === -1 ? input.length : end + 1; + continue; + } + const tag = readTag(input, start); + if (!tag) { + output += "<"; + index = start + 1; + continue; + } + const name = tag.name.toLowerCase(); + if (name === "svg") { + const end = tag.selfClosing ? tag.end : findClose(input, tag.end, "svg"); + const raw = input.slice(start, end); + if (isAllowlistedSvg(raw)) output += raw; + index = end; + continue; + } + if (DROP_WITH_CONTENT.has(name) && !tag.selfClosing) { + index = findClose(input, tag.end, name); + continue; + } + if (DROP_TAG.has(name) || tag.selfClosing) { + index = tag.end; + continue; + } + index = tag.end; + } + return output; +} + +function isAllowlistedSvg(raw: string): boolean { + const tags = [...raw.matchAll(/<\/?\s*([a-zA-Z0-9]+)/g)].map((match) => match[1].toLowerCase()); + if (tags[0] !== "svg") return false; + if (!tags.every((tag) => SVG_TAGS.has(tag))) return false; + if (/\bon[a-z]+\s*=/i.test(raw)) return false; + if (/\b(?:javascript|vbscript|data|file):/i.test(raw)) return false; + return ordinaryOutputLeaks(raw).length === 0; +} + +function readTag(input: string, start: number): { name: string; end: number; selfClosing: boolean } | null { + if (input[start] !== "<") return null; + let index = start + 1; + if (input[index] === "/") index += 1; + const nameStart = index; + while (index < input.length && /[A-Za-z0-9]/.test(input[index] ?? "")) index += 1; + if (index === nameStart) return null; + const name = input.slice(nameStart, index); + let quote: string | null = null; + while (index < input.length) { + const char = input[index]; + if (quote) { + if (char === quote) quote = null; + index += 1; + continue; + } + if (char === "\"" || char === "'") { + quote = char; + index += 1; + continue; + } + if (char === ">") { + const selfClosing = input[index - 1] === "/"; + return { name, end: index + 1, selfClosing }; + } + index += 1; + } + return null; +} + +function findClose(input: string, from: number, name: string): number { + const open = new RegExp(`<\\s*${name}\\b`, "gi"); + const close = new RegExp(``, "gi"); + open.lastIndex = from; + close.lastIndex = from; + let depth = 1; + while (depth > 0) { + const nextClose = close.exec(input); + if (!nextClose) return input.length; + open.lastIndex = from; + let nested = 0; + let nextOpen = open.exec(input); + while (nextOpen && nextOpen.index < nextClose.index) { + nested += 1; + nextOpen = open.exec(input); + } + depth += nested - 1; + from = nextClose.index + nextClose[0].length; + if (depth === 0) return from; + open.lastIndex = from; + close.lastIndex = from; + } + return input.length; +} + +function parseBlocks(markdown: string): Block[] { + const lines = splitLines(markdown); + const blocks: Block[] = []; + let index = 0; + while (index < lines.length) { + const line = lines[index]; + if (!line) break; + const fence = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line.text); + if (fence) { + const marker = fence[1][0]; + const width = fence[1].length; + const collected = [line]; + index += 1; + while (index < lines.length) { + const next = lines[index]; + collected.push(next); + index += 1; + if (new RegExp(`^ {0,3}${marker}{${width},}\\s*$`).test(next.text)) break; + } + blocks.push({ + type: "fence", + raw: collected.map((item) => item.raw).join(""), + text: collected.map((item) => item.text).join("\n"), + lang: fence[2].trim().split(/\s+/)[0] ?? "", + }); + continue; + } + const html = /^ {0,3}<([a-zA-Z][a-zA-Z0-9]*)\b/.exec(line.text); + if (html && /^(script|style|iframe|object|embed|img|svg)$/i.test(html[1])) { + const tag = html[1].toLowerCase(); + const collected = [line]; + index += 1; + if (!/\/\s*>$/.test(line.text) && tag !== "img" && tag !== "embed") { + const close = new RegExp(``, "i"); + while (index < lines.length && !close.test(collected[collected.length - 1]?.text ?? "")) { + collected.push(lines[index]); + index += 1; + if (close.test(lines[index - 1]?.text ?? "")) break; + } + } + blocks.push({ + type: "html", + raw: collected.map((item) => item.raw).join(""), + text: collected.map((item) => item.text).join("\n"), + tag, + }); + continue; + } + if (/^ {0,3}#{1,6}\s+\S/.test(line.text)) { + const level = /^( {0,3})(#+)/.exec(line.text)?.[2].length ?? 1; + blocks.push({ + type: "heading", + raw: line.raw, + text: line.text.replace(/^ {0,3}#{1,6}\s+/, "").trim(), + level, + }); + index += 1; + continue; + } + if (isTableStart(lines, index)) { + const collected = [line]; + index += 1; + while (index < lines.length && lines[index].text.includes("|") && lines[index].text.trim()) { + collected.push(lines[index]); + index += 1; + } + blocks.push({ + type: "table", + raw: collected.map((item) => item.raw).join(""), + text: collected.map((item) => item.text).join("\n"), + }); + continue; + } + if (/^\s*$/.test(line.text)) { + const collected = [line]; + index += 1; + while (index < lines.length && /^\s*$/.test(lines[index].text)) { + collected.push(lines[index]); + index += 1; + } + blocks.push({ + type: "blank", + raw: collected.map((item) => item.raw).join(""), + text: "", + }); + continue; + } + const collected = [line]; + index += 1; + while (index < lines.length && !isBlockStart(lines, index)) { + collected.push(lines[index]); + index += 1; + } + const list = collected.every((item) => /^(\s*)([-*+]|\d+\.)\s+/.test(item.text) || /^\s+/.test(item.text) || item.text.trim() === ""); + blocks.push({ + type: list ? "list" : "paragraph", + raw: collected.map((item) => item.raw).join(""), + text: collected.map((item) => item.text).join("\n"), + }); + } + return blocks; +} + +function isBlockStart(lines: LineSlice[], index: number): boolean { + const text = lines[index]?.text ?? ""; + if (/^\s*$/.test(text)) return true; + if (/^ {0,3}(`{3,}|~{3,})/.test(text)) return true; + if (/^ {0,3}#{1,6}\s+\S/.test(text)) return true; + if (/^ {0,3}<(script|style|iframe|object|embed|img|svg)\b/i.test(text)) return true; + return isTableStart(lines, index); +} + +function isTableStart(lines: LineSlice[], index: number): boolean { + const current = lines[index]?.text ?? ""; + const next = lines[index + 1]?.text ?? ""; + return current.includes("|") && /^\s*\|?\s*:?-{3,}/.test(next); +} + +function splitLines(markdown: string): LineSlice[] { + const lines: LineSlice[] = []; + let index = 0; + while (index < markdown.length) { + const next = markdown.indexOf("\n", index); + if (next === -1) { + lines.push({ text: markdown.slice(index), raw: markdown.slice(index) }); + break; + } + const raw = markdown.slice(index, next + 1); + lines.push({ text: raw.replace(/\r?\n$/, ""), raw }); + index = next + 1; + } + return lines; +} + +function collapseBlankLines(text: string): string { + return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +function isReportShaped(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + if (record.schemaVersion === "report_document.v1" || record.schemaVersion === "report_document.v2") return true; + return isRecord(record.evidenceAppendix) || isRecord(record.executiveSummary); +} + +function takeText(value: unknown, limitations: Set): string { + if (typeof value !== "string") return ""; + const analyzed = analyzeOrdinaryReportMarkdown(value); + for (const note of analyzed.limitations) limitations.add(note); + return analyzed.prose.trim(); +} + +function takeTextList(value: unknown, limitations: Set): string[] { + if (!Array.isArray(value)) return []; + return value.flatMap((item) => { + const text = takeText(item, limitations); + return text ? [text] : []; + }); +} + +function narrativeSections(value: unknown, limitations: Set) { + return recordList(value).flatMap((section) => { + const narrative = takeText(section.narrative, limitations); + const actions = takeTextList(section.actions, limitations); + const caveats = takeTextList(section.caveats, limitations); + const title = takeText(section.title, limitations); + if (!title && !narrative && actions.length === 0 && caveats.length === 0) return []; + return [{ title, narrative, actions, caveats }]; + }); +} + +function foundationSection(value: unknown, limitations: Set) { + const section = readRecord(value); + if (!section) return []; + return [{ + title: takeText(section.title, limitations), + narrative: takeText(section.narrative, limitations), + actions: takeTextList(section.keyFactors, limitations), + caveats: takeTextList(section.caveats, limitations), + }]; +} + +function phaseSection(value: unknown, limitations: Set) { + const section = readRecord(value); + if (!section) return []; + return [{ + title: takeText(section.title, limitations) || takeText(section.phaseLabel, limitations), + narrative: takeText(section.narrative, limitations), + actions: takeTextList(section.timingNotes, limitations), + caveats: takeTextList(section.caveats, limitations), + }]; +} + +function actionNotes(value: unknown, limitations: Set) { + return recordList(value).flatMap((note) => { + const title = takeText(note.title, limitations); + const body = takeText(note.note, limitations); + const priority = note.priority === "now" || note.priority === "next" || note.priority === "watch" + ? note.priority + : ""; + if (!title && !body) return []; + return [{ title, note: body, ...(priority ? { priority } : {}) }]; + }); +} + +function recordList(value: unknown): Record[] { + if (!Array.isArray(value)) return []; + return value.filter(isRecord); +} + +function readRecord(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/frontend/tests/consultation-report-export.test.ts b/frontend/tests/consultation-report-export.test.ts index a0217ac6..fda14e8f 100644 --- a/frontend/tests/consultation-report-export.test.ts +++ b/frontend/tests/consultation-report-export.test.ts @@ -25,11 +25,17 @@ test("exports latest consultation answer with workflow receipt and claim boundar }); assert.match(report, /# 事业咨询/); assert.match(report, /先看阶段/); - assert.match(report, /technique_truth: partial/); - assert.match(report, /workflow_route: career/); - assert.match(report, /precise_timing: blocked/); - assert.match(report, /missing_layers: MEVG/); - assert.match(report, /未闭环内容不得包装成确定预测/); + assert.match(report, /这次说不到具体哪一天/); + assert.match(report, /有些依据还没补上/); + assert.match(report, /有些判断还没闭合/); + assert.doesNotMatch(report, /technique_truth/); + assert.doesNotMatch(report, /workflow_route/); + assert.doesNotMatch(report, /workflow_status/); + assert.doesNotMatch(report, /precise_timing/); + assert.doesNotMatch(report, /missing_layers/); + assert.doesNotMatch(report, /MEVG/); + assert.doesNotMatch(report, /career/); + assert.doesNotMatch(report, /Claim boundary/); }); test("assistant answer does not expose internal report controls", () => { diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts index fc06b428..b1b10f68 100644 --- a/frontend/tests/personal-report-entry.test.ts +++ b/frontend/tests/personal-report-entry.test.ts @@ -292,8 +292,12 @@ test("legacy consultation Markdown export is untouched and still works", () => { ], }); assert.match(markdown, /# 事业咨询/); - assert.match(markdown, /workflow_route: career/); - assert.match(markdown, /precise_timing: blocked/); + assert.match(markdown, /先看阶段/); + assert.match(markdown, /这次说不到具体哪一天/); + assert.doesNotMatch(markdown, /workflow_route/); + assert.doesNotMatch(markdown, /precise_timing/); + assert.doesNotMatch(markdown, /technique_truth/); + assert.doesNotMatch(markdown, /MEVG/); assert.equal(typeof downloadMarkdownReport, "function"); assert.match(pageSource, /consultation-report-export/); assert.doesNotMatch(pageSource, /consultationReportMarkdown[\s\S]{0,200}生成个人报告/); @@ -304,7 +308,9 @@ test("ready reports expose Markdown export only in the ready branch", () => { assert.match(reportCenterSource, /PERSONAL_REPORT_EXPORT_LABEL/); assert.match(reportCenterSource, /downloadPersonalReportLongformAppendix/); assert.match(reportCenterSource, /cardSummary/); - assert.match(longformDownloadSource, /professional-reference/); + assert.doesNotMatch(longformDownloadSource, /professional-reference/); + assert.match(longformDownloadSource, /projectOrdinaryReportMarkdown/); + assert.match(longformDownloadSource, /\/api\/reports\/\$\{encodeURIComponent\(reportId\)\}/); assert.match(longformDownloadSource, /personalReportMarkdownFilename/); assert.doesNotMatch(reportCenterSource, /全量数据附录/); assert.doesNotMatch(reportCenterSource, /章节/); diff --git a/frontend/tests/professional-report-reference-route.test.ts b/frontend/tests/professional-report-reference-route.test.ts index 5b024700..c4050108 100644 --- a/frontend/tests/professional-report-reference-route.test.ts +++ b/frontend/tests/professional-report-reference-route.test.ts @@ -130,7 +130,11 @@ test("ready export is cache-only and never calls the writer or Python engine", ( test("cached appendix returns markdown without model or engine calls", () => { const result = executeReadyReportExport(true); assert.equal(result.status, 200); - assert.deepEqual(result.body, { format: "markdown", markdown: "# Professional reference" }); + assert.deepEqual(result.body, { + documentKind: "professional_reference", + format: "markdown", + markdown: "# Professional reference", + }); assert.equal(result.modelCalls, 0); assert.equal(result.telemetryEvents, 0); assert.equal(result.upstream.length, 0); diff --git a/frontend/tests/report-public-projection.test.ts b/frontend/tests/report-public-projection.test.ts new file mode 100644 index 00000000..d75a7d4a --- /dev/null +++ b/frontend/tests/report-public-projection.test.ts @@ -0,0 +1,330 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx"; +import { consultationReportMarkdown } from "../src/lib/consultation-report-export.ts"; +import { ordinaryReportDownloadMarkdown } from "../src/lib/personal-report-longform-download.ts"; +import { resolveReportRead } from "../src/lib/personal-report-route-core.ts"; +import type { PersonalReportRecord } from "../src/lib/personal-report-service-core.ts"; +import { stripReportChartBlocks } from "../src/lib/report-chart-block.ts"; +import { + INTERNAL_REPORT_FIELDS, + ORDINARY_LIMITATION_COPY, + ORDINARY_PUBLIC_FIELDS, + REPORT_DOCUMENT_KINDS, + ordinaryOutputLeaks, + projectChatExportMarkdown, + projectOrdinaryReportDocument, + projectOrdinaryReportMarkdown, + projectOrdinarySnippet, + releaseProfessionalReference, +} from "../src/lib/report-public-projection.ts"; + +const CLEAN_MARKDOWN = "# 长报告\n\n### 摘要\n正文"; +const CHART_FENCE = [ + "事业先看阶段。", + "", + "```jyotish-chart", + JSON.stringify({ + version: 1, + id: "D1", + title: "本命", + layout: "north", + ascendant: { sign: "Aries", degree: 1 }, + planets: [], + }), + "```", + "", + "", +].join("\n"); + +const CACHED_MARKDOWN = [ + "# 旧缓存报告", + "", + "事业先看阶段。", + "", + "## Claim boundary", + "", + "technique_truth: partial", + "workflow_route: career", + "workflow_status: degraded", + "precise_timing: blocked", + "missing_layers: MEVG", + "score: 0.82", + "weight: 1.4", + "job_id: job-1", + "attempt_count: 3", + "provider: openai", + "model_debug: true", + "tool_call_id: call_1", + "", + "## 质量验收矩阵", + "", + "| 项 | 状态 |", + "| --- | --- |", + "| MEVG | blocked |", + "", + "## 摘要", + "", + "不承诺具体日期。", +].join("\n"); + +const UNSAFE_MARKDOWN = [ + "事业方向保持观察。正文里夹了 还能读。", + "", + "", + "", + "", + "", + "\"图\"", + "[bad](javascript:alert(1))", + "[file](file:///etc/passwd)", + "见 http://127.0.0.1:5200/api/secret 与 http://localhost:3000/hidden", + "sk-testsecretvalue", + "provider_payload: {\"model\":\"hidden\"}", + "Bearer abcdefghijklmnop", +].join("\n"); + +test("ordinary fields are an allowlist and internal fields stay classified", () => { + assert.deepEqual(ORDINARY_PUBLIC_FIELDS, [ + "title", + "prose", + "conclusion", + "action", + "limitation", + "chart_fence", + "engine_svg", + ]); + assert.ok(INTERNAL_REPORT_FIELDS.includes("technique_truth")); + assert.ok(INTERNAL_REPORT_FIELDS.includes("workflow_route")); + assert.ok(INTERNAL_REPORT_FIELDS.includes("secret")); + assert.ok(INTERNAL_REPORT_FIELDS.includes("provider")); + assert.deepEqual(REPORT_DOCUMENT_KINDS, [ + "chat_export", + "personal_report_detail", + "ordinary_markdown_download", + "professional_reference", + ]); +}); + +test("clean markdown is unchanged and absent fields are not invented", () => { + assert.equal(projectOrdinaryReportMarkdown(CLEAN_MARKDOWN), CLEAN_MARKDOWN); + assert.equal(projectOrdinarySnippet("事业方向保持观察"), "事业方向保持观察"); + const report = projectChatExportMarkdown({ + documentKind: "chat_export", + title: "空", + prose: "只有正文。", + }); + assert.match(report, /只有正文/); + assert.doesNotMatch(report, /需要知道的限制/); + assert.doesNotMatch(report, /technique_truth|unknown|Claim boundary/); + assert.equal(ordinaryOutputLeaks(report).length, 0); +}); + +test("chat export rewrites limitation signals and hides internal keys", () => { + const report = consultationReportMarkdown({ + title: "事业咨询", + messages: [ + { role: "user", text: "未来一年事业如何?" }, + { + role: "assistant", + text: "先看阶段,不承诺具体日期。", + techniqueTruth: "partial", + workflowReceipt: { + route: "career", + status: "ready", + preciseTiming: "blocked", + missingLayers: ["MEVG"], + }, + }, + ], + }); + assert.match(report, /先看阶段/); + assert.match(report, new RegExp(ORDINARY_LIMITATION_COPY.preciseTiming)); + assert.match(report, new RegExp(ORDINARY_LIMITATION_COPY.missingEvidence)); + assert.match(report, new RegExp(ORDINARY_LIMITATION_COPY.techniqueOpen)); + assert.equal(ordinaryOutputLeaks(report).length, 0); + assert.doesNotMatch(report, /career/); +}); + +test("old cached markdown cannot bypass the ordinary projection", () => { + const projected = projectOrdinaryReportMarkdown(CACHED_MARKDOWN); + assert.match(projected, /事业先看阶段/); + assert.match(projected, /不承诺具体日期/); + assert.match(projected, new RegExp(ORDINARY_LIMITATION_COPY.preciseTiming)); + assert.doesNotMatch(projected, /Claim boundary|质量验收矩阵/); + assert.equal(ordinaryOutputLeaks(projected).length, 0, ordinaryOutputLeaks(projected).join(", ")); + assert.equal(projectOrdinaryReportMarkdown(projected), projected); +}); + +test("professional reference is an explicit kind and still fail-safes without a separate grant", () => { + const released = releaseProfessionalReference(CACHED_MARKDOWN); + assert.equal(released.documentKind, "professional_reference"); + assert.equal(released.format, "markdown"); + assert.equal(ordinaryOutputLeaks(released.markdown).length, 0); + assert.notEqual(released.documentKind, "ordinary_markdown_download"); + const routeSource = readFileSync( + new URL("../src/app/api/reports/[reportId]/professional-reference/route.ts", import.meta.url), + "utf8", + ); + assert.match(routeSource, /releaseProfessionalReference/); + assert.doesNotMatch(routeSource, /professionalGrant/); + const downloadSource = readFileSync( + new URL("../src/lib/personal-report-longform-download.ts", import.meta.url), + "utf8", + ); + assert.doesNotMatch(downloadSource, /professional-reference/); +}); + +test("chart fences stay in ordinary reading output and download still strips them", () => { + const projected = projectOrdinaryReportMarkdown(CHART_FENCE); + assert.match(projected, /```jyotish-chart/); + assert.match(projected, /"id":"D1"/); + assert.match(projected, /<\/svg>/); + const downloaded = ordinaryReportDownloadMarkdown(CHART_FENCE); + assert.equal(downloaded, stripReportChartBlocks(projected)); + assert.doesNotMatch(downloaded, /```jyotish-chart/); + assert.match(downloaded, /<\/svg>/); + const engineSvg = [ + "图如下。", + "", + "", + "", + "", + "", + ].join("\n"); + const keptSvg = projectOrdinaryReportMarkdown(engineSvg); + assert.match(keptSvg, //); + const poisoned = projectOrdinaryReportMarkdown([ + "```jyotish-chart", + "{\"technique_truth\":\"partial\"}", + "```", + ].join("\n")); + assert.doesNotMatch(poisoned, /jyotish-chart|technique_truth/); +}); + +test("ordinary output drops HTML, dangerous URLs, secrets, and model fields", () => { + const projected = projectOrdinaryReportMarkdown(UNSAFE_MARKDOWN); + assert.match(projected, /事业方向保持观察/); + assert.match(projected, /还能读/); + assert.equal(ordinaryOutputLeaks(projected).length, 0, ordinaryOutputLeaks(projected).join(", ")); + assert.doesNotMatch(projected, /evil\.example|alert\(1\)|alert\(2\)|sk-testsecretvalue|abcdefghijklmnop/); + const svgScript = projectOrdinaryReportMarkdown(""); + assert.doesNotMatch(svgScript, / { + const detail = classifyReportEnvelope(200, { + report: { + id: "11111111-1111-4111-8111-111111111111", + status: "ready", + createdAt: "2026-09-22T00:00:00.000Z", + }, + longformMarkdown: CACHED_MARKDOWN, + }); + assert.equal(detail.phase, "markdown-ready"); + if (detail.phase === "markdown-ready") { + assert.match(detail.markdown, /事业先看阶段/); + assert.equal(ordinaryOutputLeaks(detail.markdown).length, 0); + } + + const leakedDocument = { + schemaVersion: "report_document.v2", + executiveSummary: { + headline: "方向", + summary: "先看阶段。", + priorities: ["先观察"], + }, + evidenceAppendix: { + techniqueAudit: [{ techniqueName: "MEVG", status: "blocked", score: 0.2 }], + }, + provenance: { + skillSnapshotSha256: "ab".repeat(32), + calculationHash: "cd".repeat(32), + provider: "openai", + }, + disclaimer: "不把未闭合的判断写成确定结论。", + }; + const projectedDocument = projectOrdinaryReportDocument(leakedDocument); + assert.equal(JSON.stringify(projectedDocument).includes("evidenceAppendix"), false); + assert.equal(JSON.stringify(projectedDocument).includes("techniqueAudit"), false); + assert.equal(JSON.stringify(projectedDocument).includes("skillSnapshotSha256"), false); + assert.equal(ordinaryOutputLeaks(JSON.stringify(projectedDocument)).length, 0); + + const response = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + reportId: "11111111-1111-4111-8111-111111111111", + persistence: { + async getOwnedById() { + return syntheticRecord(); + }, + }, + loadLongformAppendix: async () => ({ + status: "ready", + lastErrorCode: null, + markdown: CACHED_MARKDOWN, + }), + validateReadyDocument: () => ({ ok: true, document: leakedDocument }), + }); + assert.equal(response.status, 200); + assert.equal(typeof response.body.longformMarkdown, "string"); + assert.match(String(response.body.longformMarkdown), /事业先看阶段/); + assert.equal(ordinaryOutputLeaks(String(response.body.longformMarkdown)).length, 0); + assert.equal(JSON.stringify(response.body.reportDocument).includes("evidenceAppendix"), false); + const passthrough = { ok: true }; + assert.equal(projectOrdinaryReportDocument(passthrough), passthrough); +}); + +test("markdown view and chart fence contracts are not weakened", () => { + const viewSource = readFileSync( + new URL("../src/components/personal-report/personal-report-markdown-view.tsx", import.meta.url), + "utf8", + ); + assert.match(viewSource, /skipHtml/); + assert.match(viewSource, /disallowedElements=\{\["script", "iframe", "object", "embed", "img"\]\}/); + assert.doesNotMatch(viewSource, /rehype-raw/); + assert.doesNotMatch(viewSource, /dangerouslySetInnerHTML/); + const fenceSource = readFileSync( + new URL("../src/lib/report-chart-block.ts", import.meta.url), + "utf8", + ); + assert.match(fenceSource, /```jyotish-chart/); + const coreSource = readFileSync( + new URL("../src/lib/personal-report-route-core.ts", import.meta.url), + "utf8", + ); + assert.match(coreSource, /projectOrdinaryReportMarkdown/); + assert.match(coreSource, /projectOrdinaryReportDocument/); +}); + +function syntheticRecord(): PersonalReportRecord { + return { + id: "11111111-1111-4111-8111-111111111111", + userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + sessionId: null, + chartProfileId: null, + requestId: "22222222-2222-4222-8222-222222222222", + requestFingerprint: "f".repeat(64), + reportType: "personal_full", + status: "ready", + schemaVersion: "report_document.v2", + presentationMode: "default", + depth: "standard", + requestedThemes: ["career"], + reportDocument: null, + calculationHash: null, + evidenceHash: null, + skillName: null, + skillVersion: null, + skillSourceCommit: null, + skillSnapshotSha256: "a".repeat(64), + failureCode: null, + createdAt: "2026-09-22T00:00:00.000Z", + updatedAt: "2026-09-22T00:00:00.000Z", + completedAt: "2026-09-22T00:00:00.000Z", + }; +}