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 @@ -540,6 +540,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
{value?.events.length ? <details ref={records} className="research-history-all-records" open={recordsOpen} onToggle={event => setRecordsOpen(event.currentTarget.open)}><summary>{zh ? "全部已读记录" : "All loaded records"} · {value.events.length}</summary>
{recordsOpen ? <ResearchRecordSearch key={JSON.stringify([goalId, readSourceUrl])} goalId={goalId} events={history.canonical?.events ?? []}
displayEvents={currentDisplay && !originalDisplay ? value.events : undefined}
displaySections={currentDisplay && !originalDisplay ? displayProjection.sections : undefined}
renderBody={currentDisplay && !originalDisplay ? findingBody : undefined}
complete={read.complete} stale={Boolean(error || history.error)} busy={busy} zh={zh} stateLabel={stateLabel} timeLabel={timeLabel}/> : null}
</details> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,33 @@ import {CircleHelp, TriangleAlert} from "lucide-react";
import {ContextHelp} from "./context-help";
import {useMemo, useState, type ReactNode} from "react";
import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts";
import {searchResearchRecords} from "./research-record-search";
import {searchResearchRecords, type ResearchRecordDisplay} from "./research-record-search";
import {exportResearchRecords} from "./research-record-export";
import {downloadReport} from "./report-download";

/** Local read controls over the existing validated history snapshot. */
export function ResearchRecordSearch({goalId, events, displayEvents, renderBody, complete, stale, busy, zh, stateLabel, timeLabel}: {
export function ResearchRecordSearch({goalId, events, displayEvents, displaySections, renderBody, complete, stale, busy, zh, stateLabel, timeLabel}: {
goalId: string;
displayEvents?: readonly ExploreHistoryEvent[]; renderBody?: (event: ExploreHistoryEvent) => ReactNode;
displaySections?: ResearchRecordDisplay["sections"];
events: readonly ExploreHistoryEvent[]; complete: boolean; stale: boolean; busy: boolean; zh: boolean;
stateLabel: (status: string) => string; timeLabel: (at: string) => string;
}) {
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [limit, setLimit] = useState(50);
const [downloadFailed, setDownloadFailed] = useState(false);
const matches = useMemo(() => searchResearchRecords(events, query, status), [events, query, status]);
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]);
const statuses = [...new Set(events.map(event => event.status).filter(Boolean))].sort();
const countLabel = zh ? `匹配 ${matches.length} / ${events.length} 条已读记录` : `${matches.length} matches in ${events.length} loaded records`;
const readState = busy ? (zh ? "读取中" : "Reading") : stale ? (zh ? "刷新失败" : "Refresh failed") : "";
const reset = () => {setQuery(""); setStatus(""); setLimit(50);};
return <div className="research-record-search">
<div className="research-record-filters">
<input type="search" aria-label={zh ? "搜索研究记录" : "Search research records"}
placeholder={zh ? "原文、来源引用或 ID" : "Original text, source reference or ID"} value={query}
placeholder={zh ? "正文、来源引用或 ID" : "Text, source reference or ID"} value={query}
onChange={event => {setQuery(event.target.value); setLimit(50);}}/>
<select aria-label={zh ? "记录状态" : "Recorded status"} value={status}
onChange={event => {setStatus(event.target.value); setLimit(50);}}>
Expand All @@ -38,7 +40,7 @@ export function ResearchRecordSearch({goalId, events, displayEvents, renderBody,
<button type="button" disabled={busy || !matches.length} onClick={() => {
setDownloadFailed(false);
try {
const text = exportResearchRecords({goalId, events, query, status, complete, stale, exportedAt: new Date().toISOString()});
const text = exportResearchRecords({goalId, events, query, status, complete, stale, display, exportedAt: new Date().toISOString()});
downloadReport(`research-${goalId}.json`, "", text);
} catch {setDownloadFailed(true);}
}}>{zh ? "下载匹配记录(JSON)" : "Download matches (JSON)"}</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,13 @@ test("rejects a mismatched Goal even when its records would be filtered out", ()
assert.throws(() => snapshot([record({goal_id: "other"})], {query: "no match"}), /Goal mismatch/);
assert.throws(() => snapshot([], {goalId: ""}), /Goal mismatch/);
});

test("a displayed-language match exports exactly the source revision and preserves coverage", () => {
const original = Object.freeze(record({title: "Negative evidence", summary: "Unknown complete cost", status: "refuted"}));
const display = {events: new Map([[original.event_id, {...original, title: "负向证据", summary: "完整成本未知"}]])};
const result = snapshot([original], {query: "完整成本未知", status: "refuted", display, complete: false, stale: true});
assert.deepEqual(result.records, [original]);
assert.deepEqual(result.filters, {query: "完整成本未知", status: "refuted"});
assert.deepEqual(result.coverage, {loaded_records: 1, exported_records: 1, history_complete: false, refresh_failed: true});
assert.doesNotMatch(JSON.stringify(result.records), /负向证据|完整成本未知/);
});
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts";
import {searchResearchRecords} from "./research-record-search.ts";
import {searchResearchRecords, type ResearchRecordDisplay} from "./research-record-search.ts";

/** A portable reading snapshot, not an import format or an acceptance receipt.
* 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}: {
export function exportResearchRecords({goalId, events, query, status, complete, stale, exportedAt, display}: {
goalId: string; events: readonly ExploreHistoryEvent[]; query: string; status: string;
complete: boolean; stale: boolean; exportedAt: string;
complete: boolean; stale: boolean; exportedAt: string; display?: ResearchRecordDisplay;
}): string {
if (!goalId || events.some(event => event.goal_id !== goalId)) throw new Error("Research export Goal mismatch");
const records = searchResearchRecords(events, query, status);
const records = searchResearchRecords(events, query, status, display);
return JSON.stringify({
goal_id: goalId,
exported_at: exportedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,22 @@ test("terms may span provenance and original multilingual text without mutating
assert.deepEqual(searchResearchRecords([source], "质量下降 RUN:42 deadbeef"), [source]);
assert.deepEqual(searchResearchRecords([source], "quality"), [], "The search does not translate or infer source claims");
});

test("matches the reader's translated narrative alongside original text and identity, returning only originals", () => {
const original = Object.freeze(record({title: "Negative observation", summary: "Original serialized finding", event_id: "old",
status: "refuted", run_id: "run:old", execution_observation: {change_reason: "Keep the reference"}}));
const other = Object.freeze(record({event_id: "new", title: "Later evidence", status: "confirmed"}));
const events = Object.freeze([original, other]);
const display = {events: new Map([["old", {...original, title: "负向观察", execution_observation: {change_reason: "保留参考方案"}}]]),
sections: new Map([["old", {applicability: "仅限本地", observation: "完整成本未知", interpretation: "",
decision: {outcome: "replan", changed: ["核对原始证据"], stopped: ["停止当前路线"], retained: ["保留旧版本"]}}]])};
for (const text of ["负向观察", "仅限本地", "完整成本未知", "核对原始证据", "停止当前路线", "保留旧版本", "保留参考方案"]) {
assert.deepEqual(searchResearchRecords(events, `${text} RUN:OLD`, "refuted", display), [original]);
}
assert.deepEqual(searchResearchRecords(events, "负向观察 Negative", "", display), [original]);
assert.equal(searchResearchRecords(events, "负向观察", "", display)[0], original);
assert.deepEqual(searchResearchRecords(events, "负向观察", "confirmed", display), []);
assert.deepEqual(searchResearchRecords(events, "Later", "", display), [other], "Partial translations retain original matching");
assert.deepEqual(searchResearchRecords(events, "负向观察"), [], "An absent/stale display never supplies cached aliases");
assert.equal(original.title, "Negative observation");
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts";
import type {ExploreFindingSections} from "../../../../../../loopx/control_plane/presentation/explore_timeline.ts";

/** The history reader supplies its already qualified, current-language projection.
* This search neither validates a translation nor borrows another snapshot. */
export type ResearchRecordDisplay = {
events?: ReadonlyMap<string, ExploreHistoryEvent>;
sections?: ReadonlyMap<string, ExploreFindingSections | null>;
};

/** Search source records, including superseded revisions, before display paging.
* Optional display text broadens matching; results remain the original records.
* Tokens are literal, case-insensitive AND terms; no inferred evidence or status. */
export function searchResearchRecords(events: readonly ExploreHistoryEvent[], query: string, status = "") {
export function searchResearchRecords(events: readonly ExploreHistoryEvent[], query: string, status = "", display?: ResearchRecordDisplay) {
const terms = query.trim().toLowerCase().split(/\s+/u).filter(Boolean);
return events.filter(event => {
if (status && event.status !== status) return false;
if (!terms.length) return true;
const translated = display?.events?.get(event.event_id);
const sections = display?.sections?.get(event.event_id);
const fields = [event.title, event.summary, event.blocked_reason, event.event_id,
event.result_id, event.node_id, event.parent_id, event.from_node, event.to_node,
event.run_id, event.agent_id, event.supersedes, ...event.evidence_refs].map(field => field.toLowerCase());
event.run_id, event.agent_id, event.supersedes, ...event.evidence_refs,
translated?.title, translated?.summary, translated?.blocked_reason, translated?.execution_observation?.change_reason,
sections?.applicability, sections?.observation, sections?.interpretation,
...(sections?.decision?.changed ?? []), ...(sections?.decision?.stopped ?? []), ...(sections?.decision?.retained ?? []),
].filter((field): field is string => typeof field === "string").map(field => field.toLowerCase());
return terms.every(term => fields.some(field => field.includes(term)));
});
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,34 @@ export const researchDisplayLanguageScenario = {
assert(await allRecords.locator("li").first().evaluate(element => parseFloat(getComputedStyle(element).fontSize) >= 16), "Expanded history uses readable body typography");
assert(!await allRecords.innerText().then(text => text.includes("Input revision:")), "Serialized source labels stay inside original view");
const search = allRecords.getByRole("searchbox", {name: "搜索研究记录"});
const downloadRecords = async () => {
const download = page.waitForEvent("download");
await allRecords.getByRole("button", {name: "下载匹配记录(JSON)", exact: true}).click();
return JSON.parse(await readFile(await (await download).path(), "utf8"));
};
const originalsExport = await downloadRecords();
await search.fill("V18 是否在固定预算");
await allRecords.evaluate(element => element.scrollIntoView({block: "start"}));
await page.screenshot({path: resolve(outputDir, `research-translated-search-title-${width}.png`), animations: "disabled"});
await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor();
assert.equal(await allRecords.locator(".research-history-records > li").count(), 1, "Displayed Chinese titles are searchable");
const finding = capture.events.find(event => event.event_kind === "finding");
await search.fill(`第4次尝试 ${finding.event_id}`);
await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor();
const translatedFinding = allRecords.locator(".research-history-records > li").first();
await translatedFinding.locator(":scope > details > summary").click();
assert.match(await translatedFinding.innerText(), /实测变化为 -0.25ms/);
const exported = await downloadRecords();
assert.deepEqual(exported.records, originalsExport.records.filter(event => event.event_id === finding.event_id),
"Chinese matching downloads the original event, not translated prose");
await page.screenshot({path: resolve(outputDir, `research-translated-search-body-${width}.png`), animations: "disabled"});
await search.fill(`核对原始证据 ${finding.event_id}`);
await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor();
await original.click();
await allRecords.getByRole("status", {name: /^匹配 0 \/ 30 条已读记录/}).waitFor();
assert.equal(await search.inputValue(), `核对原始证据 ${finding.event_id}`, "Original view retains the query but removes translation-only matches");
await history.getByRole("button", {name: "查看译文", exact: true}).click();
await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor();
await search.fill(capture.events[0].title);
assert.equal(await allRecords.locator(".research-history-records > li").count(), 1, "Search matches original text while translations are displayed");
const record = allRecords.locator(".research-history-records > li").first();
Expand Down Expand Up @@ -267,6 +295,14 @@ export const researchDisplayLanguageScenario = {
await scope.selectOption({label: "其他来源"});
await history.getByText("译文版本不适用,显示原文", {exact: true}).waitFor();
assert.match(await history.locator(".research-route-caption").first().innerText(), /Does V18/);
await allRecords.locator(":scope > summary").click();
await search.fill("V18 是否在固定预算");
await allRecords.getByRole("status", {name: /^匹配 0 \/ 30 条已读记录/}).waitFor();
await search.fill(capture.events[0].title);
await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor();
assert.equal(await allRecords.locator(".research-history-records > li strong").first().innerText(), capture.events[0].title,
"A mismatched source cannot retain the previous translation's search aliases");
await allRecords.locator(":scope > summary").click();
await scope.selectOption({label: "完整固定快照"});
await original.waitFor();
await page.reload({waitUntil: "domcontentloaded"}); await original.waitFor();
Expand Down
Loading