Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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]);
Expand All @@ -38,13 +40,18 @@ export function ResearchRecordSearch({goalId, events, displayEvents, displaySect
{[...new Set([...statuses, ...(status ? [status] : [])])].map(item => <option key={item} value={item}>{stateLabel(item)}</option>)}
</select>
{query || status ? <button type="button" onClick={reset}>{zh ? "清除筛选" : "Clear filters"}</button> : null}
<select aria-label={zh ? "下载格式" : "Download format"} value={format} onChange={event => setFormat(event.target.value)}>
<option value="json">JSON</option>
<option value="md">{zh ? "阅读报告(Markdown)" : "Reading report (Markdown)"}</option>
</select>
<button type="button" disabled={busy || !matches.length} onClick={() => {
setDownloadFailed(false);
try {
const text = exportResearchRecords({goalId, events, query, status, complete, stale, display, exportedAt: new Date().toISOString()});
downloadReport(`research-${goalId}.json`, "", text);
const input = {goalId, events, query, status, complete, stale, display, exportedAt: new Date().toISOString()};
const text = format === "md" ? exportResearchReport(input, zh) : exportResearchRecords(input);
downloadReport(`research-${goalId}.${format}`, "", text);
} catch {setDownloadFailed(true);}
}}>{zh ? "下载匹配记录(JSON)" : "Download matches (JSON)"}</button>
}}>{format === "md" ? (zh ? "下载阅读报告" : "Download reading report") : (zh ? "下载匹配记录(JSON)" : "Download matches (JSON)")}</button>
</div>
{downloadFailed ? <p role="alert">{zh ? "无法开始下载,记录未改变。请重试下载。" : "Could not start the download. Records are unchanged; try downloading again."}</p> : null}
<div className="research-record-count">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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";
}
Original file line number Diff line number Diff line change
@@ -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<script>bad()</script>\n````\n结论";
const report = exportResearchReport(input([record({title: "<img>\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\. \\<img\\> \\# 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/);
});
Original file line number Diff line number Diff line change
@@ -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";
}
Binary file added docs/images/research-record-report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 22 additions & 3 deletions examples/personal-workspace-browser/research-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```

Expand Down
Loading
Loading