diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx index 9c6c3f88fb..1b82d8618e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx @@ -5,6 +5,7 @@ import {useMemo, useState, type ReactNode} from "react"; import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts"; import {searchResearchRecords, type ResearchRecordDisplay} from "./research-record-search"; import {exportResearchRecords} from "./research-record-export"; +import {exportResearchReport} from "./research-record-report"; import {downloadReport} from "./report-download"; /** Local read controls over the existing validated history snapshot. */ @@ -20,6 +21,7 @@ export function ResearchRecordSearch({goalId, events, displayEvents, displaySect const [status, setStatus] = useState(""); const [limit, setLimit] = useState(50); const [downloadFailed, setDownloadFailed] = useState(false); + const [format, setFormat] = useState("json"); const displayed = useMemo(() => new Map(displayEvents?.map(event => [event.event_id, event])), [displayEvents]); const display = useMemo(() => ({events: displayed, sections: displaySections}), [displayed, displaySections]); const matches = useMemo(() => searchResearchRecords(events, query, status, display), [events, query, status, display]); @@ -38,13 +40,18 @@ export function ResearchRecordSearch({goalId, events, displayEvents, displaySect {[...new Set([...statuses, ...(status ? [status] : [])])].map(item => )} {query || status ? : null} + + }}>{format === "md" ? (zh ? "下载阅读报告" : "Download reading report") : (zh ? "下载匹配记录(JSON)" : "Download matches (JSON)")} {downloadFailed ?

{zh ? "无法开始下载,记录未改变。请重试下载。" : "Could not start the download. Records are unchanged; try downloading again."}

