diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css index 0816518dfb..5851785b3a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css @@ -59,6 +59,12 @@ .analysis-source { border: 1px solid var(--pw-line); border-radius: 8px; padding: 16px; margin-block: 16px; } .analysis-source p { font-size: var(--text-caption); color: var(--pw-muted); } .analysis-source pre { font-family: var(--font-mono); font-size: var(--text-body); line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 360px; overflow: auto; } +.analysis-source-command + .analysis-source-command { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--pw-line); } +.analysis-source-command-meta { display: flex; flex-wrap: wrap; gap: 4px 16px; color: var(--pw-muted); } +.analysis-source pre.analysis-source-output { font-size: var(--text-lead); line-height: var(--leading-prose); max-height: none; overflow: visible; } +.analysis-source p.analysis-source-empty { font-size: var(--text-lead); color: var(--pw-text); } +.analysis-source-content details > summary { min-height: 44px; padding-block: 10px; cursor: pointer; } +.analysis-source-raw { margin-top: 12px; border-top: 1px solid var(--pw-line); } .analysis-method { border-block-start: 1px solid var(--pw-line); margin-top: 16px; padding-top: 12px; color: var(--pw-muted); font-size: var(--text-lead); } .analysis-method > summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; color: var(--pw-text); } .analysis-method dl { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 8px 16px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index f8e5c4dd0a..5c6e34c7c8 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -289,6 +289,39 @@ export function ResearchReportSummary({analysis: a}: {analysis: Controller}) { onExpand={origin => {a.trigger.current = origin; a.show();}} onCitation={a.readCitation}/> : null; } +function CitationContent({citation, zh}: {citation: Citation; zh: boolean}) { + // A reading projection of the native completed-command envelope, not a + // source verifier. Unknown or mixed formats retain the complete literal. + const commands: {output: string; argv: string[]; exitCode: number; status: string}[] = []; + try { + for (const line of citation.text.split("\n")) { + const event: {type?: unknown; payload?: {type?: unknown; item?: Record}} | null = JSON.parse(line); + const item = event?.payload?.item; + if (event?.type !== "event_msg" || event.payload?.type !== "item_completed" + || item?.type !== "CommandExecution" || typeof item.aggregated_output !== "string" + || !Array.isArray(item.command) || !item.command.every((arg: unknown) => typeof arg === "string") + || typeof item.exit_code !== "number" || !Number.isSafeInteger(item.exit_code) || typeof item.status !== "string") { + return
{citation.text}
; + } + commands.push({output: item.aggregated_output, argv: item.command, exitCode: item.exit_code, status: item.status}); + } + } catch { + return
{citation.text}
; + } + return
+ {commands.map((command, index) =>
+
+ {commands.length > 1 ? {zh ? "行" : "Line"} {citation.first_line + index} : null} + {zh ? "退出码" : "Exit code"} {command.exitCode}{command.status} +
+ {command.output ?
{command.output}
+ :

{zh ? "输出为空" : "Empty output"}

} +
{zh ? "命令参数" : "Command arguments"}
{JSON.stringify(command.argv, null, 2)}
+
)} +
{zh ? "完整原始记录" : "Complete raw record"}
{citation.text}
+
; +} + export function ResearchCitationPanel({analysis: a}: {analysis: Controller}) { if (!a.citation && !a.citationBusy && !a.citationError) return null; const zh = a.zh; @@ -298,7 +331,8 @@ export function ResearchCitationPanel({analysis: a}: {analysis: Controller}) {

{zh ? "引用原文" : "Cited original"}

{a.citationBusy ?

{zh ? "正在读取引用原文…" : "Reading cited source…"}

: null} {a.citationError ?

{a.citationError}

: null} - {a.citation ? <>

{a.citation.artifact} · {zh ? "行" : "lines"} {a.citation.first_line}–{a.citation.last_line}

{a.citation.text}
: null} + {a.citation ? <>

{a.citation.artifact} · {zh ? "行" : "lines"} {a.citation.first_line}–{a.citation.last_line}

+ : null} ; } diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/citation-after-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/citation-after-desktop.png new file mode 100644 index 0000000000..f15c22fe0f Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/citation-after-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/citation-after-narrow.png b/examples/personal-workspace-browser/evidence/research-finding-reader/citation-after-narrow.png new file mode 100644 index 0000000000..797f1fad66 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/citation-after-narrow.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/citation-before-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/citation-before-desktop.png new file mode 100644 index 0000000000..fb54b76492 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/citation-before-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/citation-before-narrow.png b/examples/personal-workspace-browser/evidence/research-finding-reader/citation-before-narrow.png new file mode 100644 index 0000000000..702d4e7a39 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/citation-before-narrow.png differ diff --git a/examples/personal-workspace-browser/research-native-reading.mjs b/examples/personal-workspace-browser/research-native-reading.mjs index a7f3548510..db8d99fd30 100644 --- a/examples/personal-workspace-browser/research-native-reading.mjs +++ b/examples/personal-workspace-browser/research-native-reading.mjs @@ -25,6 +25,8 @@ export const researchNativeReadingScenario = { expected: [0.0000001234567890123456, 0.0000001234567890123456, 0.0000001123456789012345, 0.0000001123456789012345], display: ["1.235E-7", "1.235E-7", "1.123E-7", "1.123E-7"], gain: "-1.111E-8"}, {id: "large", values: [123456789.12345679], higher: true, retained: 0, expected: [123456789.12345679, 123456789.12345679, 123456789.12345679, 123456789.12345679], display: ["1.235E8", "1.235E8", "1.235E8", "1.235E8"], gain: "0"}, + ...["command", "mixed", "damaged", "empty-output", "unsafe-exit"].map(source => ({id: `${source}-source`, source, + values: [2, 1], higher: false, retained: 0, expected: [1, 2, 1, 2]})), ]; const root = await mkdtemp(resolve(tmpdir(), "loopx-native-reading-")); let owner, page; @@ -48,7 +50,20 @@ export const researchNativeReadingScenario = { })); if (c.gain) native.comparison = {baseline_result_id: "result-0", retention_citation: {source_id: "facts", first_line: c.retained + 1, last_line: c.retained + 1}}; - const text = c.values.map((v, i) => `Synthetic observation ${i}: value ${v ?? "unknown"}; valid ${v !== null}.`).join("\n") || "No measurements captured."; + let text = c.values.map((v, i) => `Synthetic observation ${i}: value ${v ?? "unknown"}; valid ${v !== null}.`).join("\n") || "No measurements captured."; + if (c.source) { + const command = (output, exitCode) => JSON.stringify({type: "event_msg", ordinal: exitCode + 1, + payload: {type: "item_completed", item: {type: "CommandExecution", status: "completed", exit_code: exitCode, + command: ["python", "-c", "print('synthetic output')"], aggregated_output: output}}}); + const output = JSON.stringify({cases: 8, improved: 3, regressed: 2, unchanged: 3, complete_cost: null}, null, 2); + const longFailure = "Validation failed\n0 accepted updates\n\n" + + Array.from({length: 32}, (_, i) => `Synthetic case ${i + 1}: negative result retained; 完整成本仍未知。`).join("\n"); + text = [command(c.source === "empty-output" ? "" : output, 0), c.source === "mixed" + ? JSON.stringify({type: "unknown_event", text: "Unrecognized source MUST_REMAIN_LITERAL"}) + : c.source === "damaged" ? "{malformed MUST_REMAIN_LITERAL" + : command(c.source === "empty-output" ? "" : longFailure, c.source === "unsafe-exit" ? Number.MAX_SAFE_INTEGER + 1 : 17)].join("\n"); + native.samples[0].citation.last_line = 2; + } a.artifacts["facts.txt"] = text; a.artifacts["native.json"] = JSON.stringify(native); Object.assign(a.input.sources[0], {text, artifact_sha256: createHash("sha256").update(text).digest("hex")}); @@ -56,6 +71,9 @@ export const researchNativeReadingScenario = { }); const path = resolve(root, "cases.json"); await writeFile(path, JSON.stringify(fixture)); + // Same public synthetic data for reproducible before/after inspection. + await writeFile(resolve(outputDir, "research-citation-reader-fixture.json"), JSON.stringify({...fixture, + analyses: fixture.analyses.filter(a => a.title === "command-source")})); owner = await startHistoryOwner(path); page = await browser.newPage({viewport: {width: 1280, height: 900}, hasTouch: true}); const errors = []; @@ -115,6 +133,47 @@ export const researchNativeReadingScenario = { await page.waitForFunction(() => document.activeElement?.getAttribute("aria-label")?.startsWith("原生保留结果:")); await page.screenshot({path: resolve(outputDir, "research-native-negative-phone.png"), animations: "disabled"}); } + if (c.source) { + const retained = native.getByRole("button", {name: /原生保留结果.*查看原始证据/}); + await retained.focus(); await retained.press("Enter"); + const citation = page.getByRole("region", {name: "引用原文", exact: true}); + await citation.waitFor(); + const literal = fixture.analyses.find(a => a.title === c.id).artifacts["facts.txt"]; + if (["mixed", "damaged", "unsafe-exit"].includes(c.source)) { + await citation.locator("pre").waitFor(); + assert.equal(await citation.locator("pre").textContent(), literal, "Never discard unsupported or malformed neighboring evidence"); + assert.equal(await citation.locator("details").count(), 0); + } else { + await citation.locator(".analysis-source-content").waitFor(); + assert.equal(await citation.locator(".analysis-source-command").count(), 2); + await citation.getByText("行 2", {exact: true}).waitFor(); + await citation.getByText("退出码 17", {exact: true}).waitFor(); + const raw = citation.locator(".analysis-source-raw"); + assert.equal(await raw.getAttribute("open"), null); + await raw.locator("summary").tap(); + assert.equal(await raw.locator("pre").textContent(), literal, "Raw evidence remains byte-for-byte available"); + await raw.locator("summary").tap(); + if (c.source === "empty-output") assert.equal(await citation.getByText("输出为空", {exact: true}).count(), 2); + else { + const records = literal.split("\n").map(line => JSON.parse(line).payload.item); + for (const [i, record] of records.entries()) { + assert.equal(await citation.locator(".analysis-source-output").nth(i).textContent(), record.aggregated_output); + } + assert.equal(await citation.locator("script").count(), 0); + assert.equal(await page.evaluate(() => window.sourceExecuted), undefined); + for (const width of [1280, 390]) { + await page.setViewportSize({width, height: 900}); + await citation.evaluate(e => e.scrollIntoView({block: "start"})); + assert(await citation.evaluate(e => e.scrollWidth <= e.clientWidth + 1)); + assert.equal(await citation.locator(".analysis-source-output").first().evaluate(e => parseFloat(getComputedStyle(e).fontSize)), 16); + assert(await citation.locator(".analysis-source-output").last().evaluate(e => e.clientHeight > 360), "Full long output uses page reading, not an inner clipped wall"); + await page.screenshot({path: resolve(outputDir, `research-citation-output-${width}.png`), animations: "disabled"}); + } + } + } + await citation.press("Escape"); + await page.waitForFunction(() => document.activeElement?.getAttribute("aria-label")?.startsWith("原生保留结果:")); + } if (c.unit) { await page.evaluate(() => document.documentElement.style.fontSize = "200%"); assert(await native.evaluate(e => e.scrollWidth <= e.clientWidth + 1), "Long unit remains readable at 200% text"); @@ -132,12 +191,20 @@ export const researchNativeReadingScenario = { await page.getByRole("button", {name: "About native measurements", exact: true}).focus(); await page.getByRole("tooltip").waitFor({state: "visible"}); await page.keyboard.press("Escape"); + await selector.selectOption({label: "command-source"}); + const selected = await selector.inputValue(); + await page.locator(`.research-progress-scope[data-pack-id="${selected}"]`).waitFor(); + await page.getByRole("region", {name: "Native measurements", exact: true}).getByRole("button", {name: /Native retained result.*Read original evidence/}).click(); + const originalPanel = page.getByRole("region", {name: "Cited original", exact: true}); + await originalPanel.getByText("Exit code 17", {exact: true}).waitFor(); + await originalPanel.getByText("Complete raw record", {exact: true}).waitFor(); + await originalPanel.press("Escape"); const readback = await (await page.request.get(`${owner.base}/fixture/readback`)).json(); assert.equal(readback.source_unchanged, true); assert.equal(readback.mutation_attempts, 0); assert.deepEqual(errors, []); return {coverageEntries: collectCoverage ? await page.coverage.stopJSCoverage() : [], - note: "real-owner native result roles: signed/zero/invalid/missing/partial, compact decimal/tiny/large values with full-precision details, exact citations, readable desktop/390/200%, Chinese and English; no mutation"}; + note: "real-owner native roles and citations; literal command output, failure/empty/long text, raw preservation, unknown/malformed fallback, exact focus return, desktop/390 and Chinese/English; no mutation"}; } finally { await page?.close(); await owner?.close();