diff --git a/apps/presentation/dashboard/src/data/fixtures/research-synthesis-readback.json b/apps/presentation/dashboard/src/data/fixtures/research-synthesis-readback.json index 0447d13b96..2de197ca25 100644 --- a/apps/presentation/dashboard/src/data/fixtures/research-synthesis-readback.json +++ b/apps/presentation/dashboard/src/data/fixtures/research-synthesis-readback.json @@ -91,6 +91,26 @@ "eligible_metrics": [ "benefit_test" ] + }, + { + "id": "control-episode", + "question": "Does a matched control distinguish the proposed mechanism from variation?", + "source_ids": [ + "facts" + ], + "eligible_metrics": [ + "discrimination" + ] + }, + { + "id": "retention-episode", + "question": "Is the later candidate recorded as actually retained?", + "source_ids": [ + "facts" + ], + "eligible_metrics": [ + "evidence_adoption" + ] } ] }, @@ -109,6 +129,32 @@ "last_line": 3 } ] + }, + { + "episode_id": "control-episode", + "metric": "discrimination", + "verdict": "unknown", + "reason": "没有记录匹配对照;改动效应与随机波动尚不能区分。", + "citations": [ + { + "source_id": "facts", + "first_line": 1, + "last_line": 3 + } + ] + }, + { + "episode_id": "retention-episode", + "metric": "evidence_adoption", + "verdict": "unknown", + "reason": "原始记录没有保留信息;最佳值不能替代实际采用记录。", + "citations": [ + { + "source_id": "facts", + "first_line": 4, + "last_line": 4 + } + ] } ], "limitations": [ @@ -118,6 +164,14 @@ { "episode_id": "search-episode", "question": "后续搜索是否检验了实际任务进展?" + }, + { + "episode_id": "control-episode", + "question": "匹配对照是否区分了改动机制与随机波动?" + }, + { + "episode_id": "retention-episode", + "question": "后续候选是否记录为实际保留?" } ], "synthesis": { @@ -143,6 +197,7 @@ "随机波动可能解释部分差值;当前材料无法排除。" ], "episode_ids": [ + "control-episode", "search-episode" ], "citations": [ @@ -159,7 +214,7 @@ "interpretation": "保留结果未知;这不抹去已观测的改善。", "alternative_explanations": [], "episode_ids": [ - "search-episode" + "retention-episode" ], "citations": [ { @@ -179,7 +234,7 @@ "prediction": "若改动有效,配对差值应持续改善,而非只出现单次最好值。", "disconfirming_result": "若配对重复后差值消失或反向,应放弃该机制解释。", "episode_ids": [ - "search-episode" + "control-episode" ], "citations": [ { @@ -206,6 +261,32 @@ "last_line": 3 } ] + }, + { + "episode_id": "control-episode", + "metric": "discrimination", + "verdict": "unknown", + "reason": "No matched control is recorded; the intervention and random variation remain unresolved.", + "citations": [ + { + "source_id": "facts", + "first_line": 1, + "last_line": 3 + } + ] + }, + { + "episode_id": "retention-episode", + "metric": "evidence_adoption", + "verdict": "unknown", + "reason": "The source records no retention; the best value cannot establish actual adoption.", + "citations": [ + { + "source_id": "facts", + "first_line": 4, + "last_line": 4 + } + ] } ], "limitations": [ @@ -215,6 +296,14 @@ { "episode_id": "search-episode", "question": "Did later search test useful task progress?" + }, + { + "episode_id": "control-episode", + "question": "Did a matched control distinguish the intervention from variation?" + }, + { + "episode_id": "retention-episode", + "question": "Was the later candidate recorded as actually retained?" } ], "synthesis": { @@ -240,6 +329,7 @@ "Random variation could explain some of the difference; these records cannot exclude it." ], "episode_ids": [ + "control-episode", "search-episode" ], "citations": [ @@ -256,7 +346,7 @@ "interpretation": "Retention is unknown without erasing observed improvement.", "alternative_explanations": [], "episode_ids": [ - "search-episode" + "retention-episode" ], "citations": [ { @@ -276,7 +366,7 @@ "prediction": "An effective change produces consistent paired gains rather than a single best value.", "disconfirming_result": "Disappearing or reversed paired differences would disconfirm the mechanism.", "episode_ids": [ - "search-episode" + "control-episode" ], "citations": [ { 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 0275a6c1c7..f14ba8fa2d 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 @@ -20,8 +20,12 @@ .analysis-error { color: var(--pw-red); } .analysis-finding { padding-block: 12px 8px; margin-top: 12px; max-width: 76ch; } .analysis-finding h4 { margin-top: 8px; } +.analysis-episodes { color: var(--pw-text); } .analysis-episode-columns, .analysis-episode > summary { display: grid; grid-template-columns: minmax(0, 1fr) 8rem 6rem 9rem 1rem; align-items: baseline; gap: 16px; } .analysis-episode-columns { color: var(--pw-muted); padding-block: 8px; border-bottom: 1px solid var(--pw-line); } +.analysis-episode-reading { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-block: 8px 16px; } +.analysis-episode-reading > span { color: var(--pw-muted); font-size: var(--text-body); } +.analysis-episode-reading > button { color: var(--pw-text); } .analysis-episode-group + .analysis-episode-group { margin-top: 12px; } .research-analysis .analysis-shared-question { margin: 12px 0 4px; max-width: 76ch; color: var(--pw-muted); font-size: var(--text-body); } .analysis-episode { border-block-end: 1px solid var(--pw-line); } @@ -56,7 +60,7 @@ .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-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 > 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; } .analysis-method dd { margin: 0; color: var(--pw-text); } .analysis-continuation { margin-block: 16px; border-block: 1px solid var(--pw-line); padding-block: 12px; } 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 019dacf5a7..7589627889 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 @@ -1,5 +1,5 @@ -import {ArrowUpRight, ChevronDown, CircleHelp, LoaderCircle, RefreshCw, Sparkles, TriangleAlert, X} from "lucide-react"; -import {useCallback, useEffect, useRef, useState} from "react"; +import {useCallback, useEffect, useLayoutEffect, useRef, useState} from "react"; +import {ArrowLeft, ArrowUpRight, ChevronDown, CircleHelp, LoaderCircle, RefreshCw, Sparkles, TriangleAlert, X} from "lucide-react"; import type {AssessmentCitation, TrajectoryAssessment, TrajectoryEpisode, TrajectoryOutputLanguage, TrajectoryContinuation} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js"; import {ResearchProgress, episodeQuestionGroups, metricCopy, verdictLabel, type ResearchProgressPack} from "./goal-research-progress"; import {requestJson} from "../../data/chat"; @@ -301,9 +301,10 @@ export function ResearchCitationPanel({analysis: a}: {analysis: Controller}) { ; } -function AnalysisEpisodes({pack, result, zh, onCitation}: {pack: Pack; result: TrajectoryAssessment; zh: boolean; onCitation: Controller["readCitation"]}) { - const [active, setActive] = useState(null); - const groups = episodeQuestionGroups(result, (pack.episodes ?? []).map(episode => ({episode_id: episode.id, episode})), pack.episodes); +function AnalysisEpisodes({pack, result, zh, onCitation, selectedIds}: {pack: Pack; result: TrajectoryAssessment; zh: boolean; onCitation: Controller["readCitation"]; selectedIds?: string[]}) { + const episodes = (pack.episodes ?? []).filter(episode => !selectedIds || selectedIds.includes(episode.id)); + const [active, setActive] = useState(() => selectedIds ? episodes[0]?.id ?? null : null); + const groups = episodeQuestionGroups(result, episodes.map(episode => ({episode_id: episode.id, episode})), pack.episodes); const citationButtons = (citations: AssessmentCitation[]) =>
{citations.map(c => )}
; return
{zh ? "研究片段" : "Episode"}{metricCopy.benefit_test[zh ? 0 : 1]}{metricCopy.evidence_adoption[zh ? 0 : 1]}{zh ? "需留意" : "Exceptions"}
@@ -334,7 +335,7 @@ function AnalysisEpisodes({pack, result, zh, onCitation}: {pack: Pack; result: T } const isPrimary = (group: typeof rows) => group.some(row => row.metric === "benefit_test" || row.metric === "evidence_adoption"); const judgments = (group: typeof rows) =>
{group.map(row => {metricCopy[row.metric][zh ? 0 : 1]}{verdictLabel(row.metric, row.verdict, zh)})}
; - return
+ return
{event.preventDefault(); setActive(previous => previous === episode.id ? null : episode.id);}} aria-label={[question, ...primary.map((row, i) => `${row.label} · ${primaryText[i]}`), unknownText, ...exceptionText].filter(Boolean).join(" · ")}> {group.shared ? episode.id : question} @@ -358,6 +359,46 @@ function AnalysisEpisodes({pack, result, zh, onCitation}: {pack: Pack; result: T
; } +/** Follow the synthesis owner's explicit episode references, never a prose or + * time-window match against the current Goal's broader Explore history. */ +function AnalysisReport({pack, result, zh, onCitation}: {pack: Pack; result: TrajectoryAssessment; zh: boolean; onCitation: Controller["readCitation"]}) { + const reader = useRef(null); + const origin = useRef(null); + const [review, setReview] = useState<{ids: string[]; sequence: number} | null>(null); + const [all, setAll] = useState(false); + const shownCount = (pack.episodes ?? []).filter(episode => all || !review || review.ids.includes(episode.id)).length; + useLayoutEffect(() => { + if (!review || !reader.current) return; + reader.current.open = true; + const target = reader.current.querySelector(".analysis-episode > summary") ?? reader.current.querySelector("summary"); + target?.focus({preventScroll: true}); + reader.current.scrollIntoView({block: "start"}); + }, [review]); + return <> + { + origin.current = trigger; + setAll(false); + setReview(previous => ({ids, sequence: (previous?.sequence ?? 0) + 1})); + } : undefined}/> +
{zh ? "研究片段" : "Research episodes"} + {review ?
+ + {shownCount} / {pack.episode_count} + +
: null} + +
+ ; +} + export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Controller; onHistory?: () => void}) { if (!a.location.open) return null; const zh = a.zh, p = a.pack, result = p?.result; @@ -370,7 +411,7 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr : benefit?.yes ? (zh ? "模型记录了收益检验;这不等于收益为正" : "The model identified a utility test; this does not imply positive utility") : (zh ? "此方法未覆盖原目标收益检验" : "This method does not cover original-objective utility testing"); return
{if (event.key === "Escape") {event.stopPropagation(); a.close();}}}> -

{result?.synthesis ? (zh ? "研究汇报" : "Research report") : (zh ? "轨迹深度分析" : "Trajectory analysis")}

{p?.title ?? (zh ? "只分析已授权的固定轨迹,不改变研究历史。" : "Analyze an authorized fixed trajectory without changing its history.")}

{result?.synthesis && onHistory ? : null}{result ? : null}
+

{result?.synthesis ? (zh ? "研究汇报" : "Research report") : (zh ? "轨迹深度分析" : "Trajectory analysis")}

{p?.title ?? (zh ? "只分析已授权的固定轨迹,不改变研究历史。" : "Analyze an authorized fixed trajectory without changing its history.")}

{result?.synthesis && onHistory ? : null}{result ? : null}
{a.error ?
{zh ? "分析请求未完成,已保存结果保留。" : "The analysis request did not complete; saved results are preserved."}

{a.error}

: null} {a.snapshot?.catalog.unreadable_pack_ids?.length && !p?.continuation_unavailable ?

{zh ? "部分历史分析暂不可读" : "Some historical analyses cannot be read"} · {a.snapshot.catalog.unreadable_pack_ids.length}

: null} @@ -399,8 +440,7 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr {zh ? "本次尝试" : "This attempt"} · {p.job?.analysis_wall_seconds == null ? (zh ? "耗时未知" : "Time unknown") : `${p.job.analysis_wall_seconds.toFixed(1)}s`} · {zh ? "输入" : "input"} {p.job?.usage?.input_tokens?.toLocaleString() ?? "—"} / {zh ? "输出" : "output"} {p.job?.usage?.output_tokens?.toLocaleString() ?? "—"} tokens

: null} {result ? <> - {result.synthesis ? <> -
{zh ? "逐片段复核" : "Review individual episodes"}
+ {result.synthesis ? : <>
{zh ? "Agent 判断 · 未校准" : "Agent interpretation · uncalibrated"}{p.applicability === "historical" ? (zh ? " · 历史结果" : " · Historical result") : ""}

{headline}

}
{zh ? "来源、方法、覆盖与分析成本" : "Sources, method, coverage and analysis cost"}

{p.objective}

{zh ? "来源范围" : "Source scope"}
{p.scope.case_id} · {p.scope.run_id}
{p.scope.window}
{zh ? "方法" : "Method"}
{result.method_version} · {zh ? "模型判断未校准" : "Uncalibrated model judgment"}
{zh ? "模型设置" : "Requested model"}
{p.config.model} / {p.config.effort} / {p.config.service_tier} · {zh ? "实际服务档位" : "Served tier"}: {resultJob?.served_service_tier ?? (zh ? "未知" : "unknown")}
{zh ? "分析用量" : "Analysis usage"}
{resultJob?.reused_from_analysis_key ? (zh ? "复用上一版判读,无新增模型调用;原用量保留在上一版。" : "Reused previous judgments without a new model call; original usage stays with the previous version.") : <>{resultJob?.analysis_wall_seconds == null ? "—" : `${resultJob.analysis_wall_seconds.toFixed(1)}s`} · {zh ? "输入" : "input"} {resultJob?.usage?.input_tokens?.toLocaleString() ?? "—"} / {zh ? "输出" : "output"} {resultJob?.usage?.output_tokens?.toLocaleString() ?? "—"} tokens · {zh ? "金额未采集,推理 token 已包含于输出" : "Dollar cost unmeasured; reasoning tokens are included in output"}}
{zh ? "覆盖" : "Coverage"}
{p.episode_count} {zh ? "个固定片段" : "fixed episodes"} · {p.source_count} {zh ? "份来源" : "sources"} · {zh ? "总体片段数" : "Population"}: {p.selection.population_episodes ?? (zh ? "未知" : "unknown")}
{zh ? "取样规则" : "Selection"}
{p.selection.rule}
{zh ? "版本" : "Revision"}
{p.scope.revision}
    {result.limitations.map((text, i) =>
  • {text}
  • )}

{zh ? "引用行存在不等于解释正确;本页不提供独立科学校准结论。" : "A valid citation range does not establish entailment or independent scientific calibration."}

diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-synthesis.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-synthesis.css index a1a0371994..0bd305f0a1 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-synthesis.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-synthesis.css @@ -19,6 +19,7 @@ .research-synthesis-citations { display: flex; flex-wrap: wrap; gap: 0 4px; } .research-synthesis .research-synthesis-citations button { display: inline-flex; align-items: center; gap: 2px; min-height: 44px; min-width: 36px; border: 0; background: none; color: var(--pw-muted); font: inherit; font-size: var(--text-body); padding: 4px; cursor: pointer; } .research-synthesis button:hover { text-decoration: underline; text-underline-offset: 3px; } +.research-synthesis .research-review-episodes { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 4px 0; border: 0; background: none; color: var(--pw-text); font: inherit; font-size: var(--text-body); cursor: pointer; } .research-synthesis button:focus-visible, .research-synthesis summary:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } .research-synthesis-detail { max-width: none; } .research-report-tabs { display: flex; gap: 24px; border-block-end: 1px solid var(--pw-line); margin-block: 16px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-synthesis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-synthesis.tsx index 8065d00618..be2a43e580 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-synthesis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-synthesis.tsx @@ -1,14 +1,15 @@ import {useId, useState, type ReactNode} from "react"; -import {ArrowUpRight} from "lucide-react"; +import {ArrowDownRight, ArrowUpRight} from "lucide-react"; import type {AssessmentCitation, TrajectoryResearchSynthesis} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js"; import "./goal-research-synthesis.css"; type Props = {synthesis: TrajectoryResearchSynthesis; zh: boolean; detail?: boolean; onExpand?: (origin: HTMLButtonElement) => void; + onReviewEpisodes?: (ids: string[], origin: HTMLButtonElement) => void; onCitation: (citation: AssessmentCitation, origin?: HTMLButtonElement) => void}; /** One saved result, progressively disclosed. Never a new inference or executor. */ -export function ResearchSynthesis({synthesis: s, zh, detail = false, onExpand, onCitation}: Props) { +export function ResearchSynthesis({synthesis: s, zh, detail = false, onExpand, onReviewEpisodes, onCitation}: Props) { const id = useId(); const [experiments, setExperiments] = useState(false); const citations = (rows: AssessmentCitation[]) =>
{rows.map((c, i) => @@ -16,6 +17,8 @@ export function ResearchSynthesis({synthesis: s, zh, detail = false, onExpand, o aria-label={`${zh ? "证据" : "Evidence"} ${i + 1}: ${c.source_id} · ${c.first_line}–${c.last_line}`} onClick={event => onCitation(c, event.currentTarget)}>[{i + 1}])}
; const alternatives = (items: string[]) => items.length ? <>
{zh ? "竞争解释" : "Competing explanations"}
    {items.map((text, n) =>
  • {text}
  • )}
: null; + const episodeLink = (ids: string[]) => onReviewEpisodes && ids.length ? : null; const controls = (experiment: TrajectoryResearchSynthesis["next_experiments"][number]) =>
{zh ? "最小干预" : "Smallest intervention"}
{experiment.intervention}
{zh ? "固定条件" : "Controls"}
{experiment.controls}
@@ -51,6 +54,7 @@ export function ResearchSynthesis({synthesis: s, zh, detail = false, onExpand, o
{i + 1}. {experiment.question}

{experiment.why_priority}

{controls(experiment)}{predictions(experiment)}
{citations(experiment.citations)} + {episodeLink(experiment.episode_ids)} )}
:
{!!s.findings.length &&
{zh ? "观察与依据" : "Observation and evidence"}{zh ? "解释与竞争解释" : "Interpretation and alternatives"}
} @@ -59,13 +63,14 @@ export function ResearchSynthesis({synthesis: s, zh, detail = false, onExpand, o
{i + 1}. {finding.title}

{finding.observation}

{citations(finding.citations)}

{finding.interpretation}

{alternatives(finding.alternative_explanations)}
+ {episodeLink(finding.episode_ids)} )}
} ; } return
-
{!detail ?

{zh ? "研究汇报" : "Research report"}

: null}{zh ? "Agent 分析 · 所选片段" : "Agent analysis · selected episodes"} +
{!detail ?

{zh ? "研究汇报" : "Research report"}

: null}{zh ? "Agent 分析" : "Agent analysis"} {!detail && onExpand ? : null}

{s.summary.text}

diff --git a/docs/development/assets/research-report-episodes/before-episodes-narrow.png b/docs/development/assets/research-report-episodes/before-episodes-narrow.png new file mode 100644 index 0000000000..7491557ee8 Binary files /dev/null and b/docs/development/assets/research-report-episodes/before-episodes-narrow.png differ diff --git a/docs/development/assets/research-report-episodes/before-report-desktop.png b/docs/development/assets/research-report-episodes/before-report-desktop.png new file mode 100644 index 0000000000..fcffca25b1 Binary files /dev/null and b/docs/development/assets/research-report-episodes/before-report-desktop.png differ diff --git a/docs/development/assets/research-report-episodes/overview-desktop.png b/docs/development/assets/research-report-episodes/overview-desktop.png new file mode 100644 index 0000000000..237114db85 Binary files /dev/null and b/docs/development/assets/research-report-episodes/overview-desktop.png differ diff --git a/docs/development/assets/research-report-episodes/overview-narrow.png b/docs/development/assets/research-report-episodes/overview-narrow.png new file mode 100644 index 0000000000..9b3b15b250 Binary files /dev/null and b/docs/development/assets/research-report-episodes/overview-narrow.png differ diff --git a/docs/development/assets/research-report-episodes/related-desktop.png b/docs/development/assets/research-report-episodes/related-desktop.png new file mode 100644 index 0000000000..ee7fc20abc Binary files /dev/null and b/docs/development/assets/research-report-episodes/related-desktop.png differ diff --git a/docs/development/assets/research-report-episodes/related-narrow-en.png b/docs/development/assets/research-report-episodes/related-narrow-en.png new file mode 100644 index 0000000000..e314510482 Binary files /dev/null and b/docs/development/assets/research-report-episodes/related-narrow-en.png differ diff --git a/docs/development/assets/research-report-episodes/related-narrow.png b/docs/development/assets/research-report-episodes/related-narrow.png new file mode 100644 index 0000000000..465ea66ef0 Binary files /dev/null and b/docs/development/assets/research-report-episodes/related-narrow.png differ diff --git a/docs/development/assets/research-report-episodes/report-desktop.png b/docs/development/assets/research-report-episodes/report-desktop.png new file mode 100644 index 0000000000..8c427c028b Binary files /dev/null and b/docs/development/assets/research-report-episodes/report-desktop.png differ diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index c12389c14a..7537385d99 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -173,7 +173,7 @@ export const researchHistoryScenario = { const synthesisOwner = await startHistoryOwner(resolve(repoRoot, "apps/presentation/dashboard/src/data/fixtures/research-synthesis-readback.json")); const synthesisPage = await browser.newPage({viewport: {width: 1280, height: 900}}); try { - await synthesisPage.addInitScript(() => localStorage.setItem('loopx-pw-locale', 'zh-CN')); + await synthesisPage.addInitScript(() => {if (!localStorage.getItem('loopx-pw-locale')) localStorage.setItem('loopx-pw-locale', 'zh-CN');}); const query = new URLSearchParams({goalId: 'synthetic-research-readback', view: 'research', statusUrl: '/status.json?goal_id=synthetic-research-readback'}); await synthesisPage.goto(`${synthesisOwner.base}/chat/?${query}`, {waitUntil: 'domcontentloaded'}); const synthesis = synthesisPage.getByRole('region', {name: '研究汇报', exact: true}); @@ -229,8 +229,62 @@ export const researchHistoryScenario = { await analysis.getByRole('tab', {name: /结论与解释/}).click(); await analysis.evaluate(e => e.scrollIntoView({block: 'start'})); await synthesisPage.screenshot({path: resolve(outputDir, 'research-synthesis-deep-desktop.png'), animations: 'disabled'}); + // The report already declares its episode lineage. Follow those exact + // identities, not the whole Goal's history or a title/time heuristic. + const review = analysis.locator('.analysis-method').first(); + const related = analysis.locator('.research-report-finding .research-review-episodes').first(); + await related.click(); + assert.deepEqual(await review.locator('[data-analysis-episode]').evaluateAll(rows => rows.map(row => row.dataset.analysisEpisode)), ['search-episode', 'control-episode']); + assert.equal(await review.locator('.analysis-episode[open]').getAttribute('data-analysis-episode'), 'search-episode'); + await synthesisPage.waitForFunction(() => document.activeElement?.closest('[data-analysis-episode]')?.dataset.analysisEpisode === 'search-episode'); + assert.match(await review.innerText(), /证据不足/); + await synthesisPage.screenshot({path: resolve(outputDir, 'research-related-episodes-desktop.png'), animations: 'disabled'}); + await review.locator('.analysis-episode[open] .analysis-citations button').first().click(); + await source.getByText(/Synthetic baseline: candidate-one/).waitFor(); + await source.press('Escape'); + await synthesisPage.waitForFunction(() => document.activeElement?.closest('[data-analysis-episode]')?.dataset.analysisEpisode === 'search-episode'); + await review.getByRole('button', {name: '返回汇报', exact: true}).click(); + assert.equal(await review.evaluate(e => e.open), false); + assert(await related.evaluate(e => e === document.activeElement)); + + const retention = analysis.locator('.research-report-finding .research-review-episodes').nth(1); + await retention.click(); + assert.deepEqual(await review.locator('[data-analysis-episode]').evaluateAll(rows => rows.map(row => row.dataset.analysisEpisode)), ['retention-episode']); + assert.match(await review.locator('.analysis-episode[open]').innerText(), /证据不足/); + await review.getByRole('button', {name: '显示全部片段', exact: true}).click(); + assert.deepEqual(await review.locator('[data-analysis-episode]').evaluateAll(rows => rows.map(row => row.dataset.analysisEpisode)), ['search-episode', 'control-episode', 'retention-episode']); + assert.equal(await review.getByRole('status', {name: '全部片段', exact: true}).innerText(), '3 / 3', 'The reading scope describes the episodes actually shown'); + const subset = review.getByRole('button', {name: '只看相关片段', exact: true}); + assert(await subset.evaluate(e => e === document.activeElement), 'Changing scope retains the same control focus'); + await subset.click(); + assert.deepEqual(await review.locator('[data-analysis-episode]').evaluateAll(rows => rows.map(row => row.dataset.analysisEpisode)), ['retention-episode']); + assert.equal(await review.getByRole('status', {name: '相关片段', exact: true}).innerText(), '1 / 3'); + await review.getByRole('button', {name: '返回汇报', exact: true}).click(); + assert(await retention.evaluate(e => e === document.activeElement)); + + await related.click(); + const nextExperiments = analysis.getByRole('tab', {name: /下一步实验/}); + await nextExperiments.click(); + await review.getByRole('button', {name: '返回汇报', exact: true}).click(); + assert(await nextExperiments.evaluate(e => e === document.activeElement), 'A removed report trigger returns to the current report tab'); + const experiment = analysis.locator('.research-report-experiment .research-review-episodes').first(); + await synthesisPage.setViewportSize({width: 390, height: 844}); + await experiment.click(); + assert.deepEqual(await review.locator('[data-analysis-episode]').evaluateAll(rows => rows.map(row => row.dataset.analysisEpisode)), ['control-episode']); + assert(await synthesisPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + assert(await review.locator('.analysis-episode[open] > summary').evaluate(row => row.getBoundingClientRect().top < innerHeight - 80), 'The chosen episode is visible after a narrow-screen jump'); + assert(await review.locator('.analysis-episode[open] > summary').evaluate(row => { + const name = row.querySelector('.analysis-episode-name').getBoundingClientRect(); + const verdict = row.querySelector('.analysis-episode-verdict').getBoundingClientRect(); + return verdict.top >= name.bottom; + }), 'Narrow episode verdicts reflow below the full selected question'); + await synthesisPage.screenshot({path: resolve(outputDir, 'research-related-episodes-mobile.png'), animations: 'disabled'}); + await review.getByRole('button', {name: '返回汇报', exact: true}).click(); + assert(await experiment.evaluate(e => e === document.activeElement)); + assert.equal(await analysis.getByRole('tab', {name: /下一步实验/}).getAttribute('aria-selected'), 'true'); + await synthesisPage.setViewportSize({width: 1280, height: 900}); const packBeforeHistory = new URL(synthesisPage.url()).searchParams.get('research_pack'); - await analysis.getByRole('button', {name: '查看研究历程', exact: true}).click(); + await analysis.getByRole('button', {name: 'Goal 研究历程', exact: true}).click(); assert.equal(await analysis.count(), 0); assert.equal(new URL(synthesisPage.url()).searchParams.get('research_pack'), packBeforeHistory); await synthesisPage.waitForFunction(() => document.activeElement?.classList.contains('research-history-heading')); @@ -238,6 +292,23 @@ export const researchHistoryScenario = { await synthesisPage.getByRole('button', {name: '深入解读', exact: true}).click(); await analysis.getByRole('button', {name: '关闭分析', exact: true}).click(); await synthesisPage.waitForFunction(() => document.activeElement?.textContent?.includes('深入解读')); + await synthesisPage.evaluate(() => localStorage.setItem('loopx-pw-locale', 'en')); + await synthesisPage.reload({waitUntil: 'domcontentloaded'}); + await synthesisPage.setViewportSize({width: 390, height: 844}); + await synthesisPage.getByRole('button', {name: 'Read deeper', exact: true}).click(); + const english = synthesisPage.locator('.research-analysis'); + const englishReview = english.locator('.analysis-method').first(); + const englishOrigin = english.locator('.research-report-finding .research-review-episodes').first(); + await englishOrigin.click(); + assert.equal(await englishReview.getByRole('status', {name: 'Related episodes', exact: true}).innerText(), '2 / 3'); + await englishReview.getByRole('button', {name: 'Show all episodes', exact: true}).click(); + assert.equal(await englishReview.getByRole('status', {name: 'All episodes', exact: true}).innerText(), '3 / 3'); + await englishReview.getByRole('button', {name: 'Related episodes only', exact: true}).click(); + assert.deepEqual(await englishReview.locator('[data-analysis-episode]').evaluateAll(rows => rows.map(row => row.dataset.analysisEpisode)), ['search-episode', 'control-episode']); + assert.match(await englishReview.locator('[data-analysis-episode="control-episode"] > summary').innerText(), /Unknown 1/); + await synthesisPage.screenshot({path: resolve(outputDir, 'research-related-episodes-mobile-en.png'), animations: 'disabled'}); + await englishReview.getByRole('button', {name: 'Back to report', exact: true}).click(); + assert(await englishOrigin.evaluate(e => e === document.activeElement)); const readback = await (await synthesisPage.request.get(`${synthesisOwner.base}/fixture/readback`)).json(); assert.equal(readback.source_unchanged, true); assert.equal(readback.mutation_attempts, 0, 'Reading either layer and citations starts no model or state write');