: null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.ts b/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.ts index 16ab0ab832..fff0532b21 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.ts @@ -5,13 +5,15 @@ import {searchResearchRecords, type ResearchRecordDisplay} from "./research-reco * Export canonical records, never translated/analysed display replacements. * Source endpoints are deliberately absent: opening a file needs no connection. */ -export function exportResearchRecords({goalId, events, query, status, complete, stale, exportedAt, display}: { +export type ResearchRecordExport = { goalId: string; events: readonly ExploreHistoryEvent[]; query: string; status: string; complete: boolean; stale: boolean; exportedAt: string; display?: ResearchRecordDisplay; -}): string { +}; + +export function researchRecordSnapshot({goalId, events, query, status, complete, stale, exportedAt, display}: ResearchRecordExport) { if (!goalId || events.some(event => event.goal_id !== goalId)) throw new Error("Research export Goal mismatch"); const records = searchResearchRecords(events, query, status, display); - return JSON.stringify({ + return { goal_id: goalId, exported_at: exportedAt, filters: {query, status}, @@ -22,5 +24,9 @@ export function exportResearchRecords({goalId, events, query, status, complete, refresh_failed: stale, }, records, - }, null, 2) + "\n"; + }; +} + +export function exportResearchRecords(input: ResearchRecordExport): string { + return JSON.stringify(researchRecordSnapshot(input), null, 2) + "\n"; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-report.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/research-record-report.test.mjs new file mode 100644 index 0000000000..417c007702 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-report.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {exportResearchReport} from "./research-record-report.ts"; + +const record = (overrides = {}) => ({goal_id: "research", title: "Evidence", summary: "Original result", + event_id: "event", result_id: "finding", recorded_at: "2026-01-01T00:00:00Z", status: "refuted", + evidence_refs: [], confidence: null, ...overrides}); +const input = (events, overrides = {}) => ({goalId: "research", events, query: "", status: "", + complete: true, stale: false, exportedAt: "2026-01-02T00:00:00Z", ...overrides}); + +test("report preserves all matching originals beyond UI paging and the actual read scope", () => { + const events = Object.freeze(Array.from({length: 75}, (_, i) => Object.freeze(record({title: `Observation ${i}`, event_id: `event-${i}`})))); + const report = exportResearchReport(input(events, {complete: false, stale: true, query: "Observation", status: "refuted"}), false); + assert.equal((report.match(/^## \d+\./gm) ?? []).length, 75); + assert.match(report, /75 \/ 75/); + assert.match(report, /Incomplete; unloaded records are not included/); + assert.match(report, /Failed; previously read records retained/); + assert.match(report, /\*\*Search:\*\* Observation/); + assert.match(report, /\*\*Status filter:\*\* refuted/); + assert.match(report, /Observation 74/); +}); + +test("translated matches keep original prose, negative revisions and measurement conditions", () => { + const old = record({summary: "Earlier negative evidence", evidence_refs: ["result:old"], run_id: "run:old", + measurements: [{metric: "Error", value: null, unit: "points", higher_is_better: false, + comparison_scope: "scope:v1", input_revision: "input:v1", evaluation_ref: "method:v1", candidate_ref: "candidate:v1", evidence_ref: "result:old"}]}); + const later = record({event_id: "later", summary: "Measured zero", supersedes: "event", + measurements: [{...old.measurements[0], value: 0}]}); + const display = {events: new Map([[old.event_id, {...old, summary: "较早的负向证据"}]])}; + const report = exportResearchReport(input([old, later], {query: "负向证据", display}), true); + assert.match(report, /Earlier negative evidence/); + assert.doesNotMatch(report, /Measured zero|较早的负向证据/); + assert.match(report, /\*\*数值:\*\* 未知/); + for (const ref of ["scope:v1", "input:v1", "method:v1", "candidate:v1", "result:old", "run:old"]) assert(report.includes(ref)); + const both = exportResearchReport(input([old, later]), false); + assert.match(both, /\*\*Value:\*\* Unknown/); + assert.match(both, /\*\*Value:\*\* 0/); + assert.match(both, /\*\*Direction:\*\* Lower is better/); + assert.match(both, /\*\*supersedes:\*\* event/); +}); + +test("source markup remains literal without loading remote images or forging report sections", () => { + const summary = "```\n# Forged claim\n![remote](https://example.test/image)\n\n````\n结论"; + const report = exportResearchReport(input([record({title: "\n# title", summary, + evidence_refs: ["[source](https://example.test)"], execution_observation: {basis: "observed"}})]), false); + assert(report.includes(`\`\`\`\`\`text\n${summary}\n\`\`\`\`\``)); + assert.match(report, /## 1\. \\ \\# title/); + assert(report.includes('"basis": "observed"')); +}); + +test("empty reports keep scope and mixed Goals are rejected before filtering", () => { + assert.match(exportResearchReport(input([]), false), /0 \/ 0/); + assert.throws(() => exportResearchReport(input([record({goal_id: "other"})], {query: "absent"}), false), /Goal mismatch/); +}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-report.ts b/apps/presentation/dashboard/src/features/personal-workspace/research-record-report.ts new file mode 100644 index 0000000000..6536e1de8e --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-report.ts @@ -0,0 +1,58 @@ +import {researchRecordSnapshot, type ResearchRecordExport} from "./research-record-export.ts"; + +// Source prose stays literal: it cannot introduce headings, remote images or +// links into the saved report. Fences grow to preserve embedded code fences. +function literal(text: string): string { + const fence = "`".repeat(Math.max(3, ...Array.from(text.matchAll(/`+/g), match => match[0].length + 1))); + return `${fence}text\n${text}\n${fence}`; +} +const inline = (text: string) => text.replace(/[\\`*_{}\[\]<>()#+.!|~>-]/g, "\\$&").replace(/\r?\n/g, " "); + +/** Human-readable original evidence, using exactly the JSON export's selection. */ +export function exportResearchReport(input: ResearchRecordExport, zh: boolean): string { + const snapshot = researchRecordSnapshot(input); + const label = (en: string, cn: string) => zh ? cn : en; + const missing = label("Not recorded", "未记录"); + const field = (name: string, value: unknown) => `- **${name}:** ${inline(value === undefined || value === null || value === "" ? missing : String(value))}`; + const lines = [ + `# ${label("Research record report", "研究记录报告")}`, + label("Original recorded evidence; not an analysis, matched evaluation or acceptance receipt.", "原始记录证据;不是分析、同条件评测或验收回执。"), + field("Goal", snapshot.goal_id), field(label("Exported at", "导出时间"), snapshot.exported_at), + `## ${label("Reading scope", "阅读范围")}`, + field(label("Exported / loaded records", "导出 / 已读记录"), `${snapshot.coverage.exported_records} / ${snapshot.coverage.loaded_records}`), + field(label("History", "历史"), input.complete ? label("Complete loaded history", "已完整载入历史") : label("Incomplete; unloaded records are not included", "不完整;不包含尚未读取的记录")), + field(label("Refresh", "刷新"), input.stale ? label("Failed; previously read records retained", "失败;保留先前读取的记录") : label("No refresh failure reported", "未报告刷新失败")), + field(label("Search", "搜索"), input.query || label("All text", "全部文字")), + field(label("Status filter", "状态筛选"), input.status || label("All statuses", "全部状态")), + label("Filters may match saved display translations; the records below retain their original language and revision. Record time is not execution time. References are identifiers, not downloaded source files. Keep this report as private as its source research.", + "筛选可能匹配已保存的展示翻译;以下记录保留原始语言与版本。记录时间不是执行时间。引用是标识,不包含其源文件。请按原研究的隐私范围保管本报告。"), + ]; + snapshot.records.forEach((record, index) => { + lines.push(`## ${index + 1}. ${inline(record.title)}`, field(label("Recorded at", "记录时间"), record.recorded_at), + field(label("Recorded status", "记录状态"), record.status), + `### ${label("Original text", "原文")}`, record.summary ? literal(record.summary) : missing); + if (record.blocked_reason) lines.push(`### ${label("Blocked reason", "受阻原因")}`, literal(record.blocked_reason)); + if (record.measurements?.length) { + lines.push(`### ${label("Native measurements", "原生测量")}`); + for (const measurement of record.measurements) { + lines.push(`#### ${inline(measurement.metric)}`, + field(label("Value", "数值"), measurement.value === null ? label("Unknown", "未知") : measurement.value), + field(label("Unit", "单位"), measurement.unit), + field(label("Direction", "方向"), measurement.higher_is_better ? label("Higher is better", "越高越好") : label("Lower is better", "越低越好")), + field(label("Comparison scope", "比较范围"), measurement.comparison_scope), + field(label("Input revision", "输入版本"), measurement.input_revision), + field(label("Evaluation reference", "评测引用"), measurement.evaluation_ref), + field(label("Candidate reference", "候选引用"), measurement.candidate_ref), + field(label("Evidence reference", "证据引用"), measurement.evidence_ref)); + } + } + lines.push(`### ${label("Sources and identity", "来源与标识")}`); + for (const key of ["schema_version", "event_id", "event_kind", "result_id", "node_kind", "node_id", "parent_id", "from_node", "to_node", "edge_type", "agent_id", "run_id", "supersedes"] as const) { + if (record[key]) lines.push(field(key, record[key])); + } + lines.push(field(label("Recorded confidence", "记录置信度"), record.confidence)); + lines.push(record.evidence_refs.length ? literal(record.evidence_refs.join("\n")) : label("No evidence references recorded.", "未记录证据引用。")); + if (record.execution_observation) lines.push(`### ${label("Recorded execution observation", "记录的执行观察")}`, literal(JSON.stringify(record.execution_observation, null, 2))); + }); + return lines.join("\n\n") + "\n"; +} diff --git a/docs/images/research-record-report.png b/docs/images/research-record-report.png new file mode 100644 index 0000000000..b12f410675 Binary files /dev/null and b/docs/images/research-record-report.png differ diff --git a/examples/personal-workspace-browser/research-history.md b/examples/personal-workspace-browser/research-history.md index f3f81f8f85..b6101c9eee 100644 --- a/examples/personal-workspace-browser/research-history.md +++ b/examples/personal-workspace-browser/research-history.md @@ -131,7 +131,26 @@ claim matched evaluations, improved quality or acceptance. Partial/stale history retains the reader's coverage warning; refresh to retry. Closing and reopening the comparison does not change either source record. -Choose **Download matches (JSON)** to keep all matching original records, including +Choose **Reading report (Markdown)** in **Download format**, then **Download reading +report** to read the matching evidence outside the workbench. The report includes +original text, blocked reasons, source/revision identities, execution observations +and native measurement values with their units, direction and comparison conditions. +Unknown measurements remain unknown; zero remains zero. Source markup stays literal, +so opening the report does not load embedded remote images. Report headings follow +the interface language; original evidence is never translated by exporting it. + +在**下载格式**中选择**阅读报告(Markdown)**,再点**下载阅读报告**,即可离线阅读 +匹配的原文、受阻原因、版本与证据标识、执行观察,以及测量值、单位、方向和比较条件。 +未知测量不替换成零,原文标记不会加载远端图片。报告标题沿用界面语言,导出不翻译 +原始证据。报告保留筛选、部分历史和刷新失败提示,不代表分析或验收。 + +![Choose a reading report for the currently filtered original evidence](../../docs/images/research-record-report.png) + +The narrow view above uses synthetic mixed-language records in the packaged +workbench. Format selection stays beside the existing download action; it does +not add a setup step or change the default Research overview. + +The default **Download matches (JSON)** still keeps all matching original records, including those not yet expanded on screen. The file includes the Goal, export time, literal filters and loaded/exported counts. `history_complete` and `refresh_failed` preserve the reading limits: a partial or stale snapshot is not current complete evidence. @@ -144,14 +163,14 @@ loading or no records match, downloading is unavailable; retry after a download failure or refresh the history after a read failure. The synthetic packaged regression exercises native paged history, an old negative -revision after 75 other findings, status filtering, full/filtered JSON downloads, +revision after 75 other findings, status filtering, full/filtered JSON and Markdown downloads, original-version comparison (including unmeasured versus zero), partial/stale coverage, interrupted reads/downloads and retry, Goal isolation and desktop/narrow layouts without dispatching an Agent: ```sh node --experimental-strip-types --test apps/presentation/dashboard/src/features/personal-workspace/research-record-search.test.mjs apps/presentation/dashboard/src/features/personal-workspace/research-record-versions.test.mjs -node --experimental-strip-types --test apps/presentation/dashboard/src/features/personal-workspace/research-record-export.test.mjs +node --experimental-strip-types --test apps/presentation/dashboard/src/features/personal-workspace/research-record-export.test.mjs apps/presentation/dashboard/src/features/personal-workspace/research-record-report.test.mjs LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PERSONAL_WORKSPACE_SCENARIO=research-record-search node examples/personal-workspace-browser-smoke.mjs ``` diff --git a/examples/personal-workspace-browser/research-record-search.mjs b/examples/personal-workspace-browser/research-record-search.mjs index c16a2d4233..1ca850aebf 100644 --- a/examples/personal-workspace-browser/research-record-search.mjs +++ b/examples/personal-workspace-browser/research-record-search.mjs @@ -66,6 +66,16 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s assert.match(file.suggestedFilename(), /^report-research-synthetic-[a-z-]+-readback\.json$/); return JSON.parse(await readFile(await file.path(), "utf8")); }; + const report = async () => { + await results.getByRole("combobox", {name: "Download format"}).selectOption("md"); + const received = page.waitForEvent("download"); + await results.getByRole("button", {name: "Download reading report", exact: true}).click(); + const file = await received; + assert.match(file.suggestedFilename(), /-readback\.md$/); + const text = await readFile(await file.path(), "utf8"); + await results.getByRole("combobox", {name: "Download format"}).selectOption("json"); + return text; + }; await search.waitFor(); assert(await search.evaluate(element => element === document.activeElement), "The search entry moves keyboard focus to the input"); await page.waitForFunction(() => document.querySelector('.research-history')?.getAttribute('aria-busy') === 'false'); @@ -102,6 +112,11 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s assert.equal(all.goal_id, "synthetic-record-search"); assert.equal(all.records.length, 79, "Download includes matches beyond both HTTP and display paging"); assert.deepEqual(all.coverage, {loaded_records: 79, exported_records: 79, history_complete: true, refresh_failed: false}); + const fullReport = await report(); + assert.equal((fullReport.match(/^## \d+\./gm) ?? []).length, 79); + assert.match(fullReport, /\*\*Value:\*\* Unknown/); + assert.match(fullReport, /\*\*Value:\*\* 0/); + assert.match(fullReport, /candidate:amber-v3/); await search.fill("AMBER-V1 run:old"); await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).waitFor(); assert.equal(await results.locator("li").count(), 1, "Search reaches beyond both the display limit and HTTP page boundary"); @@ -127,6 +142,10 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s assert.equal(selected.records[0].summary, "质量下降 — original negative evidence for candidate amber."); assert.deepEqual(selected.records[0].evidence_refs, ["candidate:amber-v1"]); assert.equal(selected.records[0].run_id, "run:old"); + const selectedReport = await report(); + assert.match(selectedReport, /质量下降 — original negative evidence/); + assert.match(selectedReport, /\*\*Comparison scope:\*\* synthetic:fixed/); + assert.doesNotMatch(selectedReport, /candidate:amber-v2|Later observation/); // Compare the original negative record with a revision excluded by both filters. const comparison = results.locator(".research-record-comparison"); const comparisonSummary = comparison.locator(":scope > summary"); @@ -180,6 +199,16 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await page.screenshot({path: resolve(outputDir, "research-record-export-download-error.png")}); assert.deepEqual((await download()).records, selected.records); assert.equal(await results.getByRole("alert").count(), 0); + await results.getByRole("combobox", {name: "Download format"}).selectOption("md"); + await page.evaluate(() => { + const original = URL.createObjectURL; + URL.createObjectURL = () => {URL.createObjectURL = original; throw new Error("Synthetic report interruption");}; + }); + await results.getByRole("button", {name: "Download reading report", exact: true}).click(); + await results.getByRole("alert").waitFor(); + assert.match(await report(), /Earlier negative observation/); + assert.equal(await results.getByRole("alert").count(), 0); + await results.getByRole("combobox", {name: "Download format"}).selectOption("md"); for (const width of [1440, 390]) { await page.setViewportSize({width, height: 900}); await results.evaluate(element => element.scrollIntoView({block: "start"})); @@ -187,6 +216,7 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await page.screenshot({path: resolve(outputDir, `research-record-search-${width}.png`), animations: "disabled"}); } // A failed refresh keeps the selected query and old evidence visibly stale. + await results.getByRole("combobox", {name: "Download format"}).selectOption("json"); await page.route("**/api/chat/explore-results?*", route => route.fulfill({status: 503, json: {error: "Synthetic interruption"}})); await history.getByRole("button", {name: "Refresh history", exact: true}).click(); await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).getByText(/Refresh failed/).waitFor(); @@ -194,6 +224,7 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s assert.equal(await results.locator("li").count(), 1); const stale = await download(); assert.equal(stale.coverage.refresh_failed, true); + assert.match(await report(), /Failed; previously read records retained/); await comparisonSummary.click(); await comparison.getByRole("combobox", {name: "Compare with"}).waitFor(); assert.match(await comparison.innerText(), /candidate:amber-v2/); @@ -227,6 +258,7 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s const partial = await download(); assert.equal(partial.coverage.history_complete, false); assert.equal(partial.coverage.refresh_failed, true); + assert.match(await report(), /Incomplete; unloaded records are not included/); assert.equal(partial.records.length, partial.coverage.loaded_records); assert(partial.records.length > 0 && partial.records.length < 79); await search.fill("amber-v1"); @@ -285,6 +317,15 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await chineseDownload.focus(); await page.keyboard.press("Enter"); assert.equal(JSON.parse(await readFile(await (await received).path(), "utf8")).records.length, 79); + await results.getByRole("combobox", {name: "下载格式"}).selectOption("md"); + const chineseReport = page.waitForEvent("download"); + await results.getByRole("button", {name: "下载阅读报告", exact: true}).focus(); + await page.keyboard.press("Enter"); + const reportText = await readFile(await (await chineseReport).path(), "utf8"); + assert.match(reportText, /^# 研究记录报告/); + assert.match(reportText, /\*\*数值:\*\* 未知/); + assert.match(reportText, /\*\*数值:\*\* 0/); + assert.match(reportText, /质量下降 — original negative evidence/); return {coverageEntries: collectCoverage ? await page.coverage.stopJSCoverage() : [], note: "packaged evidence search/export through native paged history; full and filtered downloads, original revisions, coverage/staleness, original revision comparison, download/read recovery, Goal isolation and narrow viewport"}; } catch (error) { if (page && !page.isClosed()) await page.screenshot({path: resolve(outputDir, "research-record-search-failed.png")});