From 438966bb2787a68e916995914f3c8422d8331d3e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:12:12 +0800 Subject: [PATCH 1/3] feat(benchmark): register source-bound authored case studies Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../trajectory_assessment_store.py | 15 ++++ loopx/cli_commands/benchmark_trajectory.py | 3 + .../capabilities/trajectory_assessment.ts | 2 + .../capabilities/trajectory_case_study.ts | 80 +++++++++++++++++++ .../test_trajectory_assessment_store.py | 36 +++++++++ .../trajectory_assessment.test.ts | 26 ++++++ 6 files changed, 162 insertions(+) create mode 100644 loopx/control_plane/capabilities/trajectory_case_study.ts diff --git a/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py b/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py index bac350b082..2cdd368158 100644 --- a/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py +++ b/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py @@ -57,6 +57,8 @@ def _pack_id(owner_goal_id: str, record: dict) -> str: identity["native_outcomes"] = record["native_outcomes"] if "base_pack_id" in record: identity["base_pack_id"] = record["base_pack_id"] + if "case_study" in record: + identity["case_study"] = record["case_study"] return _digest(identity) @@ -362,6 +364,9 @@ def _summary(record: dict, reason: str | None, prepared: dict | None = None) -> "config": analysis["config"], "availability": "unavailable" if reason else "available", "reason_code": reason, + **({"case_study_summary": {"title": record["case_study"]["title"], + "language": record["case_study"]["language"]}} + if "case_study" in record else {}), **({field: _captured_view(record, field) for field in ("solver_usage", "native_outcomes") if field in record}), } @@ -381,6 +386,7 @@ def register_pack( loopx_input_json: str | None = None, native_outcomes_json: str | None = None, base_pack_id: str | None = None, + case_study_json: Path | None = None, select_default: bool = True, ) -> dict: """Trusted operator only; no HTTP/renderer path-registration endpoint.""" @@ -392,6 +398,8 @@ def register_pack( "input": json.loads(input_path.read_text(encoding="utf-8")), "config": config or DEFAULT_CONFIG, "generation_defaults": not config, + **({"case_study": json.loads(case_study_json.read_text(encoding="utf-8"))} + if case_study_json is not None else {}), }, ) record = { @@ -404,6 +412,8 @@ def register_pack( }, } _verify_sources(record) + if "case_study" in prepared: + record["case_study"] = prepared["case_study"] if base_pack_id is not None: _, previous = _resolve(runtime_root, owner_goal_id, base_pack_id) # The typed owner validates Goal/case/run identity before registration. @@ -594,6 +604,7 @@ def inspect_pack( "episodes": record["input"]["episodes"], "job": _public_job(job) if job else None, "result": result, + **({"case_study": record["case_study"]} if "case_study" in record else {}), **({"display_request": prepared["display_request"]} if "display_request" in prepared else {}), **({"result_attempt": result_job["attempt"]} if prepared.get("display_request") and result is not None else {}), **({"result_job": _public_job(result_job)} if result is not None and result_job is not job else {}), @@ -726,6 +737,10 @@ def read_citation( citations.extend(c for field in ((result or {}).get("display") or {}).get("fields", []) for c in field.get("context_citations", [])) synthesis = (result or {}).get("synthesis") + case_study = view.get("case_study") + if case_study: + citations.extend(case_study["citations"]) + citations.extend(c for section in case_study["sections"] for c in section["citations"]) if synthesis: citations.extend(c for item in [synthesis["summary"], *synthesis["findings"], *synthesis["next_experiments"]] for c in item["citations"]) diff --git a/loopx/cli_commands/benchmark_trajectory.py b/loopx/cli_commands/benchmark_trajectory.py index 6a11dd053e..a1d0580968 100644 --- a/loopx/cli_commands/benchmark_trajectory.py +++ b/loopx/cli_commands/benchmark_trajectory.py @@ -33,6 +33,8 @@ def register_benchmark_trajectory_commands(subparsers, add_format) -> None: register.add_argument("--input-json", type=Path, required=True) register.add_argument("--source-root", type=Path, required=True) register.add_argument("--title", required=True) + register.add_argument("--case-study-json", type=Path, + help="Optional authored case reading document; source-bound, versioned with this pack, no model execution.") register.add_argument("--base-pack-id", help="Existing frozen pack in this owner Goal and original case/run; links a successor without running analysis.") register.add_argument("--preserve-default", action="store_true", help="Keep the existing default capture when registering another version; the first capture still becomes default.") @@ -104,6 +106,7 @@ def _handle_benchmark_trajectory_command( input_path=args.input_json, source_root=args.source_root, title=args.title, + case_study_json=args.case_study_json, usage_jsonl=args.usage_jsonl, usage_session_id=args.usage_session_id, include_incomplete_usage=args.include_incomplete_usage, diff --git a/loopx/control_plane/capabilities/trajectory_assessment.ts b/loopx/control_plane/capabilities/trajectory_assessment.ts index 33d2df62f6..9275afbd7c 100644 --- a/loopx/control_plane/capabilities/trajectory_assessment.ts +++ b/loopx/control_plane/capabilities/trajectory_assessment.ts @@ -5,6 +5,7 @@ import {BARE_SHA256_PATTERN} from '../content_digest.ts'; import {EffectRuntimeRequestError} from '../effect_runtime_errors.ts'; import {parseExploreHistoryPage, projectExploreHistory} from '../presentation/explore_history.ts'; import {trajectoryDisplayFields, trajectoryDisplaySnapshotText, maskTrajectoryDisplayText} from './trajectory_assessment_display.ts'; +import {qualifyTrajectoryCaseStudy} from './trajectory_case_study.ts'; export const TRAJECTORY_METHOD = 'research-episodes-v2'; import {TRAJECTORY_METRICS} from './trajectory_assessment_types.ts'; @@ -326,6 +327,7 @@ Keep JSON field names, IDs, metric/verdict tokens and citation ranges unchanged. || input.episodes.some(e => e.source_ids.includes(source.id)) || input.display_snapshot?.contexts?.some(row => row.citations.some(citation => citation.source_id === source.id))); return {analysis_key, method_version: method, config: configIdentity, input, + ...(params.case_study === undefined ? {} : {case_study: qualifyTrajectoryCaseStudy(params.case_study, input)}), ...(display ? {display_request: {schema_version: display.schema_version, snapshot_key: display.snapshot_key, source_id: display.source_id, source_namespace: display.source_namespace, eligible: display.fields.length}} : {}), ...(measured ? {native_outcomes: measured} : {}), diff --git a/loopx/control_plane/capabilities/trajectory_case_study.ts b/loopx/control_plane/capabilities/trajectory_case_study.ts new file mode 100644 index 0000000000..2a4ac48aff --- /dev/null +++ b/loopx/control_plane/capabilities/trajectory_case_study.ts @@ -0,0 +1,80 @@ +/** Authored reading companion to a frozen trajectory pack. No execution or scoring authority. */ +import {EffectRuntimeRequestError} from '../effect_runtime_errors.ts'; +import type {AssessmentCitation, TrajectoryInput, TrajectoryOutputLanguage} from './trajectory_assessment_types.ts'; + +export interface TrajectoryCaseStudy { + title: string; + language: TrajectoryOutputLanguage; + summary: string; + citations: AssessmentCitation[]; + sections: Array<{ + id: string; title: string; text: string; citations: AssessmentCitation[]; + table?: {columns: string[]; rows: string[][]}; + series?: {label: string; unit: string; points: Array<{label: string; value: number | null}>}; + timeline?: Array<{label: string; title: string; text: string}>; + }>; + limitations: string[]; +} + +/** Presentation shapes stay local to benchmark-toolkit; sources and pack identity remain the owners. */ +export function qualifyTrajectoryCaseStudy(raw: unknown, input: TrajectoryInput): TrajectoryCaseStudy { + const fail = (field: string): never => {throw new EffectRuntimeRequestError(`invalid case study ${field}`);}; + if (JSON.stringify(raw)?.length > 160_000) fail('reading size'); + const object = (v: unknown, field: string): Record => + v && typeof v === 'object' && !Array.isArray(v) ? v as Record : fail(field); + const text = (v: unknown, field: string, max = 12_000) => + typeof v === 'string' && v.trim() && !v.includes('\0') && v.length <= max ? v : fail(field); + const array = (v: unknown, field: string, max = 40): unknown[] => + Array.isArray(v) && v.length <= max ? v : fail(field); + const citations = (v: unknown): AssessmentCitation[] => { + const rows = array(v, 'citations').map(value => { + const c = object(value, 'citation'); + const source = input.sources.find(s => s.id === c.source_id); + if (!source || !Number.isSafeInteger(c.first_line) || !Number.isSafeInteger(c.last_line) + || Number(c.first_line) < source.first_line || Number(c.last_line) < Number(c.first_line) + || Number(c.last_line) >= source.first_line + source.text.split('\n').length) fail('citation range'); + return {source_id: source!.id, first_line: Number(c.first_line), last_line: Number(c.last_line)}; + }); + if (!rows.length) fail('missing evidence'); + return rows; + }; + const doc = object(raw, 'document'); + if (doc.language !== 'zh' && doc.language !== 'en') fail('language'); + const sections = array(doc.sections, 'sections', 24).map(value => { + const s = object(value, 'section'); + const id = text(s.id, 'section id', 80); + if (!/^[a-zA-Z0-9_-]+$/.test(id)) fail('section id'); + const section: TrajectoryCaseStudy['sections'][number] = {id, title: text(s.title, 'section title', 240), + text: text(s.text, 'section text'), citations: citations(s.citations)}; + if (s.table !== undefined) { + const table = object(s.table, 'table'); + const columns = array(table.columns, 'columns', 8).map(v => text(v, 'column', 120)); + if (!columns.length) fail('empty columns'); + const rows = array(table.rows, 'rows', 40).map(v => { + const row = array(v, 'row', 8).map(v => text(v, 'cell', 2000)); + if (row.length !== columns.length) fail('row width'); + return row; + }); + section.table = {columns, rows}; + } + if (s.series !== undefined) { + const series = object(s.series, 'series'); + const points = array(series.points, 'points', 40).map(v => { + const point = object(v, 'point'); + if (point.value !== null && (typeof point.value !== 'number' || !Number.isFinite(point.value))) fail('point value'); + return {label: text(point.label, 'point label', 120), value: point.value as number | null}; + }); + if (!points.length) fail('empty series'); + section.series = {label: text(series.label, 'series label', 240), unit: text(series.unit, 'unit', 80), points}; + } + if (s.timeline !== undefined) section.timeline = array(s.timeline, 'timeline', 30).map(v => { + const event = object(v, 'event'); + return {label: text(event.label, 'event label', 120), title: text(event.title, 'event title', 240), text: text(event.text, 'event text', 4000)}; + }); + return section; + }); + if (!sections.length || new Set(sections.map(s => s.id)).size !== sections.length) fail('section identities'); + return {title: text(doc.title, 'title', 240), language: doc.language as TrajectoryOutputLanguage, + summary: text(doc.summary, 'summary', 4000), citations: citations(doc.citations), sections, + limitations: array(doc.limitations, 'limitations', 20).map(v => text(v, 'limitation', 3000))}; +} diff --git a/tests/capabilities/test_trajectory_assessment_store.py b/tests/capabilities/test_trajectory_assessment_store.py index 3be6937b96..b5b7d64caf 100644 --- a/tests/capabilities/test_trajectory_assessment_store.py +++ b/tests/capabilities/test_trajectory_assessment_store.py @@ -1446,3 +1446,39 @@ def test_turn_counter_evidence_preserves_baseline_and_excludes_content(pack, inc capture.unlink() with pytest.raises(PackUnavailable, match="source_unavailable"): read_usage_turn(**operation, turn_id="b") + + +def test_authored_case_survives_source_change_and_revises_without_model_job(pack, tmp_path): + scope, _, original, input_path, sources, _ = pack + citation = dict(source_id="source", first_line=2, last_line=2) + study = dict(title="Observed negative result", language="en", summary="No gain was measured.", + citations=[citation], sections=[dict(id="background", title="What was measured", + text="Utility remained unchanged. The mechanism is unresolved.", citations=[citation])], + limitations=["Synthetic illustration, not a benchmark finding."]) + document = tmp_path / "case-study.json" + document.write_text(json.dumps(study), encoding="utf-8") + registered = register_pack(**scope, input_path=input_path, source_root=sources, + title="Case reading", case_study_json=document) + op = {**scope, "pack_id": registered["pack_id"], "analysis_key": registered["analysis_key"]} + assert registered["pack_id"] != original["pack_id"] + assert registered["analysis_key"] == original["analysis_key"] + view = inspect_pack(**scope, pack_id=registered["pack_id"]) + assert view["case_study"] == study and view["result"] is None and view["job"] is None + assert read_citation(**op, **citation)["text"].startswith("The native measurement") + with pytest.raises(PackUnavailable, match="citation_not_in_result"): + read_citation(**op, source_id="source", first_line=1, last_line=2) + assert register_pack(**scope, input_path=input_path, source_root=sources, + title="Case reading", case_study_json=document)["pack_id"] == registered["pack_id"] + study["summary"] = "The evidence does not establish a treatment effect." + document.write_text(json.dumps(study), encoding="utf-8") + revised = register_pack(**scope, input_path=input_path, source_root=sources, + title="Revised reading", case_study_json=document) + assert revised["pack_id"] != registered["pack_id"] + assert inspect_pack(**scope, pack_id=registered["pack_id"])["case_study"]["summary"] == "No gain was measured." + (sources / "result.txt").write_text("source replaced", encoding="utf-8") + historical = inspect_pack(**scope, pack_id=registered["pack_id"]) + assert historical["applicability"] == "historical" + assert historical["case_study"] == view["case_study"] + assert read_citation(**op, **citation)["text"].startswith("The native measurement") + assert not list(scope["runtime_root"].glob("benchmark/trajectory-assessments/*/jobs/*")) + assert "case_study" not in inspect_pack(**scope, pack_id=original["pack_id"]) diff --git a/tests/control_plane_ts/trajectory_assessment.test.ts b/tests/control_plane_ts/trajectory_assessment.test.ts index 99cc3e72a6..b5008ea5b8 100644 --- a/tests/control_plane_ts/trajectory_assessment.test.ts +++ b/tests/control_plane_ts/trajectory_assessment.test.ts @@ -718,3 +718,29 @@ test('synthesis revision separates new guidance while frozen and disabled reques assert.throws(() => prepareTrajectoryAssessment({input: input(), config: {...config, research_synthesis: false, research_synthesis_revision: 4}}), /enabled synthesis/); }); + +test('authored reading binds to frozen source lines without changing model semantics', () => { + const p = prepared(); + const citation = {source_id:'s',first_line:10,last_line:11}; + const study = {title:'A negative result',language:'en',summary:'Evidence changed the interpretation.',citations:[citation], + sections:[{id:'context',title:'Context',text:'A measured failure is information, not success.',citations:[citation], + series:{label:'Observed utility',unit:'points',points:[{label:'A',value:-2},{label:'B',value:null}]}, + table:{columns:['Route','Outcome'],rows:[['A','No gain']]}}], limitations:['No matched treatment effect.']}; + const qualified = prepareTrajectoryAssessment({input:input(), config, case_study:study}); + const {case_study, ...unchanged} = qualified; + assert.deepEqual(unchanged,p,'The optional reading does not alter frozen model semantics'); + assert.deepEqual(case_study,study); + for (const change of [ + d => {d.citations[0].source_id='unregistered';}, + d => {d.sections[0].citations[0].first_line=9;}, + d => {d.sections[0].citations[0].last_line=12;}, + d => {d.sections[0].citations=[];}, + d => {d.sections.push(structuredClone(d.sections[0]));}, + d => {d.sections[0].table.rows[0].pop();}, + d => {d.sections[0].series.points[0].value=NaN;}, + d => {d.summary='x'.repeat(160_001);}, + ] as Array<(d:any) => void>) { + const bad=structuredClone(study); change(bad); + assert.throws(()=>prepareTrajectoryAssessment({input:input(),config,case_study:bad}),/invalid case study/); + } +}); From 6b9353d4bc665a6ce4c940ed8359fa699afa9862 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:12:20 +0800 Subject: [PATCH 2/3] feat(research): read case studies inside benchmark child Goals Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/research-analysis-read-smoke.mjs | 27 +++ .../src/data/fixtures/case-study-child.json | 165 ++++++++++++++++++ .../data/fixtures/case-study-empty-child.json | 6 + .../src/data/fixtures/case-study-parent.json | 5 + .../goal-research-analysis.tsx | 24 +++ .../goal-research-history.tsx | 21 ++- .../goal-research-workbench.tsx | 25 ++- .../personal-workspace-page.tsx | 2 +- .../research-case-study.css | 46 +++++ .../research-case-study.tsx | 76 ++++++++ docs/development/design.md | 15 ++ docs/reference/trajectory-assessment.md | 45 +++++ .../research-history-backend.py | 5 + 13 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 apps/presentation/dashboard/src/data/fixtures/case-study-child.json create mode 100644 apps/presentation/dashboard/src/data/fixtures/case-study-empty-child.json create mode 100644 apps/presentation/dashboard/src/data/fixtures/case-study-parent.json create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx diff --git a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs index edc67d2be8..601948ebba 100644 --- a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs +++ b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs @@ -9,6 +9,33 @@ import {createElement} from "react"; import {renderToStaticMarkup} from "react-dom/server"; import {resolveTestPython} from "../../../../scripts/test-python.mjs"; import {readAnalysisSnapshot, ResearchAnalysisPanel} from "../src/features/personal-workspace/goal-research-analysis.tsx"; +import {ResearchCaseStudy} from "../src/features/personal-workspace/research-case-study.tsx"; +import {ResearchGoalNavigation} from "../src/features/personal-workspace/goal-research-workbench.tsx"; + +// A case belongs to a source-scoped child Goal. Parent navigation must never +// combine private-source siblings or turn organization into execution. +const caseFixture = JSON.parse(await readFile(resolve('src/data/fixtures/case-study-child.json'), 'utf8')); +const study = caseFixture.analyses[0].case_study; +const renderedStudy = renderToStaticMarkup(createElement(ResearchCaseStudy, { + study, zh: false, historical: true, scope: caseFixture.analyses[0].input.scope, onCitation: () => {throw Error('read executed a citation');}, +})); +assert.ok(renderedStudy.includes(study.title)); +assert.ok(renderedStudy.includes('Showing the saved Chinese original.')); +assert.ok(renderedStudy.includes('not current results')); +assert.ok(renderedStudy.includes('unknown'), 'Missing values do not become zero'); +for (const section of study.sections) assert.ok(renderedStudy.includes(section.title)); +const goals = [ + {goalId:'parent',title:'Benchmark'}, + {goalId:'child',parentGoalId:'parent',title:'Local case'}, + {goalId:'foreign',parentGoalId:'parent',title:'Foreign case',sourceAccess:{namespace:'other',label:'Other',readOnly:true}}, +]; +const navigate = goalId => renderToStaticMarkup(createElement(ResearchGoalNavigation, { + goalId, goals, zh:false, onSelect: () => {throw Error('read navigated');}, +})); +assert.ok(navigate('parent').includes('Local case')); +assert.ok(!navigate('parent').includes('Foreign case')); +assert.ok(navigate('child').includes('Benchmark')); +assert.equal(navigate('foreign'), '', 'An absent same-source parent is not a navigation grant'); // A language-specific cache miss must not hide the same registered pack's // older, unspecified-language result or turn a read into model execution. diff --git a/apps/presentation/dashboard/src/data/fixtures/case-study-child.json b/apps/presentation/dashboard/src/data/fixtures/case-study-child.json new file mode 100644 index 0000000000..d736ab7f91 --- /dev/null +++ b/apps/presentation/dashboard/src/data/fixtures/case-study-child.json @@ -0,0 +1,165 @@ +{ + "goal_id": "synthetic-case-search", + "parent_goal_id": "synthetic-benchmark", + "display_name": "Search · Case 研究", + "events": [], + "analyses": [ + { + "title": "初版解读", + "input": { + "schema_version": "trajectory_assessment_input_v1", + "scope": { + "goal_id": "synthetic-solver", + "case_id": "Search", + "run_id": "synthetic-run", + "revision": "fixed-synthetic-evidence", + "window": "Three synthetic observations" + }, + "objective": "Compare valid error measurements without inventing retention or causality.", + "selection": { + "rule": "Complete synthetic illustration", + "population_episodes": 1, + "exhaustive": true + }, + "sources": [ + { + "id": "facts", + "artifact": "facts.txt", + "artifact_sha256": "2dec56af0cdca4e603227b1a42b56d15c2ccec3ad4973bd2b34d2d94122aeb91", + "first_line": 1, + "text": "Synthetic candidate A measured error 7 points.\nSynthetic candidate B was invalid and has no score.\nSynthetic candidate C measured error 3 points.\nRetention and matched treatment controls were not captured." + } + ], + "episodes": [ + { + "id": "search", + "question": "What changed and what is still unknown?", + "source_ids": [ + "facts" + ], + "eligible_metrics": [ + "benefit_test" + ] + } + ] + }, + "artifacts": { + "facts.txt": "Synthetic candidate A measured error 7 points.\nSynthetic candidate B was invalid and has no score.\nSynthetic candidate C measured error 3 points.\nRetention and matched treatment controls were not captured." + }, + "case_study": { + "title": "搜索示例:误差下降了,是否真正采用仍未知", + "language": "zh", + "summary": "合成观测中,后续候选把误差从 **7 分降至 3 分**,中间还有一次无效候选。这个变化值得研究,但不能直接称作最终交付,更不能当成 LoopX 的因果收益。\n\n理解本案需要分开三个问题:怎样算更好,测到了什么,以及改善是否真的留在最终成果里。下面按这个顺序解释,原始事实可在每一节查阅。", + "citations": [ + { + "source_id": "facts", + "first_line": 1, + "last_line": 4 + } + ], + "sections": [ + { + "id": "problem", + "title": "先理解题目:什么才算一次有效改善", + "text": "本例用误差衡量候选结果与目标的距离,因此数值越低越好。有效性先于数值比较:一次未通过有效性检查的尝试没有可比较的分数,不能把它补成零。\n\n**本页全部数据都是合成的展示样本。** 它用于验证负例、未知状态和证据阅读,不代表真实 benchmark 的结果。后面的最佳值也只描述这个固定窗口,不代表窗口外的历史。", + "citations": [ + { + "source_id": "facts", + "first_line": 1, + "last_line": 4 + } + ], + "table": { + "columns": [ + "概念", + "怎样理解", + "本例中可说什么" + ], + "rows": [ + [ + "误差", + "偏离目标的程度;更低更好", + "候选 C 的 3 分低于 A 的 7 分" + ], + [ + "无效结果", + "没有通过资格检查", + "候选 B 没有分数,不能当作零" + ], + [ + "最终采用", + "改善被保留在实际成果中", + "未捕获保留证据,仍未知" + ] + ] + } + }, + { + "id": "trajectory", + "title": "过程与结果:看到进展,也保留空白", + "text": "只看第一轮和最后一轮,会漏掉中间的无效尝试。把三次观测放回顺序中,可以确认后续出现了更低误差,但看不出它为何出现。\n\n这里不连接缺失点,也不从提交序号推导时间成本。没有耗时记录,就不能把一次更好的结果进一步解释成效率提高。", + "citations": [ + { + "source_id": "facts", + "first_line": 1, + "last_line": 3 + } + ], + "series": { + "label": "固定窗口内的观测", + "unit": "误差分 · 更低更好", + "points": [ + { + "label": "候选 A", + "value": 7 + }, + { + "label": "候选 B", + "value": null + }, + { + "label": "候选 C", + "value": 3 + } + ] + }, + "timeline": [ + { + "label": "第一次", + "title": "有了可比较的起点", + "text": "候选 A 的误差为 7 分。" + }, + { + "label": "第二次", + "title": "一次失败不能从故事中消失", + "text": "候选 B 无效,没有分数。失败仍是过程的一部分。" + }, + { + "label": "第三次", + "title": "更低误差出现,但保留未明", + "text": "候选 C 的误差为 3 分;最终采用仍需另一份证据。" + } + ] + }, + { + "id": "limits", + "title": "关于系统强弱,证据支持到哪一步", + "text": "这条记录支持“窗口内出现过更好的有效候选”。它还不能回答是哪种机制促成改善:模型本身、额外搜索、工具反馈或结构化状态,都可能影响结果。\n\n如果没有相同条件下的对照,便无法把全部变化归于某一个系统。类似地,没有最终成果的保留记录,也无法把最佳观测值称为交付值。\n\n这些信息共同界定了判断的范围:**有进展的观测,有无效尝试,也有尚未回答的问题。**", + "citations": [ + { + "source_id": "facts", + "first_line": 4, + "last_line": 4 + } + ] + } + ], + "limitations": [ + "本页是合成示例;不作为科研结论或产品效果证据。", + "未记录保留状态、耗时或匹配对照,不能补造这些事实。" + ] + }, + "outputs": {} + } + ] +} diff --git a/apps/presentation/dashboard/src/data/fixtures/case-study-empty-child.json b/apps/presentation/dashboard/src/data/fixtures/case-study-empty-child.json new file mode 100644 index 0000000000..0ec25a1191 --- /dev/null +++ b/apps/presentation/dashboard/src/data/fixtures/case-study-empty-child.json @@ -0,0 +1,6 @@ +{ + "goal_id": "synthetic-case-unread", + "parent_goal_id": "synthetic-benchmark", + "display_name": "另一 Case · 尚无解读", + "events": [] +} diff --git a/apps/presentation/dashboard/src/data/fixtures/case-study-parent.json b/apps/presentation/dashboard/src/data/fixtures/case-study-parent.json new file mode 100644 index 0000000000..9182485333 --- /dev/null +++ b/apps/presentation/dashboard/src/data/fixtures/case-study-parent.json @@ -0,0 +1,5 @@ +{ + "goal_id": "synthetic-benchmark", + "display_name": "Benchmark · 研究示例", + "events": [] +} 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 2b47843827..211cb001b7 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 @@ -7,6 +7,8 @@ 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} from "./research-case-study"; +import type {TrajectoryCaseStudy} from "../../../../../../loopx/control_plane/capabilities/trajectory_case_study.js"; import "./goal-research-analysis.css"; // Transport views of the existing toolkit producer, not an assessment, score, @@ -16,6 +18,8 @@ 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; + case_study?: TrajectoryCaseStudy; + case_study_summary?: {title: string; language: TrajectoryOutputLanguage}; display_request?: {schema_version: 'research_display_v1'; snapshot_key: string; source_id: string; source_namespace: string; eligible: number}; result_attempt?: number; result_job?: Job; @@ -346,6 +350,26 @@ export function ResearchReportSummary({analysis: a}: {analysis: Controller}) { onExpand={origin => {a.trigger.current = origin; a.show();}} onCitation={a.readCitation}/> : null; } +export function ResearchCaseReading({analysis: a, onHistory}: {analysis: Controller; onHistory: () => void}) { + const packs = (a.snapshot?.catalog.packs ?? []).filter(p => p.case_study_summary); + const p = a.pack; + const firstStudy = packs[0]?.pack_id; + useEffect(() => { + if (!a.loading && !a.readError && p && !p.case_study && firstStudy) a.select(firstStudy); + }, [a.loading, a.readError, p?.pack_id, Boolean(p?.case_study), firstStudy]); + return
+
+ + {p?.case_study ? : !a.loading && !a.readError ?

{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}

: null} + +
; +} + function CitedToolText({text, zh}: {text: string; zh: boolean}) { // Decode only the saved direct shell-result envelope, never recursively // reinterpret arbitrary JSON or infer successful work from its text. diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index 3ba8c18b2b..b69b0e27ca 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -4,7 +4,7 @@ import {AlertCircle, ArrowLeft, ArrowRight, Circle, CircleHelp, FlaskConical, Gi import {Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from "react"; import {defaultParseSearch} from "@tanstack/react-router"; import {ResearchRecordSearch} from "./research-record-browser"; -import {ResearchAnalysisButton, ResearchAnalysisPanel, ResearchAnalysisStatus, ResearchCitationPanel, ResearchProgressSummary, ResearchReportSummary, useResearchAnalysis} from "./goal-research-analysis"; +import {ResearchAnalysisButton, ResearchAnalysisPanel, ResearchAnalysisStatus, ResearchCitationPanel, ResearchProgressSummary, ResearchReportSummary, ResearchCaseReading, useResearchAnalysis} from "./goal-research-analysis"; import {buildExploreTimeline, parseExploreFindingSections, type TimelineFinding} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js"; import {GoalResearchTimeline} from "./goal-research-timeline"; import {requestJson} from "../../data/chat"; @@ -54,6 +54,22 @@ const viewFromUrl = (goal: string) => { * a judge. All recorded updates survive; unsupported analysis stays unavailable. */ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, readSourceUrl}: {goalId: string; active: boolean; zh: boolean; refreshRevision?: number; readSourceUrl?: string}) { const analysis = useResearchAnalysis(goalId, active, zh, readSourceUrl); + const readCaseMode = () => { + const query = new URLSearchParams(window.location.search); + return query.get("research_goal") !== goalId || query.get("research_reading") !== "history"; + }; + const [caseReading, setCaseReading] = useState(readCaseMode); + useEffect(() => { + const changed = () => setCaseReading(readCaseMode()); + changed(); window.addEventListener("popstate", changed); + return () => window.removeEventListener("popstate", changed); + }, [goalId, readSourceUrl]); + const setCaseMode = (reading: boolean) => { + const url = new URL(window.location.href); + url.searchParams.set("research_goal", goalId); + url.searchParams.set("research_reading", reading ? "case" : "history"); + window.history.pushState({}, "", url); setCaseReading(reading); + }; const [read, setRead] = useState({events: [], total: null, complete: false}); const [originalDisplay, setOriginalDisplay] = useState(false); const [displayIdentity, setDisplayIdentity] = useState<{events: ExploreHistoryEvent[]; namespace: string; key: string} | null>(null); @@ -449,7 +465,10 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const emptyHistory = !busy && !readFailure && read.total === 0; const emptyWithAnalysis = emptyHistory && Boolean(analysis.pack?.result || analysis.pack?.native_outcomes); const emptyHistoryMessage = zh ? "这个 Goal 尚无研究历程" : "No research history for this Goal yet"; + const hasCases = (analysis.snapshot?.catalog.packs ?? []).some(p => p.case_study_summary); + if (hasCases && caseReading) return setCaseMode(false)}/>; return
+ {hasCases ? : null} {progressiveOverview ? <>