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 88112a4bfc..de8f90b495 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,6 +1,6 @@ import {useCallback, useEffect, useRef, useState} from "react"; import {ArrowUpRight, LoaderCircle, RefreshCw, Sparkles, X} from "lucide-react"; -import type {AssessmentCitation, TrajectoryEpisode} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js"; +import type {AssessmentCitation, TrajectoryEpisode, TrajectoryOutputLanguage} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js"; import {ResearchProgress, metricCopy, verdictLabel, type ResearchProgressPack} from "./goal-research-progress"; import {requestJson} from "../../data/chat"; import {sourceReadApiUrl} from "../../data/local-status-query"; @@ -13,7 +13,7 @@ type Job = {status: string; attempt?: number; analysis_wall_seconds?: number | n config?: {model: string; effort: string; service_tier: string}; served_service_tier?: string | null; usage?: {input_tokens?: number; output_tokens?: number; reasoning_output_tokens?: number} | null}; type Pack = ResearchProgressPack & {source_count: number; analysis_key: string; method_version: string; - config: {model: string; effort: string; service_tier: string}; availability: string; reason_code: string | null; + config: {model: string; effort: string; service_tier: string; output_language?: TrajectoryOutputLanguage}; availability: string; reason_code: string | null; objective?: string; episodes?: TrajectoryEpisode[]; job?: Job | null}; type Snapshot = {ok: true; execution_enabled?: boolean; catalog: {availability: string; reason_code: string | null; default_pack_id: string | null; packs: Pack[]}; pack: Pack | null}; type Citation = AssessmentCitation & {artifact: string; text: string; applicability: string}; @@ -26,6 +26,7 @@ const locationFor = (goal: string) => { export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean, readSourceUrl?: string) { const endpoint = "/api/chat/research-analysis"; + const outputLanguage = zh ? "zh" : "en"; const [citation, setCitation] = useState(null); const [citationError, setCitationError] = useState(""); const [citationBusy, setCitationBusy] = useState(false); @@ -44,22 +45,23 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean const generation = useRef(0); const readSequence = useRef(0); const submitting = useRef(false); - const scope = useRef({goalId, packId: location.packId}); - scope.current = {goalId, packId: location.packId}; + const scope = useRef({goalId, packId: location.packId, outputLanguage}); + scope.current = {goalId, packId: location.packId, outputLanguage}; const read = useCallback(async (packId: string | null = location.packId, signal?: AbortSignal) => { const current = generation.current, sequence = ++readSequence.current; - const query = new URLSearchParams({goal_id: goalId, ...(packId ? {pack_id: packId} : {})}); + const query = new URLSearchParams({goal_id: goalId, output_language: outputLanguage, ...(packId ? {pack_id: packId} : {})}); let value: Snapshot; try {value = await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}?${query}`), {signal});} catch (e) {if (current === generation.current && sequence === readSequence.current && !signal?.aborted) setReadError(errorText(e)); throw e;} - if (!value?.ok || !Array.isArray(value.catalog?.packs) || (value.pack && value.pack.pack_id !== (packId ?? value.catalog.default_pack_id))) { + if (!value?.ok || !Array.isArray(value.catalog?.packs) || (value.pack && (value.pack.pack_id !== (packId ?? value.catalog.default_pack_id) + || value.pack.config.output_language !== outputLanguage || (value.pack.result && value.pack.result.output_language !== outputLanguage)))) { const failure = new Error(zh ? "分析来源读回不一致。" : "Analysis source readback does not match."); if (current === generation.current && sequence === readSequence.current && !signal?.aborted) setReadError(failure.message); throw failure; } - if (current === generation.current && sequence === readSequence.current && scope.current.goalId === goalId && scope.current.packId === packId && !signal?.aborted) {setSnapshot(value); setReadError(""); if (value.pack?.job?.status === "succeeded" || value.pack?.job?.status === "cancelled") setError("");} + if (current === generation.current && sequence === readSequence.current && scope.current.goalId === goalId && scope.current.packId === packId && scope.current.outputLanguage === outputLanguage && !signal?.aborted) {setSnapshot(value); setReadError(""); if (value.pack?.job?.status === "succeeded" || value.pack?.job?.status === "cancelled") setError("");} return value; - }, [goalId, location.packId, zh, readSourceUrl]); + }, [goalId, location.packId, zh, outputLanguage, readSourceUrl]); const applyLocation = useCallback((next: typeof location) => { // Selection and browser history share one source transition. Invalidate // pending reads/actions before a new scope can display the old snapshot. @@ -69,9 +71,9 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean setPending(false); setCancelPending(false); setError(""); setReadError(""); setSnapshot(previous => previous?.pack?.pack_id === next.packId ? previous : previous ? {...previous, pack: null} : null); } - scope.current = {goalId, packId: next.packId}; + scope.current = {goalId, packId: next.packId, outputLanguage}; setLocation(next); - }, [goalId]); + }, [goalId, outputLanguage]); useEffect(() => { generation.current++; submitting.current = false; applyLocation(locationFor(goalId)); setSnapshot(null); setError(""); setReadError(""); setPending(false); setCancelPending(false); @@ -85,7 +87,10 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean void read(location.packId, controller.signal).catch(() => undefined).finally(() => {if (!controller.signal.aborted) setLoading(false);}); return () => controller.abort(); }, [active, read, location.packId]); - const pack = snapshot?.pack ?? null; + // A language change is an analysis-scope change, not a model invocation. + // Hide the prior language synchronously, before the next effect/read finishes. + const currentSnapshot = snapshot?.pack && snapshot.pack.config.output_language !== outputLanguage ? null : snapshot; + const pack = currentSnapshot?.pack ?? null; useEffect(() => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false);}, [goalId, readSourceUrl, snapshot?.pack?.pack_id, snapshot?.pack?.analysis_key]); useEffect(() => { if ((!citation && !citationBusy && !citationError) || !location.open || !active) return; @@ -129,7 +134,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean show(); submitting.current = true; setPending(true); setError(""); const current = generation.current; try { - await requestJson(sourceReadApiUrl(readSourceUrl, endpoint), {method: "POST", body: JSON.stringify({goal_id: goalId, pack_id: pack.pack_id, analysis_key: pack.analysis_key, retry})}); + await requestJson(sourceReadApiUrl(readSourceUrl, endpoint), {method: "POST", body: JSON.stringify({goal_id: goalId, pack_id: pack.pack_id, analysis_key: pack.analysis_key, output_language: outputLanguage, retry})}); if (current === generation.current) await read(pack.pack_id); } catch (e) {if (current === generation.current) {setError(errorText(e)); void read(pack.pack_id).catch(() => undefined);}} finally {if (current === generation.current) {submitting.current = false; setPending(false);}} @@ -140,7 +145,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean const current = generation.current; setCancelPending(true); try { - await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}/cancel`), {method: "POST", body: JSON.stringify({goal_id: goalId, pack_id: pack.pack_id, analysis_key: pack.analysis_key})}); + await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}/cancel`), {method: "POST", body: JSON.stringify({goal_id: goalId, pack_id: pack.pack_id, analysis_key: pack.analysis_key, output_language: outputLanguage})}); if (current === generation.current) await read(pack.pack_id); } catch (e) {if (current === generation.current) setError(errorText(e));} finally {if (current === generation.current) setCancelPending(false);} @@ -151,13 +156,13 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean if (origin) sourceTrigger.current = origin; show(false); const current = ++citeGeneration.current; setCitation(null); setCitationBusy(true); setCitationError(""); - const query = new URLSearchParams({goal_id: goalId, pack_id: p.pack_id, analysis_key: p.analysis_key, source_id: c.source_id, first_line: String(c.first_line), last_line: String(c.last_line)}); + const query = new URLSearchParams({goal_id: goalId, pack_id: p.pack_id, analysis_key: p.analysis_key, output_language: outputLanguage, source_id: c.source_id, first_line: String(c.first_line), last_line: String(c.last_line)}); try {const value = await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}/citation?${query}`)); if (current === citeGeneration.current) setCitation(value);} catch (e) {if (current === citeGeneration.current) setCitationError(errorText(e));} finally {if (current === citeGeneration.current) setCitationBusy(false);} }; const closeCitation = () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); requestAnimationFrame(() => sourceTrigger.current?.focus());}; - return {readOnly: Boolean(readSourceUrl) && snapshot?.execution_enabled !== true, citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot, pack, readError, error: error || readError, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, + return {readOnly: Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true, citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot: currentSnapshot, pack, readError, error: error || readError, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, close: () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)}; } type Controller = ReturnType; @@ -182,6 +187,7 @@ export function ResearchAnalysisButton({analysis: a}: {analysis: Controller}) { {a.pack?.result ? (a.zh ? "查看分析" : "View analysis") : a.zh ? "深度分析" : "Deep analysis"} {a.pack ?

{a.zh ? "分析请求设置(固定):" : "Fixed analysis request: "}{a.pack.config.model} / {a.pack.config.effort} / {a.pack.config.service_tier}

: null} +

{a.zh ? "问题解读、理由与限制说明按界面语言生成;切换语言后单独缓存,需点击才生成。引用保留原文。" : "Questions, reasons and limitations follow the interface language. Each language is cached separately and generated only on request. Citations preserve the original text."}

{a.pack?.result ? (a.zh ? "所选范围已有分析,查看不调用 Agent。" : "An analysis exists for the selected scope. Viewing never invokes an Agent.") : (a.zh ? "点击深度分析使用本机 Codex 账户,单独产生分析用量;普通浏览不调用 Agent。" : "Deep analysis uses the local Codex account with separate analysis usage; ordinary browsing never invokes an Agent.")}

{a.zh ? "只分析已授权的固定轨迹,不改变研究历史。范围、方法与来源版本可在结果中查看。" : "Analyzes an authorized fixed trajectory without changing research history. Scope, method and source revisions remain available with the result."}

{a.pack && !a.pack.result && !unavailable ? {a.pack.config.model} · {a.zh ? "单独用量" : "Separate usage"} : null}; @@ -232,7 +238,8 @@ export function ResearchAnalysisPanel({analysis: a}: {analysis: Controller}) { {failed && !a.pending ?

{p.job?.status === "cancelled" ? (zh ? "分析已取消,研究历史保持可读。" : "Analysis cancelled. Research history remains readable.") : (zh ? "上次分析未完成;可重试,原失败记录会保留。" : "The last analysis did not complete. Retry preserves its failure record.")}

: null} {result ? <>
{zh ? "Agent 判断 · 未校准" : "Agent interpretation · uncalibrated"}{p.applicability === "historical" ? (zh ? " · 历史结果" : " · Historical result") : ""}

{headline}

- {p.episodes?.map(episode =>

{episode.question}

+ {p.episodes?.map(episode =>

{result.episode_questions?.find(q => q.episode_id === episode.id)?.question ?? episode.question}

+ {result.episode_questions ?
{zh ? "原始研究问题" : "Original research question"}

{episode.question}

: null}
{(["benefit_test", "evidence_adoption"] as const).map(metric => { const row = result.assessments.find(item => item.episode_id === episode.id && item.metric === metric); return row ?
{metric === "benefit_test" ? (zh ? "原目标收益的检验与缺口" : "Original-objective utility test and its gaps") : (zh ? "证据如何影响后续行动" : "How evidence informed subsequent action")}

{row.reason}

{citationButtons(row.citations)}
: null; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx index 720a4dcc6b..7e01ad33be 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx @@ -136,7 +136,8 @@ export function ResearchProgress({pack: p, packs, selectedId, error, zh, onSelec return
{assessment.episode_id}{verdictLabel(metric, assessment.verdict, zh)}
- {episode ?

{episode.question}

: null} + {episode ? <>

{result.episode_questions?.find(q => q.episode_id === episode.id)?.question ?? episode.question}

+ {result.episode_questions ?
{zh ? "原始研究问题" : "Original research question"}

{episode.question}

: null} : null}

{assessment.reason}

{assessment.citations.map(c => )}
diff --git a/docs/development/assets/runtime-source-analysis/analysis-language-desktop.png b/docs/development/assets/runtime-source-analysis/analysis-language-desktop.png new file mode 100644 index 0000000000..999041701b Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/analysis-language-desktop.png differ diff --git a/docs/development/assets/runtime-source-analysis/analysis-language-mobile.png b/docs/development/assets/runtime-source-analysis/analysis-language-mobile.png new file mode 100644 index 0000000000..58fb8a495d Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/analysis-language-mobile.png differ diff --git a/docs/development/design.md b/docs/development/design.md index a8f1ec8e34..c0f8a390b4 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -88,6 +88,11 @@ Owner-facing state follows four rules: - **Owner language first.** Primary copy uses the owner's words. Protocol ids, gate kinds, preview mechanics and executor profiles belong in a collapsed diagnostics disclosure. + Generated analysis follows the current interface language, including its + questions, explanations and limitations. Pass that language through the + analysis owner and cache identity; translating labels alone is insufficient. + Switching language is a read, never implicit model execution. Keep original + questions and quoted evidence available on demand, identified as originals. Review realistic populated, quiet, blocked, and unavailable states on desktop and mobile. Show before/after views, name what earns attention and what was diff --git a/docs/reference/trajectory-assessment.md b/docs/reference/trajectory-assessment.md index 2541d64fde..b58d96170e 100644 --- a/docs/reference/trajectory-assessment.md +++ b/docs/reference/trajectory-assessment.md @@ -7,6 +7,20 @@ These use the S5/S11 [research episode assessment](../architecture/rfcs/long-horizon-harness-benchmark-research-program-v0.md#evidence-based-episode-evaluation-proposed-refinement). It does not change solver prompts, benchmark scoring, checkpoints or research budgets. +The Goal reader requests `output_language` (`zh` or `en`) from the current +interface language on reads, explicit execution/retry, cancellation and citation +readback. The typed assessment owner binds it and its presentation instructions +to the analysis key. Each language has its own job/cache over the same registered +pack and frozen evidence; browsing or switching language never starts a job. +Generated `reason`, `limitations` and `episode_questions` follow that language. +The latter map each existing episode ID to an analytical rendering of its +question; the complete original question and source text remain available. +IDs, eligibility, verdict tokens, citation ranges and source bytes are unchanged. +Requests without a language retain their original identity and stored artifacts; +the frontend does not present an unspecified or other-language result as a new +analysis in the selected language. Output-language metadata records the request, +not independent verification of a model's translation or judgments. + ## What the measurements should answer Use one **research episode** as the process unit: a bounded question or competing diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index f05fbba4b3..55caccf838 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -125,13 +125,15 @@ export const researchHistoryScenario = { let analysisFixture = false, holdOldAnalysis = false, slowStarted, releaseOld, oldDelivered; let holdCitation = false, failedCitation = false, citationStarted, releaseCitation; let unavailableAnalysis = false, episodeMetadataLimit = null, comparisonFixture = false; - let analysisWrites = 0; + let analysisWrites = 0, localizedFixture = false; try { context = await openWorkspacePage(browser, url, {collectCoverage, beforeGoto: async (_, page) => { await page.route("**/api/chat/research-analysis**", async route => { if (route.request().method() !== "GET") {analysisWrites++; return route.fulfill({status: 403, json: {error: "Read-only presentation fixture"}});} if (!analysisFixture) return route.fulfill({json: {ok: true, catalog: {availability: "unavailable", reason_code: "no_available_pack", default_pack_id: null, packs: []}, pack: null}}); const query = new URL(route.request().url()).searchParams; + const language = query.get('output_language'); + assert(['zh', 'en'].includes(language), 'Every analysis and original-citation read binds the interface language'); if (new URL(route.request().url()).pathname.endsWith('/citation')) { if (holdCitation) {citationStarted(); await new Promise(resolve => {releaseCitation = resolve;});} if (failedCitation) return route.fulfill({status: 503, json: {error: 'Synthetic citation transport unavailable'}}); @@ -144,6 +146,17 @@ export const researchHistoryScenario = { const selected = ["b", "c", "d"].find(id => query.get("pack_id") === id.repeat(64)) ?? "a"; if (holdOldAnalysis && selected === "a") {slowStarted(); await new Promise(resolve => {releaseOld = resolve;});} const readback = comparisonFixture ? comparisonReadback() : analysisReadback(selected); + for (const pack of [...readback.catalog.packs, readback.pack]) pack.config = {...pack.config, output_language: language}; + if (readback.pack.result) { + readback.pack.result.output_language = language; + if (localizedFixture) { + readback.pack.result.episode_questions = readback.pack.episodes.map(episode => ({episode_id: episode.id, + question: language === 'zh' ? `${episode.id} 是否检验原始目标?` : `Did ${episode.id} test the original objective?`})); + for (const assessment of readback.pack.result.assessments) assessment.reason = language === 'zh' + ? '来源只记录了实验结果,缺少干预与目标瓶颈的联系。' : 'The source records the result but not the intervention’s link to the objective bottleneck.'; + readback.pack.result.limitations = [language === 'zh' ? '合成展示;不是科学校准结果。' : 'Synthetic presentation; not scientific calibration.']; + } + } if (episodeMetadataLimit === 0) delete readback.pack.episodes; else if (episodeMetadataLimit !== null) readback.pack.episodes = readback.pack.episodes.slice(-episodeMetadataLimit); await route.fulfill(unavailableAnalysis && selected === "a" @@ -721,6 +734,30 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti await rows.last().locator(':scope > summary').click(); await rows.last().getByText(/Synthetic evidence connects/).waitFor(); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + localizedFixture = true; + await page.reload({waitUntil: 'networkidle'}); + await history.getByRole('button', {name: '查看分析', exact: true}).click(); + const chineseAnalysis = history.getByRole('region', {name: '轨迹深度分析', exact: true}); + await chineseAnalysis.locator('.analysis-episode h4').first().filter({hasText: 'variant-alpha 是否检验原始目标?'}).waitFor(); + assert.match(await chineseAnalysis.innerText(), /来源只记录了实验结果/); + const originalQuestion = chineseAnalysis.locator('.analysis-episode').first().getByText('原始研究问题', {exact: true}); + await originalQuestion.click(); + assert.match(await chineseAnalysis.locator('.analysis-episode').first().innerText(), /variant-alpha 的实验是否检验原始目标/); + await originalQuestion.click(); + await page.setViewportSize({width: 1440, height: 1000}); + await chineseAnalysis.evaluate(element => element.scrollIntoView({block: 'start'})); + await page.screenshot({path: resolve(outputDir, 'research-analysis-language-zh.png'), animations: 'disabled'}); + await page.setViewportSize({width: 390, height: 844}); + await chineseAnalysis.evaluate(element => element.scrollIntoView({block: 'start'})); + await page.screenshot({path: resolve(outputDir, 'research-analysis-language-mobile.png'), animations: 'disabled'}); + await page.evaluate(key => localStorage.setItem(key, 'en'), 'loopx-pw-locale'); + await page.reload({waitUntil: 'networkidle'}); + const englishAnalysis = page.getByRole('region', {name: 'Trajectory analysis', exact: true}); + await englishAnalysis.locator('.analysis-episode h4').first().filter({hasText: 'Did variant-alpha test the original objective?'}).waitFor(); + assert.doesNotMatch(await englishAnalysis.innerText(), /来源只记录了实验结果/); + await page.evaluate(key => localStorage.setItem(key, 'zh-CN'), 'loopx-pw-locale'); + await page.reload({waitUntil: 'networkidle'}); + await chineseAnalysis.locator('.analysis-episode h4').first().filter({hasText: 'variant-alpha 是否检验原始目标?'}).waitFor(); assert.equal(analysisWrites, 0, "Cached, reload, switch and keyboard reading never request execution"); assert.equal(api.turnRequests.length, 0, "Reading the graph cannot start an Agent"); return {note: "Three unchanged real checkpoint histories through production HTTP + packaged UI; per-Goal complete pagination, genuine negative evidence, no invented edges, reload/back/forward, mobile and failed refresh. Separate synthetic coincident-target regression; no benchmark qualification or model dispatch.", diff --git a/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py b/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py index 5a44ac6eda..ddd12bbd0a 100644 --- a/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py +++ b/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py @@ -57,10 +57,11 @@ def _pack_id(owner_goal_id: str, record: dict) -> str: return _digest(identity) -def _prepared(record: dict) -> dict: +def _prepared(record: dict, output_language: str | None = None) -> dict: return effect_runtime_result( "benchmark.trajectory.prepare", - {"input": record["input"], "config": record["config"]}, + {"input": record["input"], "config": {**record["config"], + **({"output_language": output_language} if output_language is not None else {})}}, ) @@ -212,7 +213,8 @@ def _availability(record: dict, prepared: dict) -> str | None: return None -def _summary(record: dict, reason: str | None) -> dict: +def _summary(record: dict, reason: str | None, prepared: dict | None = None) -> dict: + analysis = prepared or record return { "pack_id": _pack_id(record["owner_goal_id"], record), "title": record["title"], @@ -220,9 +222,9 @@ def _summary(record: dict, reason: str | None) -> dict: "selection": record["input"]["selection"], "episode_count": len(record["input"]["episodes"]), "source_count": len(record["input"]["sources"]), - "analysis_key": record["analysis_key"], - "method_version": record["method_version"], - "config": record["config"], + "analysis_key": analysis["analysis_key"], + "method_version": analysis["method_version"], + "config": analysis["config"], "availability": "unavailable" if reason else "available", "reason_code": reason, **({field: _captured_view(record, field) for field in ("solver_usage", "native_outcomes") if field in record}), @@ -299,13 +301,15 @@ def register_pack( return {"ok": True, **_summary(record, None)} -def discover(*, runtime_root: Path, owner_goal_id: str) -> dict: +def discover(*, runtime_root: Path, owner_goal_id: str, output_language: str | None = None) -> dict: owner = _owner(runtime_root, owner_goal_id) index = _index(owner) packs = [] for pack_id in index["pack_ids"]: _, record = _resolve(runtime_root, owner_goal_id, pack_id) - packs.append(_summary(record, _availability(record, _prepared(record)))) + prepared = _prepared(record) + packs.append(_summary(record, _availability(record, prepared), + _prepared(record, output_language) if output_language is not None else None)) available = any(p["availability"] == "available" for p in packs) return { "ok": True, @@ -352,18 +356,23 @@ def _public_job(job: dict) -> dict: def inspect_pack( - *, runtime_root: Path, owner_goal_id: str, pack_id: str | None = None + *, runtime_root: Path, owner_goal_id: str, pack_id: str | None = None, + output_language: str | None = None, ) -> dict: owner, record = _resolve(runtime_root, owner_goal_id, pack_id) prepared = _prepared(record) reason = _availability(record, prepared) - directory = _job(owner, record, record["analysis_key"]) + if output_language is not None: + prepared = _prepared(record, output_language) + identity = prepared if output_language is not None else record + directory = _job(owner, identity, identity["analysis_key"]) job = read_job(directory) if (directory / "receipt.json").exists() else None result = None if job is not None: if ( - job["analysis_key"] != record["analysis_key"] + job["analysis_key"] != identity["analysis_key"] or job["scope"] != record["input"]["scope"] + or job["config"] != prepared["config"] ): raise PackUnavailable("receipt_mismatch") if job["status"] == "succeeded": @@ -375,13 +384,12 @@ def inspect_pack( job.get("input_sha256") != _digest(record["input"]) or job.get("assessment_sha256") != hashlib.sha256(payload).hexdigest() - or job.get("config") != record["config"] ): raise PackUnavailable("historical_result_unverified") result = json.loads(payload) if ( result.get("schema_version") != "trajectory_assessment_v1" - or result.get("analysis_key") != record["analysis_key"] + or result.get("analysis_key") != identity["analysis_key"] or result.get("scope") != record["input"]["scope"] or result.get("method_version") != record["method_version"] or job.get("method_version") != record["method_version"] @@ -401,7 +409,7 @@ def inspect_pack( ) return { "ok": True, - **_summary(record, reason), + **_summary(record, reason, prepared if output_language is not None else None), "objective": record["input"]["objective"], "episodes": record["input"]["episodes"], "job": _public_job(job) if job else None, @@ -416,14 +424,16 @@ def execute_pack( owner_goal_id: str, pack_id: str, analysis_key: str, + output_language: str | None = None, retry: bool = False, timeout_seconds: int = 900, codex: str = "codex", ) -> dict: owner, record = _resolve(runtime_root, owner_goal_id, pack_id) - _job(owner, record, analysis_key) - prepared = _prepared(record) - reason = _availability(record, prepared) + base = _prepared(record) + prepared = _prepared(record, output_language) if output_language is not None else base + _job(owner, prepared if output_language is not None else record, analysis_key) + reason = _availability(record, base) if reason: raise PackUnavailable(reason) return _public_job( @@ -438,10 +448,12 @@ def execute_pack( def cancel_pack( - *, runtime_root: Path, owner_goal_id: str, pack_id: str, analysis_key: str + *, runtime_root: Path, owner_goal_id: str, pack_id: str, analysis_key: str, + output_language: str | None = None, ) -> dict: owner, record = _resolve(runtime_root, owner_goal_id, pack_id) - return _public_job(request_cancel(_job(owner, record, analysis_key))) + identity = _prepared(record, output_language) if output_language is not None else record + return _public_job(request_cancel(_job(owner, identity, analysis_key))) def read_citation( @@ -453,12 +465,14 @@ def read_citation( source_id: str, first_line: int, last_line: int, + output_language: str | None = None, ) -> dict: _, record = _resolve(runtime_root, owner_goal_id, pack_id) - if analysis_key != record["analysis_key"]: + if analysis_key != (_prepared(record, output_language) if output_language is not None else record)["analysis_key"]: raise PackUnavailable("analysis_key_mismatch") view = inspect_pack( - runtime_root=runtime_root, owner_goal_id=owner_goal_id, pack_id=pack_id + runtime_root=runtime_root, owner_goal_id=owner_goal_id, pack_id=pack_id, + output_language=output_language, ) result = view["result"] citations = [c for a in (result or {}).get("assessments", []) for c in a["citations"]] diff --git a/loopx/control_plane/capabilities/trajectory_assessment.ts b/loopx/control_plane/capabilities/trajectory_assessment.ts index 3ae78e633d..480f886195 100644 --- a/loopx/control_plane/capabilities/trajectory_assessment.ts +++ b/loopx/control_plane/capabilities/trajectory_assessment.ts @@ -6,7 +6,7 @@ import {EffectRuntimeRequestError} from '../effect_runtime_errors.ts'; export const TRAJECTORY_METHOD = 'research-episodes-v2'; import {TRAJECTORY_METRICS} from './trajectory_assessment_types.ts'; -import type {TrajectoryMetric, TrajectoryInput, AssessmentVerdict, TrajectoryAssessment} from './trajectory_assessment_types.ts'; +import type {TrajectoryMetric, TrajectoryInput, AssessmentVerdict, TrajectoryAssessment, TrajectoryOutputLanguage} from './trajectory_assessment_types.ts'; export * from './trajectory_assessment_types.ts'; const object = (v: unknown, name: string): Record => { if (!v || typeof v !== 'object' || Array.isArray(v)) throw new EffectRuntimeRequestError(`${name} must be an object`); @@ -86,17 +86,33 @@ export const TRAJECTORY_OUTPUT_SCHEMA = {type: 'object', additionalProperties: f required: ['episode_id','metric','verdict','reason','citations'], properties: { episode_id: {type: 'string'}, metric: {type: 'string', enum: [...TRAJECTORY_METRICS]}, verdict: {type: 'string', enum: ['yes','no','unknown']}, reason: {type: 'string'}, citations: {type: 'array', items: citationSchema}}}}, limitations: {type: 'array', items: {type: 'string'}}}}; +const LOCALIZED_OUTPUT_SCHEMA = {...TRAJECTORY_OUTPUT_SCHEMA, + required: [...TRAJECTORY_OUTPUT_SCHEMA.required, 'episode_questions'], + properties: {...TRAJECTORY_OUTPUT_SCHEMA.properties, episode_questions: {type: 'array', items: { + type: 'object', additionalProperties: false, required: ['episode_id', 'question'], + properties: {episode_id: {type: 'string'}, question: {type: 'string'}}}}}}; export function prepareTrajectoryAssessment(params: Record) { const input = parseTrajectoryInput(params.input); const config = object(params.config, 'config'); + if (config.output_language !== undefined && config.output_language !== 'zh' && config.output_language !== 'en') { + throw new EffectRuntimeRequestError('output_language must be zh or en'); + } + const language = config.output_language as TrajectoryOutputLanguage | undefined; // Only analysis identity; provider configuration/execution is owned by its adapter. - const configIdentity = {model: str(config.model, 'model'), effort: str(config.effort, 'effort'), service_tier: str(config.service_tier, 'service_tier')}; - const analysis_key = createHash('sha256').update(JSON.stringify({method: TRAJECTORY_METHOD, rubric: RUBRIC, input, config: configIdentity})).digest('hex'); + // Omission preserves the identity of already registered, language-unspecified artifacts. + const configIdentity = {model: str(config.model, 'model'), effort: str(config.effort, 'effort'), service_tier: str(config.service_tier, 'service_tier'), + ...(language ? {output_language: language} : {})}; const numberedSources = input.sources.map(s => ({...s, text: s.text.split('\n').map((line, n) => `${s.first_line+n}: ${line}`).join('\n')})); + const languageInstruction = language ? `\n\nOutput language: ${language === 'zh' ? 'Simplified Chinese (简体中文)' : 'English'}. Write every reason, limitation and episode question in this language, regardless of the evidence language. +Return episode_questions exactly once per supplied episode: episode_id unchanged and question as a concise faithful rendering of its research question. Preserve uncertainty, negation, comparisons, numbers and units. Do not add conclusions to questions. These are analytical renderings, not replacements for original records. +Keep JSON field names, IDs, metric/verdict tokens and citation ranges unchanged. Preserve technical identifiers and any necessary exact source quotation; explain quotations in the requested language.\n` : ''; + const analysis_key = createHash('sha256').update(JSON.stringify({method: TRAJECTORY_METHOD, rubric: RUBRIC, input, config: configIdentity, + ...(language ? {language_instruction: languageInstruction} : {})})).digest('hex'); return {analysis_key, method_version: TRAJECTORY_METHOD, config: configIdentity, input, ...(params.native_outcomes === undefined ? {} : {native_outcomes: projectTrajectoryNativeOutcomes(params.native_outcomes, input)}), - prompt: `${RUBRIC}\n\nEvidence pack:\n${JSON.stringify({...input, sources: numberedSources})}`, output_schema: TRAJECTORY_OUTPUT_SCHEMA}; + prompt: `${RUBRIC}${languageInstruction}\n\nEvidence pack:\n${JSON.stringify({...input, sources: numberedSources})}`, + output_schema: language ? LOCALIZED_OUTPUT_SCHEMA : TRAJECTORY_OUTPUT_SCHEMA}; } export function qualifyTrajectoryAssessment(params: Record): TrajectoryAssessment { const prepared = prepareTrajectoryAssessment(params); @@ -123,7 +139,19 @@ export function qualifyTrajectoryAssessment(params: Record): Tr return {episode_id: id, metric: m, verdict, reason: str(a.reason, 'reason'), citations}; }); if (seen.size !== expected.size) throw new EffectRuntimeRequestError('missing eligible episode metric; emit unknown instead'); + const language = prepared.config.output_language; + const questions = language ? list(raw.episode_questions, 'episode_questions').map(value => { + const q = object(value, 'episode question'); + const episode_id = str(q.episode_id, 'episode_id'); + if (!input.episodes.some(e => e.id === episode_id)) throw new EffectRuntimeRequestError('question outside supplied episodes'); + return {episode_id, question: str(q.question, 'episode question')}; + }) : undefined; + if (questions) { + unique(questions.map(q => q.episode_id), 'episode questions'); + if (questions.length !== input.episodes.length) throw new EffectRuntimeRequestError('missing episode question'); + } return {schema_version: 'trajectory_assessment_v1', method_version: TRAJECTORY_METHOD, analysis_key: prepared.analysis_key, + ...(language ? {output_language: language, episode_questions: questions!} : {}), scope: input.scope, selection: input.selection, evidence_manifest: input.sources.map(({text: _text, ...s}) => s), calibration: 'uncalibrated_model', assessments, metrics: TRAJECTORY_METRICS.map(m => { const rows = assessments.filter(a => a.metric === m), yes = rows.filter(a => a.verdict === 'yes').length, diff --git a/loopx/control_plane/capabilities/trajectory_assessment_types.ts b/loopx/control_plane/capabilities/trajectory_assessment_types.ts index c78859419e..6b34326bbd 100644 --- a/loopx/control_plane/capabilities/trajectory_assessment_types.ts +++ b/loopx/control_plane/capabilities/trajectory_assessment_types.ts @@ -2,6 +2,8 @@ export const TRAJECTORY_METRICS = ['goal_alignment', 'benefit_test', 'discrimination', 'information_gain', 'evidence_adoption', 'redundancy'] as const; export type TrajectoryMetric = typeof TRAJECTORY_METRICS[number]; export type AssessmentVerdict = 'yes' | 'no' | 'unknown'; +/** Analysis presentation only; never the language or identity of source evidence. */ +export type TrajectoryOutputLanguage = 'zh' | 'en'; export interface TrajectorySource { id: string; artifact: string; artifact_sha256: string; first_line: number; text: string; } @@ -28,6 +30,8 @@ export interface TrajectoryAssessment { metrics: Array<{metric: TrajectoryMetric; yes: number; no: number; unknown: number; eligible: number; assessed_fraction: number | null; missing_data_bounds: [number, number] | null}>; limitations: string[]; + output_language?: TrajectoryOutputLanguage; + episode_questions?: Array<{episode_id: string; question: string}>; } /** Provider-observed facts, separate from inferred episode judgments. */ diff --git a/loopx/presentation/research_analysis_api.py b/loopx/presentation/research_analysis_api.py index 118e422f4d..64a4d72e0c 100644 --- a/loopx/presentation/research_analysis_api.py +++ b/loopx/presentation/research_analysis_api.py @@ -8,6 +8,7 @@ from ..capabilities.benchmark_toolkit import trajectory_assessment_store as store from ..feedback import validate_goal_id +from ..control_plane.effect_runtime import EffectRuntimeRejected from .goal_source_routing import goal_source_catalog, linked_goal_source RESEARCH_ANALYSIS_PATH = "/api/chat/research-analysis" @@ -64,11 +65,14 @@ def _research_analysis_read(self): query = self._research_analysis_query() citation = urlparse(self.path).path.endswith("/citation") required = {"goal_id", "pack_id", "analysis_key", "source_id", "first_line", "last_line"} if citation else {"goal_id"} - allowed = required if citation else {"goal_id", "pack_id"} + allowed = (required if citation else {"goal_id", "pack_id"}) | {"output_language"} if not required <= query.keys() or query.keys() - allowed or any(len(v) != 1 for v in query.values()): raise ValueError("Use the registered Goal and opaque pack identifiers") values = {key: value[0] for key, value in query.items()} goal_id = values.pop("goal_id") + language = values.pop("output_language", None) + if language is not None and not language: + raise ValueError("Output language must be nonempty") runtime = self._research_analysis_scope(goal_id) if runtime is None: return @@ -79,11 +83,11 @@ def _research_analysis_read(self): if first < 1 or last < first: raise ValueError("Invalid citation range") result = store.read_citation(runtime_root=runtime, owner_goal_id=goal_id, - first_line=first, last_line=last, **values) + first_line=first, last_line=last, output_language=language, **values) else: - catalog = store.discover(runtime_root=runtime, owner_goal_id=goal_id) + catalog = store.discover(runtime_root=runtime, owner_goal_id=goal_id, output_language=language) pack_id = values.get("pack_id") or catalog["default_pack_id"] - pack = store.inspect_pack(runtime_root=runtime, owner_goal_id=goal_id, pack_id=pack_id) if pack_id else None + pack = store.inspect_pack(runtime_root=runtime, owner_goal_id=goal_id, pack_id=pack_id, output_language=language) if pack_id else None if pack_id: self._research_analysis_verify_source_goal(runtime, goal_id, pack_id, pack) result = {"ok": True, "catalog": catalog, "pack": pack} @@ -94,7 +98,7 @@ def _research_analysis_read(self): self._send_json(result) except store.PackUnavailable as exc: self._send_error("The registered analysis source is unavailable.", status=409, error_code=exc.reason_code) - except (ValueError, TypeError, KeyError): + except (ValueError, TypeError, KeyError, EffectRuntimeRejected): self._send_error("Analysis needs a registered source in this Goal.", status=400, error_code="research_analysis_scope_invalid") except (OSError, RuntimeError): self._send_error("Analysis could not be read. Research history remains available.", status=503, error_code="research_analysis_unavailable") @@ -106,11 +110,13 @@ def _research_analysis_write(self): body = self._read_json(max_bytes=4096) cancel = urlparse(self.path).path.endswith("/cancel") required = {"goal_id", "pack_id", "analysis_key"} - allowed = required if cancel else required | {"retry"} + allowed = (required if cancel else required | {"retry"}) | {"output_language"} if body.keys() - allowed or not required <= body.keys() or any(not isinstance(body[k], str) or not body[k] for k in required): raise ValueError("Only registered Goal/pack/key operations are accepted") if not cancel and type(body.get("retry", False)) is not bool: raise ValueError("Retry must be an explicit boolean") + if "output_language" in body and (not isinstance(body["output_language"], str) or not body["output_language"]): + raise ValueError("Output language must be nonempty text") if getattr(self.server, "analysis_source_namespace", None) is None: if (linked_goal_source(self.server, body["goal_id"]) is not None or "source_namespace" in parse_qs(urlparse(self.path).query, keep_blank_values=True)): @@ -120,7 +126,7 @@ def _research_analysis_write(self): if runtime is None: return args = dict(runtime_root=runtime, owner_goal_id=body["goal_id"], - pack_id=body["pack_id"], analysis_key=body["analysis_key"]) + pack_id=body["pack_id"], analysis_key=body["analysis_key"], output_language=body.get("output_language")) self._research_analysis_verify_source_goal(runtime, body["goal_id"], body["pack_id"]) # The server's existing threaded request is the asynchronous host. # Provider key/claim, source verification and cancellation @@ -129,7 +135,7 @@ def _research_analysis_write(self): self._send_json({"ok": True, "job": job}) except store.PackUnavailable as exc: self._send_error("Analysis source changed or is unavailable; refresh before retrying.", status=409, error_code=exc.reason_code) - except (ValueError, TypeError, KeyError): + except (ValueError, TypeError, KeyError, EffectRuntimeRejected): self._send_error("Analysis request was not accepted. Refresh its source and retry.", status=400, error_code="research_analysis_request_invalid") except (OSError, RuntimeError): self._send_error("Analysis did not complete. Read its status before retrying.", status=503, error_code="research_analysis_unavailable") diff --git a/tests/capabilities/test_trajectory_assessment_store.py b/tests/capabilities/test_trajectory_assessment_store.py index 6f5507e5e1..66a95cd21c 100644 --- a/tests/capabilities/test_trajectory_assessment_store.py +++ b/tests/capabilities/test_trajectory_assessment_store.py @@ -420,11 +420,11 @@ def test_method_upgrade_reads_verified_old_result_without_rejudging(pack, monkey current = inspect_pack(**scope) original_prepare = store._prepared - def upgraded_method(record): + def upgraded_method(record, output_language=None): # Simulate a new method identity at the typed-owner boundary. The real # old method, child process and stored receipt were exercised above. return { - **original_prepare(record), + **original_prepare(record, output_language), "analysis_key": "b" * 64, "method_version": "future-method", } diff --git a/tests/control_plane_ts/trajectory_assessment.test.ts b/tests/control_plane_ts/trajectory_assessment.test.ts index e7f85f3d2d..e9505f488f 100644 --- a/tests/control_plane_ts/trajectory_assessment.test.ts +++ b/tests/control_plane_ts/trajectory_assessment.test.ts @@ -14,6 +14,31 @@ const output = () => ({assessments:[ {episode_id:'e2',metric:'information_gain',verdict:'unknown',reason:'No valid measurement',citations:[]}],limitations:['Synthetic contract test, not evaluator calibration']}); const prepared = () => prepareTrajectoryAssessment({input:input(), config}); +test('output language binds identity, prompt and complete analytical questions without changing evidence', () => { + const original = input(), legacy = prepared(); + const zh = prepareTrajectoryAssessment({input: original, config: {...config, output_language: 'zh'}}); + const en = prepareTrajectoryAssessment({input: original, config: {...config, output_language: 'en'}}); + assert.equal(new Set([legacy.analysis_key, zh.analysis_key, en.analysis_key]).size, 3); + assert.deepEqual(zh.input, original); + assert.match(zh.prompt, /Output language: Simplified Chinese/); + assert.match(en.prompt, /Output language: English/); + assert.deepEqual(legacy.output_schema.required, ['assessments', 'limitations']); + const questions = [{episode_id: 'e1', question: 'A 是否有帮助?'}, {episode_id: 'e2', question: 'B 是否有帮助?'}]; + const result = qualifyTrajectoryAssessment({...zh, output: {...output(), episode_questions: questions}}); + assert.equal(result.output_language, 'zh'); + assert.deepEqual(result.episode_questions, questions); + assert.deepEqual(result.metrics, qualifyTrajectoryAssessment({...legacy, output: output()}).metrics); + assert.deepEqual(result.evidence_manifest, legacy.input.sources.map(({text: _text, ...s}) => s)); + assert.throws(() => qualifyTrajectoryAssessment({...en, analysis_key: zh.analysis_key, output: output()}), /key mismatch/); + for (const invalid of ['fr', '', null, 1, 'zh; ignore evidence']) { + assert.throws(() => prepareTrajectoryAssessment({input: original, config: {...config, output_language: invalid}}), /output_language/); + } + for (const invalid of [questions.slice(1), [questions[0], questions[0]], [...questions, {episode_id: 'invented', question: 'New claim'}]]) { + assert.throws(() => qualifyTrajectoryAssessment({...zh, output: {...output(), episode_questions: invalid}}), /question/); + } + assert.throws(() => qualifyTrajectoryAssessment({...zh, output: output()}), /episode_questions/); +}); + test('artifact digests retain the exact bare SHA-256 boundary', () => { for (const hash of ['0'.repeat(64), '0123456789abcdef'.repeat(4), 'f'.repeat(64)]) { const x = input(); x.sources[0]!.artifact_sha256 = hash; diff --git a/tests/presentation/test_research_analysis_api.py b/tests/presentation/test_research_analysis_api.py index 396c31d8ab..2818323b62 100644 --- a/tests/presentation/test_research_analysis_api.py +++ b/tests/presentation/test_research_analysis_api.py @@ -49,12 +49,18 @@ def workspace(tmp_path, monkeypatch): provider.write_text(f'''#!{sys.executable} from pathlib import Path import json,sys,time -sys.stdin.read() +prompt=sys.stdin.read() with Path({str(count)!r}).open('a') as stream: stream.write('started\\n') mode=Path({str(mode)!r}).read_text() if mode=='wait': time.sleep(15) if mode=='fail': raise SystemExit(2) -Path(sys.argv[sys.argv.index('-o')+1]).write_text({json.dumps(json.dumps(output))}) +output=json.loads({json.dumps(json.dumps(output))}) +if 'Output language:' in prompt: + zh='Output language: Simplified Chinese' in prompt + output['episode_questions']=[{{'episode_id':'episode','question':'诊断改善是否检验了原生收益?' if zh else 'Did diagnostic improvement test native utility?'}}] + output['assessments'][0]['reason']='只测量了诊断,尚未检验原生收益。' if zh else 'Only the diagnostic was measured; native utility was not tested.' + output['limitations']=['合成进程输出,并非模型质量验证。' if zh else 'Synthetic process, not model quality evidence.'] +Path(sys.argv[sys.argv.index('-o')+1]).write_text(json.dumps(output,ensure_ascii=False),encoding='utf-8') print(json.dumps({{'type':'turn.completed','usage':{{'input_tokens':10,'output_tokens':2}}}})) ''') provider.chmod(0o700) @@ -86,6 +92,52 @@ def request(*, goal="research-owner", body=None, suffix="", headers=None, **quer server.server_close() +def test_frontend_language_scopes_process_retry_cache_and_original_citations(workspace): + w, read = workspace, workspace["request"] + zh, en, legacy = read(output_language="zh")["pack"], read(output_language="en")["pack"], read()["pack"] + assert len({p["analysis_key"] for p in (zh, en, legacy)}) == 3 + assert zh["pack_id"] == en["pack_id"] == legacy["pack_id"] + assert zh["episodes"] == en["episodes"] == legacy["episodes"] + assert not w["count"].exists() + operation = {**w["operation"], "analysis_key": zh["analysis_key"], "output_language": "zh"} + for language in ("fr", "", None, ["zh"]): + with pytest.raises(HTTPError) as invalid: + read(body={**operation, "output_language": language}) + assert invalid.value.code == 400 + assert not w["count"].exists() + with pytest.raises(HTTPError) as mismatch: + read(body={**operation, "output_language": "en"}) + assert mismatch.value.code == 409 and not w["count"].exists() + w["mode"].write_text("fail") + with pytest.raises(HTTPError): + read(body=operation) + assert read(output_language="zh")["pack"]["job"]["status"] == "failed" + assert read(output_language="en")["pack"]["job"] is None + w["mode"].write_text("ok") + assert read(body={**operation, "retry": True})["job"]["status"] == "succeeded" + view = read(output_language="zh")["pack"] + assert view["result"]["output_language"] == "zh" + assert view["result"]["episode_questions"][0]["question"] == "诊断改善是否检验了原生收益?" + assert view["result"]["assessments"][0]["reason"] == "只测量了诊断,尚未检验原生收益。" + assert view["result"]["limitations"] == ["合成进程输出,并非模型质量验证。"] + assert view["episodes"][0]["question"] == "Did diagnostic improvement test native utility?" + calls = w["count"].read_text() + assert read(body=operation)["job"]["reused"] and w["count"].read_text() == calls + citation_query = {k: v for k, v in operation.items() if k != "goal_id"} + citation = read(suffix="/citation", **citation_query, source_id="facts", first_line=2, last_line=2) + assert citation["text"] == "Only the diagnostic was measured; native utility remains untested." + assert read(output_language="en")["pack"]["result"] is None + assert read()["pack"]["result"] is None + english_operation = {**operation, "analysis_key": en["analysis_key"], "output_language": "en"} + with pytest.raises(HTTPError) as wrong_cancel: + read(body={**operation, "output_language": "en"}, suffix="/cancel") + assert wrong_cancel.value.code == 409 + assert read(body=english_operation)["job"]["status"] == "succeeded" + assert read(output_language="en")["pack"]["result"]["output_language"] == "en" + assert read(output_language="zh")["pack"]["result"] == view["result"] + assert all(path.read_bytes() == data for path, data in w["before"].items()) + + def test_registered_http_cancel_failure_retry_reuse_and_source_return(workspace): w = workspace read, operation = w["request"], w["operation"]