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 @@ -103,8 +103,15 @@ try {
localizedReady = false;
const child = JSON.parse(await readFile(resolve(import.meta.dirname, "../src/data/fixtures/case-study-child.json"), "utf8"));
const parent = {goal_id: child.parent_goal_id, display_name: "Benchmark", events: [], analyses: []};
const second = structuredClone(child.analyses[0]);
second.title = "另一版解读";
second.case_study.sections[1].citations[0].first_line = 2;
child.analyses.push(second);
const study = await start("study", child, parent);
const studyPack = (await (await fetch(new URL(analysisPath + "?" + new URLSearchParams({goal_id: child.goal_id}), study))).json()).pack;
const studyRead = await (await fetch(new URL(analysisPath + "?" + new URLSearchParams({goal_id: child.goal_id}), study))).json();
const studyPack = studyRead.pack;
const otherPack = studyRead.catalog.packs.find(pack => pack.pack_id !== studyPack.pack_id);
assert(otherPack, "Same Goal has two saved versions");
const chapter = studyPack.case_study.sections[1];
const address = new URL("/chat/", study);
address.search = new URLSearchParams({goalId: child.goal_id, view: "research", research_goal: child.goal_id,
Expand All @@ -113,6 +120,73 @@ try {
const atChapter = () => page.waitForFunction(id => document.activeElement?.id === id, address.hash.slice(1));
await page.goto(address.href);
await atChapter();
const sourceRequests = [];
const quoteRoute = url => url.origin === new URL(study).origin && url.pathname.endsWith("/citation");
let failNextQuote = false;
await page.route(quoteRoute, route => {
sourceRequests.push(new URL(route.request().url()).search);
if (failNextQuote) {
failNextQuote = false;
return route.fulfill({status: 503, json: {ok: false, error: "Synthetic source unavailable"}});
}
return route.continue();
});
const chapterControl = page.locator(".case-sections > section").nth(1);
await chapterControl.locator("summary").click();
const originalLink = chapterControl.locator(".case-citation-links").getByRole("link");
const bookmark = new URL(await originalLink.getAttribute("href"));
assert.equal(bookmark.searchParams.get("research_pack"), studyPack.pack_id);
const cited = chapter.citations[0];
const expectedSource = await (await fetch(new URL(analysisPath + "/citation?" + new URLSearchParams({
goal_id: child.goal_id, pack_id: studyPack.pack_id, analysis_key: studyPack.analysis_key,
source_id: cited.source_id, first_line: String(cited.first_line), last_line: String(cited.last_line),
}), study))).json();
assert.equal(expectedSource.source_id, cited.source_id);
assert(expectedSource.text);
await originalLink.press("Enter");
await page.waitForURL(url => url.hash === bookmark.hash);
await citation.locator("pre").first().waitFor();
assert.equal(await citation.locator("pre").first().textContent(), expectedSource.text);
const originalQuery = sourceRequests.at(-1);
await page.setViewportSize({width: 390, height: 900});
failNextQuote = true;
await page.reload();
await citation.getByRole("alert").waitFor();
assert.equal(new URL(page.url()).hash, bookmark.hash);
await citation.getByRole("button", {name: "重试读取原文"}).click();
await citation.locator("pre").first().waitFor();
assert.equal(await citation.locator("pre").first().textContent(), expectedSource.text);
assert.deepEqual(sourceRequests.slice(-2), [originalQuery, originalQuery]);
await citation.press("Escape");
await originalLink.evaluate(element => {
if (document.activeElement !== element) return new Promise(resolve => element.addEventListener("focus", resolve, {once: true}));
});
assert.equal(new URL(page.url()).hash, address.hash);
assert.equal(await citation.count(), 0);
// Paste the same permalink in the existing document. This exercises the
// browser route, where neither the link's click handler nor reload runs.
const documentStart = await page.evaluate(() => performance.timeOrigin);
const readsBeforeReopen = sourceRequests.length;
await page.evaluate(href => {window.location.assign(href);}, bookmark.href);
await citation.locator("pre").first().waitFor({timeout: 6_000});
assert.equal(await page.evaluate(() => performance.timeOrigin), documentStart);
assert.equal(await citation.locator("pre").first().textContent(), expectedSource.text);
assert.deepEqual(sourceRequests.slice(readsBeforeReopen), [originalQuery],
"Reopening a closed permalink reads its original evidence exactly once");
await citation.getByRole("button", {name: "关闭原文", exact: true}).click();
assert.equal(new URL(page.url()).hash, address.hash);
assert.equal(await citation.count(), 0);
// A valid position from another pack must never fall through to this pack.
const misplaced = new URL(bookmark);
misplaced.searchParams.set("research_pack", otherPack.pack_id);
const readsBeforeMismatch = sourceRequests.length;
await page.goto(misplaced.href);
await page.getByText("这条链接在所选解读中找不到原文。", {exact: false}).waitFor();
assert.equal(sourceRequests.length, readsBeforeMismatch);
assert.equal(await citation.count(), 0);
await page.setViewportSize({width: 1200, height: 900});
await page.goto(address.href);
await atChapter();
const tabs = page.locator(".personal-goal-tabs");
await tabs.getByRole("button", {name: "任务", exact: true}).click();
await page.waitForURL(url => url.searchParams.get("view") === "tasks");
Expand All @@ -136,7 +210,7 @@ try {
await page.goForward();
await page.waitForURL(url => url.searchParams.get("goalId") === parent.goal_id);
assertParentAddress();
console.log("research evidence version: saved excerpts isolated and chapter navigation restored");
console.log("research evidence version: saved excerpts, exact source links and chapter navigation restored");
} finally {
releaseOriginal?.();
await browser?.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {requestJson} from "../../data/chat";
import {sourceReadApiUrl} from "../../data/local-status-query";
import {ContextHelp} from "./context-help";
import {ResearchSynthesis} from "./goal-research-synthesis";
import {ResearchCaseStudy, caseChapterHashPrefix} from "./research-case-study";
import {ResearchCaseStudy, caseChapterHashPrefix, type CaseCitationBookmark} from "./research-case-study";
import type {TrajectoryCaseStudy} from "../../../../../../loopx/control_plane/capabilities/trajectory_case_study.js";
import "./goal-research-analysis.css";

Expand Down Expand Up @@ -36,6 +36,7 @@ type EvidenceRead = {
value?: Citation;
failure?: {message: string; route: string; selection: Record<string, string>};
};
type EvidenceTrigger = {origin?: HTMLElement; bookmark?: CaseCitationBookmark};
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
const locationFor = (goal: string) => {
const query = new URLSearchParams(window.location.search);
Expand Down Expand Up @@ -95,12 +96,16 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
const [evidenceRead, setEvidenceRead] = useState<EvidenceRead | null>(null);
const citeGeneration = useRef(0);
const sourcePanel = useRef<HTMLElement>(null);
const sourceTrigger = useRef<HTMLButtonElement | null>(null);
const sourceTrigger = useRef<(EvidenceTrigger & {scope: string}) | null>(null);
const invalidateCitation = useCallback(() => {
citeGeneration.current++;
sourceTrigger.current = null;
setEvidenceRead(null);
}, []);
const onCitationLocationChange = useCallback((hash: string) => {
const activeBookmark = sourceTrigger.current?.bookmark;
if (activeBookmark && activeBookmark.hash !== hash) invalidateCitation();
}, [invalidateCitation]);
const [location, setLocation] = useState(() => locationFor(goalId));
const sourceScope = JSON.stringify([goalId, outputLanguage, readSourceUrl ?? ""]);
const [readState, setReadState] = useState<{scope: string; packId: string | null; snapshot: Snapshot | null; error: string; loading: boolean}>(
Expand Down Expand Up @@ -234,8 +239,8 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
url.searchParams.set("research_goal", goalId);
url.searchParams.set("research_reading", "case");
const namespace = readSourceUrl ? new URL(readSourceUrl, url).searchParams.get("source_namespace") : null;
return {url, goalId, packId, namespace, follow: (href: URL) => {
if (href.href !== window.location.href) window.history.pushState(window.history.state, "", href);
return {url, goalId, packId, namespace, follow: (href: URL, replace = false) => {
if (href.href !== window.location.href) window.history[replace ? "replaceState" : "pushState"](window.history.state, "", href);
if (location.packId !== packId) applyLocation({open: location.open, packId});
}};
};
Expand Down Expand Up @@ -265,33 +270,50 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
} catch (e) {if (current === generation.current) setError(errorText(e));}
finally {if (current === generation.current) setCancelPending(false);}
};
const readEvidence = async (selection: Record<string, string>, route: string, origin?: HTMLButtonElement) => {
const readEvidence = async (selection: Record<string, string>, route: string, trigger?: EvidenceTrigger) => {
const p = readingPack;
if (!p || evidenceScope === null) return;
if (origin) sourceTrigger.current = origin;
if (trigger) sourceTrigger.current = {...trigger, scope: evidenceScope};
const bookmark = sourceTrigger.current?.bookmark;
const current = ++citeGeneration.current;
setEvidenceRead({scope: evidenceScope, busy: true});
const query = new URLSearchParams({goal_id: goalId, pack_id: p.pack_id, analysis_key: p.analysis_key,
...(p.config.output_language ? {output_language: p.config.output_language} : {}), ...selection});
try {
const value = await requestJson<Citation>(sourceReadApiUrl(readSourceUrl, `${endpoint}/${route}?${query}`));
if (bookmark && (value.source_id !== selection.source_id || String(value.first_line) !== selection.first_line
|| String(value.last_line) !== selection.last_line)) {
throw new Error(zh ? "原文读回与引用范围不一致。" : "The original returned does not match the cited range.");
}
if (current === citeGeneration.current) setEvidenceRead({scope: evidenceScope, value});
} catch (e) {
if (current === citeGeneration.current) setEvidenceRead({
scope: evidenceScope, failure: {message: errorText(e), route, selection},
});
}
};
const readCitation = (c: AssessmentCitation, origin?: HTMLButtonElement) => readEvidence({source_id: c.source_id, first_line: String(c.first_line), last_line: String(c.last_line)}, "citation", origin);
const readUsageTurn = (turnId: string, origin: HTMLButtonElement) => readEvidence({turn_id: turnId}, "usage-turn", origin);
const readCitation = (c: AssessmentCitation, origin?: HTMLElement, bookmark?: CaseCitationBookmark) => {
const activeTrigger = sourceTrigger.current;
// Coalesce browser navigation events only while this source panel is open.
if (bookmark && activeTrigger && activeTrigger.scope === evidenceScope && activeTrigger.bookmark?.hash === bookmark.hash) return;
return readEvidence({source_id: c.source_id, first_line: String(c.first_line), last_line: String(c.last_line)},
"citation", {origin, bookmark});
};
const readUsageTurn = (turnId: string, origin: HTMLButtonElement) => readEvidence({turn_id: turnId}, "usage-turn", {origin});
const retryCitation = failedCitation ? () => void readEvidence(failedCitation.selection, failedCitation.route) : undefined;
const closeCitation = () => {
const origin = sourceTrigger.current;
const trigger = sourceTrigger.current;
const {origin, bookmark} = trigger && trigger.scope === evidenceScope ? trigger : {};
if (bookmark && window.location.hash === bookmark.hash) {
const url = new URL(window.location.href);
url.hash = bookmark.chapterHash;
window.history.replaceState(window.history.state, "", url);
}
invalidateCitation();
const closedAt = citeGeneration.current;
requestAnimationFrame(() => {if (closedAt === citeGeneration.current) origin?.focus();});
};
return {readOnly: currentSnapshot?.execution_enabled === false || (Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true), sourceScope, readUsageComparison, citation, citationBusy, citationError, sourcePanel, readCitation, readUsageTurn, retryCitation, closeCitation, goalId, zh, location, snapshot: currentSnapshot, pack: readingPack, requestPack: pack, savedOriginal, readError, error, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel,
return {readOnly: currentSnapshot?.execution_enabled === false || (Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true), sourceScope, readUsageComparison, citation, citationBusy, citationError, sourcePanel, readCitation, readUsageTurn, retryCitation, closeCitation, onCitationLocationChange, goalId, zh, location, snapshot: currentSnapshot, pack: readingPack, requestPack: pack, savedOriginal, readError, error, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel,
studyNavigation, close: () => {invalidateCitation(); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)};
}
type Controller = ReturnType<typeof useResearchAnalysis>;
Expand Down Expand Up @@ -401,17 +423,20 @@ export function ResearchCaseReading({analysis: a, active, onHistory}: {analysis:
const p = a.pack;
const firstStudy = packs[0]?.pack_id;
useEffect(() => {
if (!a.loading && !a.readError && p && !p.case_study && firstStudy) a.select(firstStudy);
const hash = window.location.hash;
if (!a.loading && !a.readError && p && !p.case_study && firstStudy
&& !(hash.startsWith(caseChapterHashPrefix) && hash.includes("~"))) a.select(firstStudy);
}, [a.loading, a.readError, p?.pack_id, Boolean(p?.case_study), firstStudy]);
return <section className="research-case-reader" aria-label={a.zh ? "Case 深度解读" : "Case studies"}>
<header className="case-reader-toolbar">{(packs.length > 1 || (packs.length === 1 && !p?.case_study)) ? <label>{a.zh ? "解读版本" : "Study version"}
<select aria-label={a.zh ? "选择解读版本" : "Select study version"} value={p?.case_study ? p.pack_id : ""} onChange={e => a.select(e.target.value)}>
{!p?.case_study ? <option value="">{a.zh ? "选择已保存的解读" : "Choose a saved study"}</option> : null}
{packs.map(pack => <option key={pack.pack_id} value={pack.pack_id}>{pack.title}</option>)}
</select></label> : null}<button type="button" onClick={onHistory}>{a.zh ? "研究记录与分析" : "Research records and analysis"} ↗</button></header>
</select></label> : null}<button type="button" onClick={() => {a.closeCitation(); onHistory();}}>{a.zh ? "研究记录与分析" : "Research records and analysis"} ↗</button></header>
<AnalysisReadState analysis={a}/>
{p?.case_study ? <ResearchCaseStudy key={p.pack_id} study={p.case_study} zh={a.zh} historical={p.applicability !== "current"} active={active}
scope={p.scope} navigation={a.studyNavigation(p.pack_id)} onCitation={a.readCitation}/> : !a.loading && !a.readError ? <p>{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}</p> : null}
{p?.case_study ? <ResearchCaseStudy key={`${p.pack_id}:${p.analysis_key}`} study={p.case_study} zh={a.zh} historical={p.applicability !== "current"} active={active}
scope={p.scope} navigation={a.studyNavigation(p.pack_id)} onCitation={a.readCitation}
onCitationLocationChange={a.onCitationLocationChange}/> : !a.loading && !a.readError ? <p>{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}</p> : null}
<ResearchCitationPanel analysis={a}/>
</section>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
.case-section > .personal-md { max-width: 80ch; }
.case-sources { margin-block-top: 8px; }
.case-citation-links { list-style: none; margin: 0; padding: 0; }
.case-citation-links button { display: block; text-align: start; font: inherit; font-size: var(--text-body); color: var(--pw-text); background: none; border: 0; min-height: 44px; padding: 8px 0; cursor: pointer; overflow-wrap: anywhere; }
.case-citation-links a { display: block; font-size: var(--text-body); color: var(--pw-text); min-height: 44px; padding: 8px 0; text-decoration: underline; text-underline-offset: .2em; overflow-wrap: anywhere; }
.case-table-scroll { overflow-x: auto; margin-block: 24px; }.case-table-scroll table { border-collapse: collapse; width: 100%; font-size: var(--text-body); line-height: 1.7; }
.case-table-scroll :is(th,td) { border-bottom: 1px solid var(--pw-line); text-align: start; vertical-align: top; padding: 12px 16px 12px 0; min-width: 85px; }
.case-table-scroll th { font-weight: 500; }.case-table-scroll thead th { color: var(--pw-muted); }.goal-research-workbench .research-case-reader .research-case-study .case-table-scroll .personal-md { font-size: inherit; }.case-table-scroll .personal-md p { margin: 0; }
Expand Down
Loading
Loading