@researai/deepscientist 1.5.14 → 1.5.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/assets/branding/logo-raster.png +0 -0
- package/bin/ds.js +134 -49
- package/docs/en/00_QUICK_START.md +2 -2
- package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
- package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
- package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
- package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
- package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
- package/docs/en/README.md +6 -0
- package/docs/zh/00_QUICK_START.md +2 -2
- package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
- package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
- package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
- package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
- package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
- package/docs/zh/README.md +6 -0
- package/install.sh +2 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/artifact/charts.py +567 -0
- package/src/deepscientist/artifact/guidance.py +50 -10
- package/src/deepscientist/artifact/metrics.py +228 -5
- package/src/deepscientist/artifact/schemas.py +3 -0
- package/src/deepscientist/artifact/service.py +3534 -191
- package/src/deepscientist/bash_exec/models.py +23 -0
- package/src/deepscientist/bash_exec/monitor.py +147 -67
- package/src/deepscientist/bash_exec/runtime.py +218 -156
- package/src/deepscientist/bash_exec/service.py +79 -64
- package/src/deepscientist/bash_exec/shells.py +87 -0
- package/src/deepscientist/bridges/connectors.py +51 -2
- package/src/deepscientist/config/models.py +6 -3
- package/src/deepscientist/config/service.py +7 -2
- package/src/deepscientist/connector/weixin_support.py +122 -1
- package/src/deepscientist/daemon/api/handlers.py +75 -4
- package/src/deepscientist/daemon/api/router.py +1 -0
- package/src/deepscientist/daemon/app.py +758 -206
- package/src/deepscientist/doctor.py +51 -0
- package/src/deepscientist/file_lock.py +48 -0
- package/src/deepscientist/gitops/diff.py +167 -1
- package/src/deepscientist/mcp/server.py +173 -5
- package/src/deepscientist/process_control.py +161 -0
- package/src/deepscientist/prompts/builder.py +267 -442
- package/src/deepscientist/quest/service.py +2255 -163
- package/src/deepscientist/quest/stage_views.py +171 -0
- package/src/deepscientist/runners/base.py +2 -0
- package/src/deepscientist/runners/codex.py +88 -5
- package/src/deepscientist/runners/runtime_overrides.py +17 -1
- package/src/prompts/contracts/shared_interaction.md +13 -4
- package/src/prompts/system.md +916 -72
- package/src/skills/analysis-campaign/SKILL.md +31 -2
- package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
- package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
- package/src/skills/baseline/SKILL.md +2 -0
- package/src/skills/decision/SKILL.md +19 -2
- package/src/skills/experiment/SKILL.md +8 -2
- package/src/skills/finalize/SKILL.md +18 -0
- package/src/skills/idea/SKILL.md +78 -0
- package/src/skills/idea/references/idea-generation-playbook.md +100 -0
- package/src/skills/idea/references/outline-seeding-example.md +60 -0
- package/src/skills/intake-audit/SKILL.md +1 -1
- package/src/skills/optimize/SKILL.md +1644 -0
- package/src/skills/rebuttal/SKILL.md +2 -1
- package/src/skills/review/SKILL.md +2 -1
- package/src/skills/write/SKILL.md +80 -12
- package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
- package/src/tui/dist/app/AppContainer.js +3 -0
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-DaF9Nge_.js → AiManusChatView-DDjbFnbt.js} +12 -12
- package/src/ui/dist/assets/{AnalysisPlugin-BSVx6dXE.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
- package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
- package/src/ui/dist/assets/{CodeEditorPlugin-DU9G0Tox.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-DoX_fI9l.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-C4FWIXuU.js → DocViewerPlugin-CLChbllo.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-BgfFMgtf.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
- package/src/ui/dist/assets/{ImageViewerPlugin-tcPkfY_x.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-_dKV60Bf.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
- package/src/ui/dist/assets/{LabPlugin-Bje0ayoC.js → LabPlugin-DQPg-NrB.js} +2 -2
- package/src/ui/dist/assets/{LatexPlugin-CVsBzAln.js → LatexPlugin-CI05XAV9.js} +7 -7
- package/src/ui/dist/assets/{MarkdownViewerPlugin-xjmrqv_8.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-mMM2A8wP.js → MarketplacePlugin-DolE58Q2.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-3kVDSOBo.js → NotebookEditor-7Qm2rSWD.js} +11 -11
- package/src/ui/dist/assets/{NotebookEditor-SoJ8X-MO.js → NotebookEditor-C1kWaxKi.js} +1 -1
- package/src/ui/dist/assets/{PdfLoader-DElVuHl9.js → PdfLoader-BfOHw8Zw.js} +1 -1
- package/src/ui/dist/assets/{PdfMarkdownPlugin-Bq88XT4G.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
- package/src/ui/dist/assets/{PdfViewerPlugin-CsCXMo9S.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
- package/src/ui/dist/assets/{SearchPlugin-oUPvy19k.js → SearchPlugin-CjpaiJ3A.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-CRkT9yNy.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
- package/src/ui/dist/assets/{VNCViewer-BgbuvWhR.js → VNCViewer-HAg9mF7M.js} +10 -10
- package/src/ui/dist/assets/{bot-v_RASACv.js → bot-0DYntytV.js} +1 -1
- package/src/ui/dist/assets/{code-5hC9d0VH.js → code-B20Slj_w.js} +1 -1
- package/src/ui/dist/assets/{file-content-D1PxfOrp.js → file-content-DT24KFma.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-DG1oT_Hj.js → file-diff-panel-DK13YPql.js} +1 -1
- package/src/ui/dist/assets/{file-socket-BmdFYQlk.js → file-socket-B4T2o4nR.js} +1 -1
- package/src/ui/dist/assets/{image-Dqe2X2tW.js → image-DSeR_sDS.js} +1 -1
- package/src/ui/dist/assets/{index-RDlNXXx1.js → index-BrFje2Uk.js} +2 -2
- package/src/ui/dist/assets/{index-DVsMKK_y.js → index-BwRJaoTl.js} +1 -1
- package/src/ui/dist/assets/{index-Nt9hS4ck.js → index-D_E4281X.js} +5007 -28514
- package/src/ui/dist/assets/{index-Duvz8Ip0.js → index-DnYB3xb1.js} +12 -12
- package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
- package/src/ui/dist/assets/{monaco-DIXge1CP.js → monaco-LExaAN3Y.js} +1 -1
- package/src/ui/dist/assets/{pdf-effect-queue-BBTTQaO-.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
- package/src/ui/dist/assets/{popover-BWlolyxo.js → popover-D3Gg_FoV.js} +1 -1
- package/src/ui/dist/assets/{project-sync-BM5PkFH4.js → project-sync-C_ygLlVU.js} +1 -1
- package/src/ui/dist/assets/{select-D4dAtrA8.js → select-CpAK6uWm.js} +2 -2
- package/src/ui/dist/assets/{sigma-CKbE5jJT.js → sigma-DEccaSgk.js} +1 -1
- package/src/ui/dist/assets/{square-check-big-CZNGMgiB.js → square-check-big-uUfyVsbD.js} +1 -1
- package/src/ui/dist/assets/{trash-DaB37xAz.js → trash-CXvwwSe8.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-C2OmAcWe.js → useCliAccess-Bnop4mgR.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-Dowd1Ij4.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-BGjAhAUq.js → wrap-text-9vbOBpkW.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-dMZQMXzc.js → zoom-out-BgVMmOW4.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
3
5
|
import re
|
|
4
6
|
import shutil
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
5
9
|
from pathlib import Path, PurePosixPath
|
|
6
10
|
from typing import Any
|
|
7
11
|
|
|
@@ -31,6 +35,7 @@ from ..shared import (
|
|
|
31
35
|
read_yaml,
|
|
32
36
|
resolve_within,
|
|
33
37
|
run_command,
|
|
38
|
+
sha256_text,
|
|
34
39
|
slugify,
|
|
35
40
|
utc_now,
|
|
36
41
|
write_json,
|
|
@@ -40,6 +45,7 @@ from ..shared import (
|
|
|
40
45
|
from ..quest import QuestService
|
|
41
46
|
from ..memory.frontmatter import dump_markdown_document, load_markdown_document
|
|
42
47
|
from .arxiv import fetch_arxiv_metadata, read_arxiv_content
|
|
48
|
+
from .charts import render_main_experiment_metric_timeline_chart
|
|
43
49
|
from .guidance import build_guidance_for_record, guidance_summary
|
|
44
50
|
from .metrics import (
|
|
45
51
|
baseline_metric_lines,
|
|
@@ -98,6 +104,8 @@ class ArtifactService:
|
|
|
98
104
|
self.baselines = BaselineRegistry(home)
|
|
99
105
|
self.quest_service = QuestService(home)
|
|
100
106
|
self.arxiv_library = ArxivLibraryService()
|
|
107
|
+
self._optimization_frontier_cache_lock = threading.Lock()
|
|
108
|
+
self._optimization_frontier_cache: dict[str, dict[str, Any]] = {}
|
|
101
109
|
|
|
102
110
|
@staticmethod
|
|
103
111
|
def _notification_text(value: object) -> str | None:
|
|
@@ -225,38 +233,124 @@ class ArtifactService:
|
|
|
225
233
|
return branch
|
|
226
234
|
return fallback or "current head"
|
|
227
235
|
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _clean_text(value: object) -> str | None:
|
|
238
|
+
text = " ".join(str(value or "").split()).strip()
|
|
239
|
+
if not text:
|
|
240
|
+
return None
|
|
241
|
+
return text
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _summary_preview_text(value: object, *, limit: int = 220) -> str | None:
|
|
245
|
+
text = ArtifactService._clean_text(value)
|
|
246
|
+
if not text:
|
|
247
|
+
return None
|
|
248
|
+
if len(text) <= limit:
|
|
249
|
+
return text
|
|
250
|
+
return text[: max(0, limit - 1)].rstrip() + "…"
|
|
251
|
+
|
|
228
252
|
def _build_idea_interaction_message(
|
|
229
253
|
self,
|
|
230
254
|
*,
|
|
255
|
+
quest_root: Path,
|
|
231
256
|
action: str,
|
|
232
257
|
idea_id: str,
|
|
233
258
|
title: str | None,
|
|
234
|
-
problem: str | None,
|
|
235
|
-
hypothesis: str | None,
|
|
236
259
|
mechanism: str | None,
|
|
260
|
+
method_brief: str | None,
|
|
237
261
|
foundation_label: str | None,
|
|
238
262
|
branch_name: str,
|
|
263
|
+
change_layer: str | None,
|
|
264
|
+
source_lens: str | None,
|
|
265
|
+
expected_gain: str | None,
|
|
239
266
|
next_target: str | None,
|
|
240
|
-
idea_md_rel_path: str | None,
|
|
241
|
-
draft_md_rel_path: str | None,
|
|
242
267
|
) -> str:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
self._append_notification_section(lines, "Mechanism", mechanism)
|
|
249
|
-
if foundation_label:
|
|
250
|
-
self._append_notification_section(lines, "Foundation", foundation_label)
|
|
251
|
-
if next_target:
|
|
252
|
-
self._append_notification_section(lines, "Next route", self._format_route_label(next_target) or next_target)
|
|
253
|
-
self._append_notification_file_section(
|
|
254
|
-
lines,
|
|
255
|
-
[
|
|
256
|
-
("Idea doc", idea_md_rel_path),
|
|
257
|
-
("Draft", draft_md_rel_path),
|
|
258
|
-
],
|
|
268
|
+
normalized_title = self._clean_text(title or idea_id) or idea_id
|
|
269
|
+
design_text = self._clean_text(method_brief or mechanism) or self.quest_service.localized_copy(
|
|
270
|
+
quest_root=quest_root,
|
|
271
|
+
zh="核心设计还未写清楚",
|
|
272
|
+
en="the core design is not written clearly yet",
|
|
259
273
|
)
|
|
274
|
+
compared_target = self._clean_text(foundation_label) or self.quest_service.localized_copy(
|
|
275
|
+
quest_root=quest_root,
|
|
276
|
+
zh="当前方案",
|
|
277
|
+
en="the current approach",
|
|
278
|
+
)
|
|
279
|
+
delta_parts: list[str] = []
|
|
280
|
+
if change_layer:
|
|
281
|
+
delta_parts.append(
|
|
282
|
+
self.quest_service.localized_copy(
|
|
283
|
+
quest_root=quest_root,
|
|
284
|
+
zh=f"重点改 `{self._clean_text(change_layer) or change_layer}`",
|
|
285
|
+
en=f"changes `{self._clean_text(change_layer) or change_layer}` first",
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
if source_lens:
|
|
289
|
+
delta_parts.append(
|
|
290
|
+
self.quest_service.localized_copy(
|
|
291
|
+
quest_root=quest_root,
|
|
292
|
+
zh=f"引入 `{self._clean_text(source_lens) or source_lens}` 的设计视角",
|
|
293
|
+
en=f"adds a `{self._clean_text(source_lens) or source_lens}` design angle",
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
if not delta_parts:
|
|
297
|
+
delta_parts.append(
|
|
298
|
+
self.quest_service.localized_copy(
|
|
299
|
+
quest_root=quest_root,
|
|
300
|
+
zh=f"把 `{design_text}` 直接加到主设计里",
|
|
301
|
+
en=f"directly adds `{design_text}` into the main design",
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
delta_text = self._clean_text(";".join(delta_parts)) or delta_parts[0]
|
|
305
|
+
expected_text = self._clean_text(expected_gain)
|
|
306
|
+
next_target_text = self._format_route_label(next_target) if next_target else None
|
|
307
|
+
if action == "candidate":
|
|
308
|
+
headline = self.quest_service.localized_copy(
|
|
309
|
+
quest_root=quest_root,
|
|
310
|
+
zh=f"收到 idea 候选:{normalized_title}",
|
|
311
|
+
en=f"Idea candidate recorded: {normalized_title}",
|
|
312
|
+
)
|
|
313
|
+
elif action == "revise":
|
|
314
|
+
headline = self.quest_service.localized_copy(
|
|
315
|
+
quest_root=quest_root,
|
|
316
|
+
zh=f"已更新 idea:{normalized_title}({branch_name})",
|
|
317
|
+
en=f"Idea updated: {normalized_title} ({branch_name})",
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
headline = self.quest_service.localized_copy(
|
|
321
|
+
quest_root=quest_root,
|
|
322
|
+
zh=f"新 idea:{normalized_title}({branch_name})",
|
|
323
|
+
en=f"New idea: {normalized_title} ({branch_name})",
|
|
324
|
+
)
|
|
325
|
+
lines = [
|
|
326
|
+
headline,
|
|
327
|
+
self.quest_service.localized_copy(
|
|
328
|
+
quest_root=quest_root,
|
|
329
|
+
zh=f"创新点:{design_text}",
|
|
330
|
+
en=f"Innovation: {design_text}",
|
|
331
|
+
),
|
|
332
|
+
self.quest_service.localized_copy(
|
|
333
|
+
quest_root=quest_root,
|
|
334
|
+
zh=f"相对 {compared_target}:{delta_text}",
|
|
335
|
+
en=f"Compared with {compared_target}: {delta_text}",
|
|
336
|
+
),
|
|
337
|
+
]
|
|
338
|
+
if expected_text:
|
|
339
|
+
lines.append(
|
|
340
|
+
self.quest_service.localized_copy(
|
|
341
|
+
quest_root=quest_root,
|
|
342
|
+
zh=f"预期收益:{expected_text}",
|
|
343
|
+
en=f"Expected gain: {expected_text}",
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
elif next_target_text:
|
|
347
|
+
lines.append(
|
|
348
|
+
self.quest_service.localized_copy(
|
|
349
|
+
quest_root=quest_root,
|
|
350
|
+
zh=f"下一步:{next_target_text}",
|
|
351
|
+
en=f"Next step: {next_target_text}",
|
|
352
|
+
)
|
|
353
|
+
)
|
|
260
354
|
return "\n".join(lines)
|
|
261
355
|
|
|
262
356
|
def _build_main_experiment_interaction_message(
|
|
@@ -313,6 +407,139 @@ class ArtifactService:
|
|
|
313
407
|
)
|
|
314
408
|
return "\n".join(lines)
|
|
315
409
|
|
|
410
|
+
def _main_experiment_chart_dir(self, workspace_root: Path, *, run_id: str) -> Path:
|
|
411
|
+
return ensure_dir(workspace_root / "experiments" / "main" / run_id / "connector-charts")
|
|
412
|
+
|
|
413
|
+
def _generate_main_experiment_metric_charts(
|
|
414
|
+
self,
|
|
415
|
+
quest_root: Path,
|
|
416
|
+
*,
|
|
417
|
+
workspace_root: Path,
|
|
418
|
+
run_id: str,
|
|
419
|
+
) -> list[dict[str, Any]]:
|
|
420
|
+
timeline = self.quest_service.metrics_timeline(self._quest_id(quest_root))
|
|
421
|
+
chart_dir = self._main_experiment_chart_dir(workspace_root, run_id=run_id)
|
|
422
|
+
charts: list[dict[str, Any]] = []
|
|
423
|
+
for series in timeline.get("series") or []:
|
|
424
|
+
if not isinstance(series, dict):
|
|
425
|
+
continue
|
|
426
|
+
points = [dict(item) for item in (series.get("points") or []) if isinstance(item, dict)]
|
|
427
|
+
if not points:
|
|
428
|
+
continue
|
|
429
|
+
latest_point = points[-1]
|
|
430
|
+
if str(latest_point.get("run_id") or "").strip() != run_id:
|
|
431
|
+
continue
|
|
432
|
+
metric_id = str(series.get("metric_id") or "").strip()
|
|
433
|
+
if not metric_id:
|
|
434
|
+
continue
|
|
435
|
+
output_path = chart_dir / f"{slugify(metric_id, 'metric')}-timeline.png"
|
|
436
|
+
chart_payload = render_main_experiment_metric_timeline_chart(
|
|
437
|
+
series=series,
|
|
438
|
+
output_path=output_path,
|
|
439
|
+
)
|
|
440
|
+
charts.append(chart_payload)
|
|
441
|
+
return charts
|
|
442
|
+
|
|
443
|
+
def _auto_metric_chart_targets(
|
|
444
|
+
self,
|
|
445
|
+
quest_root: Path,
|
|
446
|
+
*,
|
|
447
|
+
connectors: dict[str, Any],
|
|
448
|
+
) -> list[tuple[str, str]]:
|
|
449
|
+
targets: list[tuple[str, str]] = []
|
|
450
|
+
for target in self._bound_conversations(quest_root):
|
|
451
|
+
channel_name = self._normalize_channel_name(target)
|
|
452
|
+
if channel_name not in {"qq", "weixin"}:
|
|
453
|
+
continue
|
|
454
|
+
channel_config = connectors.get(channel_name) if isinstance(connectors.get(channel_name), dict) else {}
|
|
455
|
+
if not bool(channel_config.get("enabled", False)):
|
|
456
|
+
continue
|
|
457
|
+
if not bool(channel_config.get("auto_send_main_experiment_png", True)):
|
|
458
|
+
continue
|
|
459
|
+
targets.append((target, channel_name))
|
|
460
|
+
return targets
|
|
461
|
+
|
|
462
|
+
def _send_main_experiment_metric_charts(
|
|
463
|
+
self,
|
|
464
|
+
quest_root: Path,
|
|
465
|
+
*,
|
|
466
|
+
run_id: str,
|
|
467
|
+
title: str,
|
|
468
|
+
charts: list[dict[str, Any]],
|
|
469
|
+
) -> dict[str, Any]:
|
|
470
|
+
connectors = self._connectors_config()
|
|
471
|
+
targets = self._auto_metric_chart_targets(quest_root, connectors=connectors)
|
|
472
|
+
if not charts or not targets:
|
|
473
|
+
return {
|
|
474
|
+
"enabled": False,
|
|
475
|
+
"chart_count": len(charts),
|
|
476
|
+
"target_count": len(targets),
|
|
477
|
+
"targets": [target for target, _ in targets],
|
|
478
|
+
"deliveries": [],
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
deliveries: list[dict[str, Any]] = []
|
|
482
|
+
for chart_index, chart in enumerate(charts):
|
|
483
|
+
label = str(chart.get("label") or chart.get("metric_id") or "metric").strip() or "metric"
|
|
484
|
+
path = str(chart.get("path") or "").strip()
|
|
485
|
+
if not path:
|
|
486
|
+
continue
|
|
487
|
+
message = f"Main experiment metric chart · {label}"
|
|
488
|
+
attachments = [
|
|
489
|
+
{
|
|
490
|
+
"kind": "path",
|
|
491
|
+
"path": path,
|
|
492
|
+
"label": label,
|
|
493
|
+
"content_type": "image/png",
|
|
494
|
+
"connector_delivery": {
|
|
495
|
+
"qq": {
|
|
496
|
+
"media_kind": "image",
|
|
497
|
+
"allow_internal_auto_media": True,
|
|
498
|
+
},
|
|
499
|
+
"weixin": {
|
|
500
|
+
"media_kind": "image",
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
]
|
|
505
|
+
for target, channel_name in targets:
|
|
506
|
+
payload = {
|
|
507
|
+
"quest_root": str(quest_root),
|
|
508
|
+
"quest_id": self._quest_id(quest_root),
|
|
509
|
+
"conversation_id": target,
|
|
510
|
+
"kind": "main_experiment_metric_chart",
|
|
511
|
+
"message": message,
|
|
512
|
+
"response_phase": "push",
|
|
513
|
+
"importance": "info",
|
|
514
|
+
"attachments": attachments,
|
|
515
|
+
}
|
|
516
|
+
delivery_result = self._deliver_to_channel(
|
|
517
|
+
channel_name,
|
|
518
|
+
payload,
|
|
519
|
+
connectors=connectors,
|
|
520
|
+
)
|
|
521
|
+
deliveries.append(
|
|
522
|
+
{
|
|
523
|
+
"target": target,
|
|
524
|
+
"channel": channel_name,
|
|
525
|
+
"metric_id": chart.get("metric_id"),
|
|
526
|
+
"label": label,
|
|
527
|
+
"path": path,
|
|
528
|
+
"delivery": delivery_result,
|
|
529
|
+
}
|
|
530
|
+
)
|
|
531
|
+
if chart_index < len(charts) - 1:
|
|
532
|
+
time.sleep(2.0)
|
|
533
|
+
return {
|
|
534
|
+
"enabled": True,
|
|
535
|
+
"run_id": run_id,
|
|
536
|
+
"title": title,
|
|
537
|
+
"chart_count": len(charts),
|
|
538
|
+
"target_count": len(targets),
|
|
539
|
+
"targets": [target for target, _ in targets],
|
|
540
|
+
"deliveries": deliveries,
|
|
541
|
+
}
|
|
542
|
+
|
|
316
543
|
def _build_outline_interaction_message(
|
|
317
544
|
self,
|
|
318
545
|
*,
|
|
@@ -460,6 +687,7 @@ class ArtifactService:
|
|
|
460
687
|
writing_plan_rel_path: str | None,
|
|
461
688
|
references_rel_path: str | None,
|
|
462
689
|
claim_evidence_map_rel_path: str | None,
|
|
690
|
+
evidence_ledger_rel_path: str | None,
|
|
463
691
|
compile_report_rel_path: str | None,
|
|
464
692
|
pdf_rel_path: str | None,
|
|
465
693
|
latex_root_rel_path: str | None,
|
|
@@ -486,6 +714,7 @@ class ArtifactService:
|
|
|
486
714
|
("Writing plan", writing_plan_rel_path),
|
|
487
715
|
("References", references_rel_path),
|
|
488
716
|
("Claim-evidence map", claim_evidence_map_rel_path),
|
|
717
|
+
("Evidence ledger", evidence_ledger_rel_path),
|
|
489
718
|
("Compile report", compile_report_rel_path),
|
|
490
719
|
("PDF", pdf_rel_path),
|
|
491
720
|
("LaTeX root", latex_root_rel_path),
|
|
@@ -847,6 +1076,11 @@ class ArtifactService:
|
|
|
847
1076
|
next_target: str,
|
|
848
1077
|
branch: str,
|
|
849
1078
|
worktree_root: Path,
|
|
1079
|
+
method_brief: str = "",
|
|
1080
|
+
selection_scores: dict[str, Any] | None = None,
|
|
1081
|
+
mechanism_family: str = "",
|
|
1082
|
+
change_layer: str = "",
|
|
1083
|
+
source_lens: str = "",
|
|
850
1084
|
foundation_ref: dict[str, Any] | None = None,
|
|
851
1085
|
foundation_reason: str = "",
|
|
852
1086
|
lineage_intent: str | None = None,
|
|
@@ -854,9 +1088,16 @@ class ArtifactService:
|
|
|
854
1088
|
) -> str:
|
|
855
1089
|
normalized_foundation = dict(foundation_ref or {})
|
|
856
1090
|
normalized_lineage_intent = str(lineage_intent or "").strip().lower() or None
|
|
1091
|
+
normalized_method_brief = str(method_brief or "").strip()
|
|
1092
|
+
normalized_selection_scores = self._normalize_selection_scores(selection_scores)
|
|
1093
|
+
normalized_mechanism_family = str(mechanism_family or "").strip() or None
|
|
1094
|
+
normalized_change_layer = str(change_layer or "").strip() or None
|
|
1095
|
+
normalized_source_lens = str(source_lens or "").strip() or None
|
|
857
1096
|
tags = [f"branch:{branch}", f"next:{next_target}"]
|
|
858
1097
|
if normalized_lineage_intent:
|
|
859
1098
|
tags.append(f"lineage:{normalized_lineage_intent}")
|
|
1099
|
+
if normalized_mechanism_family:
|
|
1100
|
+
tags.append(f"family:{slugify(normalized_mechanism_family, 'family')}")
|
|
860
1101
|
metadata = {
|
|
861
1102
|
"id": idea_id,
|
|
862
1103
|
"type": "ideas",
|
|
@@ -867,6 +1108,11 @@ class ArtifactService:
|
|
|
867
1108
|
"branch": branch,
|
|
868
1109
|
"worktree_root": str(worktree_root),
|
|
869
1110
|
"next_target": next_target,
|
|
1111
|
+
"method_brief": normalized_method_brief or None,
|
|
1112
|
+
"selection_scores": normalized_selection_scores or None,
|
|
1113
|
+
"mechanism_family": normalized_mechanism_family,
|
|
1114
|
+
"change_layer": normalized_change_layer,
|
|
1115
|
+
"source_lens": normalized_source_lens,
|
|
870
1116
|
"foundation_ref": normalized_foundation or None,
|
|
871
1117
|
"foundation_reason": foundation_reason.strip() or None,
|
|
872
1118
|
"lineage_intent": normalized_lineage_intent,
|
|
@@ -889,10 +1135,24 @@ class ArtifactService:
|
|
|
889
1135
|
"",
|
|
890
1136
|
mechanism.strip() or "TBD",
|
|
891
1137
|
"",
|
|
1138
|
+
"## Method Brief",
|
|
1139
|
+
"",
|
|
1140
|
+
normalized_method_brief or "Not recorded",
|
|
1141
|
+
"",
|
|
892
1142
|
"## Expected Gain",
|
|
893
1143
|
"",
|
|
894
1144
|
expected_gain.strip() or "TBD",
|
|
895
1145
|
"",
|
|
1146
|
+
"## Selection Scores",
|
|
1147
|
+
"",
|
|
1148
|
+
*self._selection_score_lines(normalized_selection_scores),
|
|
1149
|
+
"",
|
|
1150
|
+
"## Diversity Tags",
|
|
1151
|
+
"",
|
|
1152
|
+
f"- Mechanism family: {normalized_mechanism_family or 'Not recorded'}",
|
|
1153
|
+
f"- Change layer: {normalized_change_layer or 'Not recorded'}",
|
|
1154
|
+
f"- Source lens: {normalized_source_lens or 'Not recorded'}",
|
|
1155
|
+
"",
|
|
896
1156
|
"## Decision Reason",
|
|
897
1157
|
"",
|
|
898
1158
|
decision_reason.strip() or "TBD",
|
|
@@ -956,6 +1216,11 @@ class ArtifactService:
|
|
|
956
1216
|
next_target: str,
|
|
957
1217
|
branch: str,
|
|
958
1218
|
worktree_root: Path,
|
|
1219
|
+
method_brief: str = "",
|
|
1220
|
+
selection_scores: dict[str, Any] | None = None,
|
|
1221
|
+
mechanism_family: str = "",
|
|
1222
|
+
change_layer: str = "",
|
|
1223
|
+
source_lens: str = "",
|
|
959
1224
|
foundation_ref: dict[str, Any] | None = None,
|
|
960
1225
|
foundation_reason: str = "",
|
|
961
1226
|
lineage_intent: str | None = None,
|
|
@@ -964,6 +1229,11 @@ class ArtifactService:
|
|
|
964
1229
|
) -> str:
|
|
965
1230
|
normalized_foundation = dict(foundation_ref or {})
|
|
966
1231
|
normalized_lineage_intent = str(lineage_intent or "").strip().lower() or None
|
|
1232
|
+
normalized_method_brief = str(method_brief or "").strip()
|
|
1233
|
+
normalized_selection_scores = self._normalize_selection_scores(selection_scores)
|
|
1234
|
+
normalized_mechanism_family = str(mechanism_family or "").strip() or None
|
|
1235
|
+
normalized_change_layer = str(change_layer or "").strip() or None
|
|
1236
|
+
normalized_source_lens = str(source_lens or "").strip() or None
|
|
967
1237
|
metadata = {
|
|
968
1238
|
"id": f"{idea_id}-draft",
|
|
969
1239
|
"type": "ideas",
|
|
@@ -975,6 +1245,11 @@ class ArtifactService:
|
|
|
975
1245
|
"branch": branch,
|
|
976
1246
|
"worktree_root": str(worktree_root),
|
|
977
1247
|
"next_target": next_target,
|
|
1248
|
+
"method_brief": normalized_method_brief or None,
|
|
1249
|
+
"selection_scores": normalized_selection_scores or None,
|
|
1250
|
+
"mechanism_family": normalized_mechanism_family,
|
|
1251
|
+
"change_layer": normalized_change_layer,
|
|
1252
|
+
"source_lens": normalized_source_lens,
|
|
978
1253
|
"foundation_ref": normalized_foundation or None,
|
|
979
1254
|
"foundation_reason": foundation_reason.strip() or None,
|
|
980
1255
|
"lineage_intent": normalized_lineage_intent,
|
|
@@ -1000,6 +1275,11 @@ class ArtifactService:
|
|
|
1000
1275
|
if evidence_paths
|
|
1001
1276
|
else "- None recorded yet."
|
|
1002
1277
|
)
|
|
1278
|
+
selection_score_lines = (
|
|
1279
|
+
"\n".join(self._selection_score_lines(normalized_selection_scores))
|
|
1280
|
+
if normalized_selection_scores
|
|
1281
|
+
else "- Not recorded"
|
|
1282
|
+
)
|
|
1003
1283
|
body = "\n".join(
|
|
1004
1284
|
[
|
|
1005
1285
|
f"# {title}",
|
|
@@ -1020,6 +1300,20 @@ class ArtifactService:
|
|
|
1020
1300
|
"",
|
|
1021
1301
|
mechanism.strip() or "TBD",
|
|
1022
1302
|
"",
|
|
1303
|
+
"## Method Brief",
|
|
1304
|
+
"",
|
|
1305
|
+
normalized_method_brief or "Not recorded",
|
|
1306
|
+
"",
|
|
1307
|
+
"## Selection Scores",
|
|
1308
|
+
"",
|
|
1309
|
+
selection_score_lines,
|
|
1310
|
+
"",
|
|
1311
|
+
"## Diversity Tags",
|
|
1312
|
+
"",
|
|
1313
|
+
f"- Mechanism family: {normalized_mechanism_family or 'Not recorded'}",
|
|
1314
|
+
f"- Change layer: {normalized_change_layer or 'Not recorded'}",
|
|
1315
|
+
f"- Source lens: {normalized_source_lens or 'Not recorded'}",
|
|
1316
|
+
"",
|
|
1023
1317
|
"## Code-Level Change Plan",
|
|
1024
1318
|
"",
|
|
1025
1319
|
mechanism.strip() or "TBD",
|
|
@@ -1047,64 +1341,369 @@ class ArtifactService:
|
|
|
1047
1341
|
next_target.strip() or "experiment",
|
|
1048
1342
|
"",
|
|
1049
1343
|
]
|
|
1050
|
-
|
|
1344
|
+
)
|
|
1051
1345
|
return dump_markdown_document(metadata, body.rstrip() + "\n")
|
|
1052
1346
|
|
|
1053
|
-
def
|
|
1054
|
-
return ensure_dir(quest_root / "
|
|
1055
|
-
|
|
1056
|
-
def _read_analysis_manifest(self, quest_root: Path, campaign_id: str) -> dict[str, Any]:
|
|
1057
|
-
path = self._analysis_manifest_path(quest_root, campaign_id)
|
|
1058
|
-
payload = read_json(path, {})
|
|
1059
|
-
if not isinstance(payload, dict) or not payload:
|
|
1060
|
-
raise FileNotFoundError(f"Unknown analysis campaign `{campaign_id}`.")
|
|
1061
|
-
return payload
|
|
1062
|
-
|
|
1063
|
-
def _write_analysis_manifest(self, quest_root: Path, campaign_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
1064
|
-
path = self._analysis_manifest_path(quest_root, campaign_id)
|
|
1065
|
-
normalized = {**payload, "campaign_id": campaign_id, "updated_at": utc_now()}
|
|
1066
|
-
write_json(path, normalized)
|
|
1067
|
-
return normalized
|
|
1068
|
-
|
|
1069
|
-
def _analysis_baseline_inventory_path(self, quest_root: Path) -> Path:
|
|
1070
|
-
return ensure_dir(quest_root / "artifacts" / "baselines") / "analysis_inventory.json"
|
|
1071
|
-
|
|
1072
|
-
def _read_analysis_baseline_inventory(self, quest_root: Path) -> dict[str, Any]:
|
|
1073
|
-
path = self._analysis_baseline_inventory_path(quest_root)
|
|
1074
|
-
payload = read_json(path, {})
|
|
1075
|
-
if not isinstance(payload, dict):
|
|
1076
|
-
payload = {}
|
|
1077
|
-
entries = payload.get("entries") if isinstance(payload.get("entries"), list) else []
|
|
1078
|
-
return {
|
|
1079
|
-
"schema_version": 1,
|
|
1080
|
-
"entries": [dict(item) for item in entries if isinstance(item, dict)],
|
|
1081
|
-
"updated_at": payload.get("updated_at"),
|
|
1082
|
-
}
|
|
1347
|
+
def _idea_candidate_root(self, quest_root: Path, idea_id: str) -> Path:
|
|
1348
|
+
return ensure_dir(quest_root / "memory" / "ideas" / "_candidates" / idea_id)
|
|
1083
1349
|
|
|
1084
|
-
def
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1350
|
+
def _build_candidate_idea_markdown(
|
|
1351
|
+
self,
|
|
1352
|
+
*,
|
|
1353
|
+
idea_id: str,
|
|
1354
|
+
quest_id: str,
|
|
1355
|
+
title: str,
|
|
1356
|
+
problem: str,
|
|
1357
|
+
hypothesis: str,
|
|
1358
|
+
mechanism: str,
|
|
1359
|
+
expected_gain: str,
|
|
1360
|
+
risks: list[str],
|
|
1361
|
+
evidence_paths: list[str],
|
|
1362
|
+
decision_reason: str,
|
|
1363
|
+
next_target: str,
|
|
1364
|
+
candidate_root: Path,
|
|
1365
|
+
method_brief: str = "",
|
|
1366
|
+
selection_scores: dict[str, Any] | None = None,
|
|
1367
|
+
mechanism_family: str = "",
|
|
1368
|
+
change_layer: str = "",
|
|
1369
|
+
source_lens: str = "",
|
|
1370
|
+
foundation_ref: dict[str, Any] | None = None,
|
|
1371
|
+
foundation_reason: str = "",
|
|
1372
|
+
lineage_intent: str | None = None,
|
|
1373
|
+
) -> str:
|
|
1374
|
+
normalized_foundation = dict(foundation_ref or {})
|
|
1375
|
+
normalized_lineage_intent = str(lineage_intent or "").strip().lower() or None
|
|
1376
|
+
normalized_method_brief = str(method_brief or "").strip()
|
|
1377
|
+
normalized_selection_scores = self._normalize_selection_scores(selection_scores)
|
|
1378
|
+
normalized_mechanism_family = str(mechanism_family or "").strip() or None
|
|
1379
|
+
normalized_change_layer = str(change_layer or "").strip() or None
|
|
1380
|
+
normalized_source_lens = str(source_lens or "").strip() or None
|
|
1381
|
+
metadata = {
|
|
1382
|
+
"id": idea_id,
|
|
1383
|
+
"type": "ideas",
|
|
1384
|
+
"kind": "idea_candidate",
|
|
1385
|
+
"title": title,
|
|
1386
|
+
"quest_id": quest_id,
|
|
1387
|
+
"scope": "quest",
|
|
1388
|
+
"submission_mode": "candidate",
|
|
1389
|
+
"candidate_root": str(candidate_root),
|
|
1390
|
+
"next_target": next_target,
|
|
1391
|
+
"method_brief": normalized_method_brief or None,
|
|
1392
|
+
"selection_scores": normalized_selection_scores or None,
|
|
1393
|
+
"mechanism_family": normalized_mechanism_family,
|
|
1394
|
+
"change_layer": normalized_change_layer,
|
|
1395
|
+
"source_lens": normalized_source_lens,
|
|
1396
|
+
"foundation_ref": normalized_foundation or None,
|
|
1397
|
+
"foundation_reason": foundation_reason.strip() or None,
|
|
1398
|
+
"lineage_intent": normalized_lineage_intent,
|
|
1399
|
+
"created_at": utc_now(),
|
|
1090
1400
|
"updated_at": utc_now(),
|
|
1401
|
+
"tags": [
|
|
1402
|
+
"idea-candidate",
|
|
1403
|
+
f"next:{next_target}",
|
|
1404
|
+
*( [f"lineage:{normalized_lineage_intent}"] if normalized_lineage_intent else []),
|
|
1405
|
+
],
|
|
1091
1406
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1407
|
+
body_lines = [
|
|
1408
|
+
f"# {title}",
|
|
1409
|
+
"",
|
|
1410
|
+
"## Candidate Summary",
|
|
1411
|
+
"",
|
|
1412
|
+
decision_reason.strip() or "This candidate is recorded for later ranking or promotion.",
|
|
1413
|
+
"",
|
|
1414
|
+
"## Problem",
|
|
1415
|
+
"",
|
|
1416
|
+
problem.strip() or "TBD",
|
|
1417
|
+
"",
|
|
1418
|
+
"## Hypothesis",
|
|
1419
|
+
"",
|
|
1420
|
+
hypothesis.strip() or "TBD",
|
|
1421
|
+
"",
|
|
1422
|
+
"## Mechanism",
|
|
1423
|
+
"",
|
|
1424
|
+
mechanism.strip() or "TBD",
|
|
1425
|
+
"",
|
|
1426
|
+
"## Method Brief",
|
|
1427
|
+
"",
|
|
1428
|
+
normalized_method_brief or "Not recorded",
|
|
1429
|
+
"",
|
|
1430
|
+
"## Expected Gain",
|
|
1431
|
+
"",
|
|
1432
|
+
expected_gain.strip() or "TBD",
|
|
1433
|
+
"",
|
|
1434
|
+
"## Selection Scores",
|
|
1435
|
+
"",
|
|
1436
|
+
*self._selection_score_lines(normalized_selection_scores),
|
|
1437
|
+
"",
|
|
1438
|
+
"## Diversity Tags",
|
|
1439
|
+
"",
|
|
1440
|
+
f"- Mechanism family: {normalized_mechanism_family or 'Not recorded'}",
|
|
1441
|
+
f"- Change layer: {normalized_change_layer or 'Not recorded'}",
|
|
1442
|
+
f"- Source lens: {normalized_source_lens or 'Not recorded'}",
|
|
1443
|
+
"",
|
|
1444
|
+
"## Risks",
|
|
1445
|
+
"",
|
|
1446
|
+
]
|
|
1447
|
+
if risks:
|
|
1448
|
+
body_lines.extend([f"- {item}" for item in risks])
|
|
1449
|
+
else:
|
|
1450
|
+
body_lines.append("- None recorded yet.")
|
|
1451
|
+
body_lines.extend(["", "## Evidence Paths", ""])
|
|
1452
|
+
if evidence_paths:
|
|
1453
|
+
body_lines.extend([f"- `{item}`" for item in evidence_paths])
|
|
1454
|
+
else:
|
|
1455
|
+
body_lines.append("- None recorded yet.")
|
|
1456
|
+
body_lines.extend(["", "## Foundation", ""])
|
|
1457
|
+
if normalized_foundation:
|
|
1458
|
+
body_lines.extend(
|
|
1459
|
+
[
|
|
1460
|
+
f"- Lineage intent: `{normalized_lineage_intent or 'manual'}`",
|
|
1461
|
+
f"- Kind: `{normalized_foundation.get('kind') or 'unknown'}`",
|
|
1462
|
+
f"- Ref: `{normalized_foundation.get('ref') or 'none'}`",
|
|
1463
|
+
f"- Branch: `{normalized_foundation.get('branch') or 'none'}`",
|
|
1464
|
+
f"- Reason: {foundation_reason.strip() or 'No explicit reason recorded.'}",
|
|
1465
|
+
]
|
|
1466
|
+
)
|
|
1467
|
+
else:
|
|
1468
|
+
body_lines.append("- Default current head foundation.")
|
|
1469
|
+
body_lines.extend(
|
|
1470
|
+
[
|
|
1471
|
+
"",
|
|
1472
|
+
"## Next Target",
|
|
1473
|
+
"",
|
|
1474
|
+
next_target.strip() or "experiment",
|
|
1475
|
+
"",
|
|
1476
|
+
]
|
|
1477
|
+
)
|
|
1478
|
+
return dump_markdown_document(metadata, "\n".join(body_lines).rstrip() + "\n")
|
|
1094
1479
|
|
|
1095
|
-
def
|
|
1480
|
+
def _build_candidate_idea_draft_markdown(
|
|
1096
1481
|
self,
|
|
1097
|
-
quest_root: Path,
|
|
1098
|
-
baseline_root_rel_path: str | None,
|
|
1099
1482
|
*,
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1483
|
+
idea_id: str,
|
|
1484
|
+
quest_id: str,
|
|
1485
|
+
title: str,
|
|
1486
|
+
problem: str,
|
|
1487
|
+
hypothesis: str,
|
|
1488
|
+
mechanism: str,
|
|
1489
|
+
expected_gain: str,
|
|
1490
|
+
risks: list[str],
|
|
1491
|
+
evidence_paths: list[str],
|
|
1492
|
+
decision_reason: str,
|
|
1493
|
+
next_target: str,
|
|
1494
|
+
candidate_root: Path,
|
|
1495
|
+
method_brief: str = "",
|
|
1496
|
+
selection_scores: dict[str, Any] | None = None,
|
|
1497
|
+
mechanism_family: str = "",
|
|
1498
|
+
change_layer: str = "",
|
|
1499
|
+
source_lens: str = "",
|
|
1500
|
+
foundation_ref: dict[str, Any] | None = None,
|
|
1501
|
+
foundation_reason: str = "",
|
|
1502
|
+
lineage_intent: str | None = None,
|
|
1503
|
+
draft_markdown: str = "",
|
|
1504
|
+
) -> str:
|
|
1505
|
+
normalized_foundation = dict(foundation_ref or {})
|
|
1506
|
+
normalized_lineage_intent = str(lineage_intent or "").strip().lower() or None
|
|
1507
|
+
normalized_method_brief = str(method_brief or "").strip()
|
|
1508
|
+
normalized_selection_scores = self._normalize_selection_scores(selection_scores)
|
|
1509
|
+
normalized_mechanism_family = str(mechanism_family or "").strip() or None
|
|
1510
|
+
normalized_change_layer = str(change_layer or "").strip() or None
|
|
1511
|
+
normalized_source_lens = str(source_lens or "").strip() or None
|
|
1512
|
+
metadata = {
|
|
1513
|
+
"id": f"{idea_id}-candidate-draft",
|
|
1514
|
+
"type": "ideas",
|
|
1515
|
+
"kind": "idea_candidate_draft",
|
|
1516
|
+
"title": f"{title} Candidate Draft",
|
|
1517
|
+
"idea_id": idea_id,
|
|
1518
|
+
"quest_id": quest_id,
|
|
1519
|
+
"scope": "quest",
|
|
1520
|
+
"submission_mode": "candidate",
|
|
1521
|
+
"candidate_root": str(candidate_root),
|
|
1522
|
+
"next_target": next_target,
|
|
1523
|
+
"method_brief": normalized_method_brief or None,
|
|
1524
|
+
"selection_scores": normalized_selection_scores or None,
|
|
1525
|
+
"mechanism_family": normalized_mechanism_family,
|
|
1526
|
+
"change_layer": normalized_change_layer,
|
|
1527
|
+
"source_lens": normalized_source_lens,
|
|
1528
|
+
"foundation_ref": normalized_foundation or None,
|
|
1529
|
+
"foundation_reason": foundation_reason.strip() or None,
|
|
1530
|
+
"lineage_intent": normalized_lineage_intent,
|
|
1531
|
+
"created_at": utc_now(),
|
|
1532
|
+
"updated_at": utc_now(),
|
|
1533
|
+
"tags": [
|
|
1534
|
+
"idea-candidate-draft",
|
|
1535
|
+
f"next:{next_target}",
|
|
1536
|
+
*( [f"lineage:{normalized_lineage_intent}"] if normalized_lineage_intent else []),
|
|
1537
|
+
],
|
|
1538
|
+
}
|
|
1539
|
+
body = str(draft_markdown or "").strip()
|
|
1540
|
+
if not body:
|
|
1541
|
+
risk_lines = "\n".join(f"- {item}" for item in risks) if risks else "- None recorded yet."
|
|
1542
|
+
evidence_lines = "\n".join(f"- `{item}`" for item in evidence_paths) if evidence_paths else "- None recorded yet."
|
|
1543
|
+
selection_score_lines = (
|
|
1544
|
+
"\n".join(self._selection_score_lines(normalized_selection_scores))
|
|
1545
|
+
if normalized_selection_scores
|
|
1546
|
+
else "- Not recorded"
|
|
1547
|
+
)
|
|
1548
|
+
foundation_label = (
|
|
1549
|
+
normalized_foundation.get("label")
|
|
1550
|
+
or normalized_foundation.get("branch")
|
|
1551
|
+
or normalized_foundation.get("ref")
|
|
1552
|
+
or "current head"
|
|
1553
|
+
)
|
|
1554
|
+
body = "\n".join(
|
|
1555
|
+
[
|
|
1556
|
+
f"# {title}",
|
|
1557
|
+
"",
|
|
1558
|
+
"## Executive Summary",
|
|
1559
|
+
"",
|
|
1560
|
+
decision_reason.strip() or "This candidate draft records a possible optimization line before promotion.",
|
|
1561
|
+
"",
|
|
1562
|
+
"## Limitation / Bottleneck",
|
|
1563
|
+
"",
|
|
1564
|
+
problem.strip() or "TBD",
|
|
1565
|
+
"",
|
|
1566
|
+
"## Selected Claim",
|
|
1567
|
+
"",
|
|
1568
|
+
hypothesis.strip() or "TBD",
|
|
1569
|
+
"",
|
|
1570
|
+
"## Theory and Method",
|
|
1571
|
+
"",
|
|
1572
|
+
mechanism.strip() or "TBD",
|
|
1573
|
+
"",
|
|
1574
|
+
"## Method Brief",
|
|
1575
|
+
"",
|
|
1576
|
+
normalized_method_brief or "Not recorded",
|
|
1577
|
+
"",
|
|
1578
|
+
"## Selection Scores",
|
|
1579
|
+
"",
|
|
1580
|
+
selection_score_lines,
|
|
1581
|
+
"",
|
|
1582
|
+
"## Diversity Tags",
|
|
1583
|
+
"",
|
|
1584
|
+
f"- Mechanism family: {normalized_mechanism_family or 'Not recorded'}",
|
|
1585
|
+
f"- Change layer: {normalized_change_layer or 'Not recorded'}",
|
|
1586
|
+
f"- Source lens: {normalized_source_lens or 'Not recorded'}",
|
|
1587
|
+
"",
|
|
1588
|
+
"## Code-Level Change Plan",
|
|
1589
|
+
"",
|
|
1590
|
+
mechanism.strip() or "TBD",
|
|
1591
|
+
"",
|
|
1592
|
+
"## Evaluation / Falsification Plan",
|
|
1593
|
+
"",
|
|
1594
|
+
expected_gain.strip() or "TBD",
|
|
1595
|
+
"",
|
|
1596
|
+
"## Risks / Caveats / Implementation Notes",
|
|
1597
|
+
"",
|
|
1598
|
+
risk_lines,
|
|
1599
|
+
"",
|
|
1600
|
+
"## Evidence / References",
|
|
1601
|
+
"",
|
|
1602
|
+
evidence_lines,
|
|
1603
|
+
"",
|
|
1604
|
+
"## Foundation Choice",
|
|
1605
|
+
"",
|
|
1606
|
+
f"- Lineage intent: `{normalized_lineage_intent or 'manual'}`",
|
|
1607
|
+
f"- Foundation: `{foundation_label}`",
|
|
1608
|
+
f"- Reason: {foundation_reason.strip() or 'Use the current active foundation.'}",
|
|
1609
|
+
"",
|
|
1610
|
+
"## Next Target",
|
|
1611
|
+
"",
|
|
1612
|
+
next_target.strip() or "experiment",
|
|
1613
|
+
"",
|
|
1614
|
+
]
|
|
1615
|
+
)
|
|
1616
|
+
return dump_markdown_document(metadata, body.rstrip() + "\n")
|
|
1617
|
+
|
|
1618
|
+
@staticmethod
|
|
1619
|
+
def _normalize_selection_scores(value: object) -> dict[str, Any] | None:
|
|
1620
|
+
if not isinstance(value, dict):
|
|
1621
|
+
return None
|
|
1622
|
+
normalized: dict[str, Any] = {}
|
|
1623
|
+
for raw_key, raw_value in value.items():
|
|
1624
|
+
key = str(raw_key or "").strip()
|
|
1625
|
+
if not key:
|
|
1626
|
+
continue
|
|
1627
|
+
if isinstance(raw_value, bool):
|
|
1628
|
+
normalized[key] = raw_value
|
|
1629
|
+
continue
|
|
1630
|
+
numeric = to_number(raw_value)
|
|
1631
|
+
if numeric is not None:
|
|
1632
|
+
normalized[key] = int(numeric) if float(numeric).is_integer() else float(numeric)
|
|
1633
|
+
continue
|
|
1634
|
+
text = str(raw_value or "").strip()
|
|
1635
|
+
if text:
|
|
1636
|
+
normalized[key] = text
|
|
1637
|
+
return normalized or None
|
|
1638
|
+
|
|
1639
|
+
@staticmethod
|
|
1640
|
+
def _selection_score_lines(selection_scores: dict[str, Any] | None) -> list[str]:
|
|
1641
|
+
if not selection_scores:
|
|
1642
|
+
return ["- Not recorded"]
|
|
1643
|
+
lines: list[str] = []
|
|
1644
|
+
for key, value in selection_scores.items():
|
|
1645
|
+
if isinstance(value, float):
|
|
1646
|
+
rendered = f"{value:.4f}".rstrip("0").rstrip(".")
|
|
1647
|
+
else:
|
|
1648
|
+
rendered = str(value)
|
|
1649
|
+
lines.append(f"- {key}: {rendered}")
|
|
1650
|
+
return lines
|
|
1651
|
+
|
|
1652
|
+
def _analysis_manifest_path(self, quest_root: Path, campaign_id: str) -> Path:
|
|
1653
|
+
return ensure_dir(quest_root / ".ds" / "analysis_campaigns") / f"{campaign_id}.json"
|
|
1654
|
+
|
|
1655
|
+
def _read_analysis_manifest(self, quest_root: Path, campaign_id: str) -> dict[str, Any]:
|
|
1656
|
+
path = self._analysis_manifest_path(quest_root, campaign_id)
|
|
1657
|
+
payload = read_json(path, {})
|
|
1658
|
+
if not isinstance(payload, dict) or not payload:
|
|
1659
|
+
raise FileNotFoundError(f"Unknown analysis campaign `{campaign_id}`.")
|
|
1660
|
+
return payload
|
|
1661
|
+
|
|
1662
|
+
def _write_analysis_manifest(self, quest_root: Path, campaign_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
1663
|
+
path = self._analysis_manifest_path(quest_root, campaign_id)
|
|
1664
|
+
normalized = {**payload, "campaign_id": campaign_id, "updated_at": utc_now()}
|
|
1665
|
+
write_json(path, normalized)
|
|
1666
|
+
return normalized
|
|
1667
|
+
|
|
1668
|
+
def _analysis_baseline_inventory_path(self, quest_root: Path) -> Path:
|
|
1669
|
+
return ensure_dir(quest_root / "artifacts" / "baselines") / "analysis_inventory.json"
|
|
1670
|
+
|
|
1671
|
+
def _read_analysis_baseline_inventory(self, quest_root: Path) -> dict[str, Any]:
|
|
1672
|
+
path = self._analysis_baseline_inventory_path(quest_root)
|
|
1673
|
+
payload = read_json(path, {})
|
|
1674
|
+
if not isinstance(payload, dict):
|
|
1675
|
+
payload = {}
|
|
1676
|
+
entries = payload.get("entries") if isinstance(payload.get("entries"), list) else []
|
|
1677
|
+
return {
|
|
1678
|
+
"schema_version": 1,
|
|
1679
|
+
"entries": [dict(item) for item in entries if isinstance(item, dict)],
|
|
1680
|
+
"updated_at": payload.get("updated_at"),
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
def _write_analysis_baseline_inventory(self, quest_root: Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
1684
|
+
path = self._analysis_baseline_inventory_path(quest_root)
|
|
1685
|
+
normalized_entries = payload.get("entries") if isinstance(payload.get("entries"), list) else []
|
|
1686
|
+
normalized = {
|
|
1687
|
+
"schema_version": 1,
|
|
1688
|
+
"entries": [dict(item) for item in normalized_entries if isinstance(item, dict)],
|
|
1689
|
+
"updated_at": utc_now(),
|
|
1690
|
+
}
|
|
1691
|
+
write_json(path, normalized)
|
|
1692
|
+
return normalized
|
|
1693
|
+
|
|
1694
|
+
def _normalize_baseline_root_rel_path(
|
|
1695
|
+
self,
|
|
1696
|
+
quest_root: Path,
|
|
1697
|
+
baseline_root_rel_path: str | None,
|
|
1698
|
+
*,
|
|
1699
|
+
baseline_id: str | None = None,
|
|
1700
|
+
) -> tuple[str | None, str | None]:
|
|
1701
|
+
raw = str(baseline_root_rel_path or "").strip()
|
|
1702
|
+
if not raw:
|
|
1703
|
+
return None, None
|
|
1704
|
+
candidate = Path(raw)
|
|
1705
|
+
resolved = candidate.resolve() if candidate.is_absolute() else resolve_within(quest_root, raw)
|
|
1706
|
+
if not resolved.exists():
|
|
1108
1707
|
raise FileNotFoundError(f"Baseline root does not exist: {resolved}")
|
|
1109
1708
|
try:
|
|
1110
1709
|
relative = resolved.relative_to(quest_root.resolve()).as_posix()
|
|
@@ -1309,6 +1908,180 @@ class ArtifactService:
|
|
|
1309
1908
|
def _paper_bundle_manifest_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
|
|
1310
1909
|
return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "paper_bundle_manifest.json"
|
|
1311
1910
|
|
|
1911
|
+
def _paper_evidence_ledger_path(self, quest_root: Path) -> Path:
|
|
1912
|
+
return ensure_dir(quest_root / "paper") / "evidence_ledger.json"
|
|
1913
|
+
|
|
1914
|
+
def _paper_evidence_ledger_markdown_path(self, quest_root: Path) -> Path:
|
|
1915
|
+
return ensure_dir(quest_root / "paper") / "evidence_ledger.md"
|
|
1916
|
+
|
|
1917
|
+
def _paper_experiment_matrix_json_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
|
|
1918
|
+
return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "paper_experiment_matrix.json"
|
|
1919
|
+
|
|
1920
|
+
def _paper_line_state_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
|
|
1921
|
+
return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "paper_line_state.json"
|
|
1922
|
+
|
|
1923
|
+
def _paper_outline_root(
|
|
1924
|
+
self,
|
|
1925
|
+
quest_root: Path,
|
|
1926
|
+
*,
|
|
1927
|
+
workspace_root: Path | None = None,
|
|
1928
|
+
create: bool = False,
|
|
1929
|
+
) -> Path:
|
|
1930
|
+
root = self._paper_root(quest_root, workspace_root=workspace_root, create=create) / "outline"
|
|
1931
|
+
return ensure_dir(root) if create else root
|
|
1932
|
+
|
|
1933
|
+
def _paper_outline_manifest_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
|
|
1934
|
+
return self._paper_outline_root(quest_root, workspace_root=workspace_root, create=True) / "manifest.json"
|
|
1935
|
+
|
|
1936
|
+
def _paper_outline_sections_root(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
|
|
1937
|
+
return ensure_dir(self._paper_outline_root(quest_root, workspace_root=workspace_root, create=True) / "sections")
|
|
1938
|
+
|
|
1939
|
+
def _paper_outline_section_dir(
|
|
1940
|
+
self,
|
|
1941
|
+
quest_root: Path,
|
|
1942
|
+
section_id: str,
|
|
1943
|
+
*,
|
|
1944
|
+
workspace_root: Path | None = None,
|
|
1945
|
+
) -> Path:
|
|
1946
|
+
return ensure_dir(self._paper_outline_sections_root(quest_root, workspace_root=workspace_root) / section_id)
|
|
1947
|
+
|
|
1948
|
+
def _paper_active_sync_roots(self, quest_root: Path, *, workspace_root: Path | None = None) -> list[Path]:
|
|
1949
|
+
roots: list[Path] = []
|
|
1950
|
+
seen: set[str] = set()
|
|
1951
|
+
base_roots = [quest_root] if workspace_root is None else [workspace_root, quest_root]
|
|
1952
|
+
for root in base_roots:
|
|
1953
|
+
paper_root = root / "paper"
|
|
1954
|
+
key = str(paper_root.resolve(strict=False))
|
|
1955
|
+
if key in seen:
|
|
1956
|
+
continue
|
|
1957
|
+
seen.add(key)
|
|
1958
|
+
roots.append(ensure_dir(paper_root))
|
|
1959
|
+
return roots
|
|
1960
|
+
|
|
1961
|
+
def _paper_sync_roots(self, quest_root: Path) -> list[Path]:
|
|
1962
|
+
roots: list[Path] = []
|
|
1963
|
+
seen: set[str] = set()
|
|
1964
|
+
for workspace_root in self.quest_service.workspace_roots(quest_root):
|
|
1965
|
+
paper_root = workspace_root / "paper"
|
|
1966
|
+
if not paper_root.exists() or not paper_root.is_dir():
|
|
1967
|
+
continue
|
|
1968
|
+
key = str(paper_root.resolve())
|
|
1969
|
+
if key in seen:
|
|
1970
|
+
continue
|
|
1971
|
+
seen.add(key)
|
|
1972
|
+
roots.append(paper_root)
|
|
1973
|
+
canonical = ensure_dir(quest_root / "paper")
|
|
1974
|
+
canonical_key = str(canonical.resolve())
|
|
1975
|
+
if canonical_key not in seen:
|
|
1976
|
+
roots.append(canonical)
|
|
1977
|
+
return roots
|
|
1978
|
+
|
|
1979
|
+
def _paper_line_id(
|
|
1980
|
+
self,
|
|
1981
|
+
*,
|
|
1982
|
+
paper_branch: str | None,
|
|
1983
|
+
outline_id: str | None,
|
|
1984
|
+
source_run_id: str | None,
|
|
1985
|
+
) -> str:
|
|
1986
|
+
seed = "::".join(
|
|
1987
|
+
[
|
|
1988
|
+
str(paper_branch or "").strip() or "paper",
|
|
1989
|
+
str(outline_id or "").strip() or "outline",
|
|
1990
|
+
str(source_run_id or "").strip() or "run",
|
|
1991
|
+
]
|
|
1992
|
+
)
|
|
1993
|
+
return slugify(seed, "paper-line")
|
|
1994
|
+
|
|
1995
|
+
@staticmethod
|
|
1996
|
+
def _paper_ready_status(status: object) -> bool:
|
|
1997
|
+
normalized = str(status or "").strip().lower()
|
|
1998
|
+
return normalized in {"ready", "completed", "analyzed", "written", "recorded", "supported"}
|
|
1999
|
+
|
|
2000
|
+
def _normalize_outline_evidence_contract(self, payload: object) -> dict[str, Any]:
|
|
2001
|
+
if not isinstance(payload, dict):
|
|
2002
|
+
payload = {}
|
|
2003
|
+
return {
|
|
2004
|
+
"main_text_items_must_be_ready": bool(payload.get("main_text_items_must_be_ready", True)),
|
|
2005
|
+
"appendix_items_may_be_ready_or_reference_only": bool(
|
|
2006
|
+
payload.get("appendix_items_may_be_ready_or_reference_only", True)
|
|
2007
|
+
),
|
|
2008
|
+
"record_results_back_into_outline": bool(payload.get("record_results_back_into_outline", True)),
|
|
2009
|
+
"result_table_required": bool(payload.get("result_table_required", True)),
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
def _normalize_outline_result_table(self, values: object) -> list[dict[str, Any]]:
|
|
2013
|
+
rows: list[dict[str, Any]] = []
|
|
2014
|
+
for raw in values or [] if isinstance(values, list) else []:
|
|
2015
|
+
if not isinstance(raw, dict):
|
|
2016
|
+
continue
|
|
2017
|
+
row = {
|
|
2018
|
+
"item_id": str(raw.get("item_id") or "").strip() or None,
|
|
2019
|
+
"title": str(raw.get("title") or "").strip() or None,
|
|
2020
|
+
"kind": str(raw.get("kind") or "").strip() or None,
|
|
2021
|
+
"paper_role": str(raw.get("paper_role") or raw.get("paper_placement") or "").strip() or None,
|
|
2022
|
+
"status": str(raw.get("status") or "").strip() or None,
|
|
2023
|
+
"claim_links": self._normalize_string_list(raw.get("claim_links")),
|
|
2024
|
+
"setup_note": str(raw.get("setup_note") or raw.get("setup") or "").strip() or None,
|
|
2025
|
+
"metric_summary": str(raw.get("metric_summary") or "").strip() or None,
|
|
2026
|
+
"result_summary": str(raw.get("result_summary") or "").strip() or None,
|
|
2027
|
+
"impact_summary": str(raw.get("impact_summary") or raw.get("claim_impact") or "").strip() or None,
|
|
2028
|
+
"source_paths": self._normalize_string_list(raw.get("source_paths")),
|
|
2029
|
+
"updated_at": str(raw.get("updated_at") or "").strip() or None,
|
|
2030
|
+
}
|
|
2031
|
+
if row["item_id"] or row["title"] or row["result_summary"]:
|
|
2032
|
+
rows.append(row)
|
|
2033
|
+
return rows
|
|
2034
|
+
|
|
2035
|
+
def _normalize_outline_sections(
|
|
2036
|
+
self,
|
|
2037
|
+
values: object,
|
|
2038
|
+
*,
|
|
2039
|
+
experimental_designs: list[str] | None = None,
|
|
2040
|
+
) -> list[dict[str, Any]]:
|
|
2041
|
+
sections: list[dict[str, Any]] = []
|
|
2042
|
+
if isinstance(values, list):
|
|
2043
|
+
for index, raw in enumerate(values, start=1):
|
|
2044
|
+
if not isinstance(raw, dict):
|
|
2045
|
+
continue
|
|
2046
|
+
title = str(raw.get("title") or raw.get("name") or raw.get("section_id") or "").strip()
|
|
2047
|
+
section_id = str(raw.get("section_id") or "").strip() or slugify(title or f"section-{index}", f"section-{index}")
|
|
2048
|
+
if not title:
|
|
2049
|
+
title = section_id
|
|
2050
|
+
sections.append(
|
|
2051
|
+
{
|
|
2052
|
+
"section_id": section_id,
|
|
2053
|
+
"title": title,
|
|
2054
|
+
"paper_role": str(raw.get("paper_role") or raw.get("paper_placement") or "main_text").strip() or "main_text",
|
|
2055
|
+
"claims": self._normalize_string_list(raw.get("claims")),
|
|
2056
|
+
"required_items": self._normalize_string_list(raw.get("required_items")),
|
|
2057
|
+
"optional_items": self._normalize_string_list(raw.get("optional_items")),
|
|
2058
|
+
"status": str(raw.get("status") or "planned").strip() or "planned",
|
|
2059
|
+
"note": str(raw.get("note") or "").strip() or None,
|
|
2060
|
+
"result_table": self._normalize_outline_result_table(raw.get("result_table")),
|
|
2061
|
+
}
|
|
2062
|
+
)
|
|
2063
|
+
if sections:
|
|
2064
|
+
return sections
|
|
2065
|
+
generated: list[dict[str, Any]] = []
|
|
2066
|
+
for index, item in enumerate(experimental_designs or [], start=1):
|
|
2067
|
+
title = str(item or "").strip()
|
|
2068
|
+
if not title:
|
|
2069
|
+
continue
|
|
2070
|
+
generated.append(
|
|
2071
|
+
{
|
|
2072
|
+
"section_id": slugify(title, f"section-{index}"),
|
|
2073
|
+
"title": title,
|
|
2074
|
+
"paper_role": "main_text",
|
|
2075
|
+
"claims": [],
|
|
2076
|
+
"required_items": [],
|
|
2077
|
+
"optional_items": [],
|
|
2078
|
+
"status": "planned",
|
|
2079
|
+
"note": None,
|
|
2080
|
+
"result_table": [],
|
|
2081
|
+
}
|
|
2082
|
+
)
|
|
2083
|
+
return generated
|
|
2084
|
+
|
|
1312
2085
|
def _paper_baseline_inventory_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
|
|
1313
2086
|
return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "baseline_inventory.json"
|
|
1314
2087
|
|
|
@@ -1549,7 +2322,7 @@ class ArtifactService:
|
|
|
1549
2322
|
suffix = path.stem.removeprefix("outline-")
|
|
1550
2323
|
if suffix.isdigit():
|
|
1551
2324
|
max_index = max(max_index, int(suffix))
|
|
1552
|
-
selected_outline =
|
|
2325
|
+
_, selected_outline = self._read_selected_outline_record(quest_root)
|
|
1553
2326
|
selected_id = str((selected_outline or {}).get("outline_id") or "").strip()
|
|
1554
2327
|
if selected_id.startswith("outline-") and selected_id.removeprefix("outline-").isdigit():
|
|
1555
2328
|
max_index = max(max_index, int(selected_id.removeprefix("outline-")))
|
|
@@ -1582,12 +2355,19 @@ class ArtifactService:
|
|
|
1582
2355
|
continue
|
|
1583
2356
|
normalized_items.append(
|
|
1584
2357
|
{
|
|
2358
|
+
"exp_id": str(raw.get("exp_id") or "").strip() or None,
|
|
1585
2359
|
"todo_id": str(raw.get("todo_id") or raw.get("slice_id") or "").strip() or None,
|
|
1586
2360
|
"slice_id": str(raw.get("slice_id") or "").strip() or None,
|
|
1587
2361
|
"title": str(raw.get("title") or "").strip() or None,
|
|
1588
2362
|
"status": str(raw.get("status") or "pending").strip() or "pending",
|
|
1589
2363
|
"research_question": str(raw.get("research_question") or "").strip() or None,
|
|
1590
2364
|
"experimental_design": str(raw.get("experimental_design") or "").strip() or None,
|
|
2365
|
+
"tier": str(raw.get("tier") or "").strip() or None,
|
|
2366
|
+
"paper_placement": str(raw.get("paper_placement") or "").strip() or None,
|
|
2367
|
+
"paper_role": str(raw.get("paper_role") or raw.get("paper_placement") or "").strip() or None,
|
|
2368
|
+
"section_id": str(raw.get("section_id") or "").strip() or None,
|
|
2369
|
+
"item_id": str(raw.get("item_id") or raw.get("exp_id") or raw.get("slice_id") or "").strip() or None,
|
|
2370
|
+
"claim_links": self._normalize_string_list(raw.get("claim_links")),
|
|
1591
2371
|
"completion_condition": str(raw.get("completion_condition") or "").strip() or None,
|
|
1592
2372
|
"why_now": str(raw.get("why_now") or "").strip() or None,
|
|
1593
2373
|
"success_criteria": str(raw.get("success_criteria") or "").strip() or None,
|
|
@@ -1598,45 +2378,1023 @@ class ArtifactService:
|
|
|
1598
2378
|
)
|
|
1599
2379
|
return normalized_items
|
|
1600
2380
|
|
|
1601
|
-
def _normalize_paper_outline_record(
|
|
2381
|
+
def _normalize_paper_outline_record(
|
|
2382
|
+
self,
|
|
2383
|
+
*,
|
|
2384
|
+
outline_id: str,
|
|
2385
|
+
title: str | None,
|
|
2386
|
+
note: str | None,
|
|
2387
|
+
story: str | None,
|
|
2388
|
+
ten_questions: list[object] | None,
|
|
2389
|
+
detailed_outline: dict[str, Any] | None,
|
|
2390
|
+
review_result: str | None,
|
|
2391
|
+
status: str,
|
|
2392
|
+
created_at: str | None = None,
|
|
2393
|
+
) -> dict[str, Any]:
|
|
2394
|
+
normalized_detailed = dict(detailed_outline or {})
|
|
2395
|
+
experimental_designs = self._normalize_string_list(normalized_detailed.get("experimental_designs"))
|
|
2396
|
+
sections = self._normalize_outline_sections(
|
|
2397
|
+
normalized_detailed.get("sections"),
|
|
2398
|
+
experimental_designs=experimental_designs,
|
|
2399
|
+
)
|
|
2400
|
+
resolved_title = (
|
|
2401
|
+
str(title or normalized_detailed.get("title") or outline_id).strip()
|
|
2402
|
+
or outline_id
|
|
2403
|
+
)
|
|
2404
|
+
record = {
|
|
2405
|
+
"schema_version": 1,
|
|
2406
|
+
"outline_id": outline_id,
|
|
2407
|
+
"status": status,
|
|
2408
|
+
"title": resolved_title,
|
|
2409
|
+
"note": str(note or "").strip() or None,
|
|
2410
|
+
"story": str(story or "").strip() or None,
|
|
2411
|
+
"ten_questions": self._normalize_string_list(ten_questions),
|
|
2412
|
+
"detailed_outline": {
|
|
2413
|
+
"title": str(normalized_detailed.get("title") or resolved_title).strip() or resolved_title,
|
|
2414
|
+
"abstract": str(normalized_detailed.get("abstract") or "").strip() or None,
|
|
2415
|
+
"research_questions": self._normalize_string_list(normalized_detailed.get("research_questions")),
|
|
2416
|
+
"methodology": str(normalized_detailed.get("methodology") or "").strip() or None,
|
|
2417
|
+
"experimental_designs": experimental_designs,
|
|
2418
|
+
"contributions": self._normalize_string_list(normalized_detailed.get("contributions")),
|
|
2419
|
+
},
|
|
2420
|
+
"sections": sections,
|
|
2421
|
+
"evidence_contract": self._normalize_outline_evidence_contract(
|
|
2422
|
+
normalized_detailed.get("evidence_contract")
|
|
2423
|
+
),
|
|
2424
|
+
"review_result": str(review_result or "").strip() or None,
|
|
2425
|
+
"created_at": created_at or utc_now(),
|
|
2426
|
+
"updated_at": utc_now(),
|
|
2427
|
+
}
|
|
2428
|
+
return record
|
|
2429
|
+
|
|
2430
|
+
def _render_outline_section_markdown(self, section: dict[str, Any]) -> str:
|
|
2431
|
+
lines = [
|
|
2432
|
+
f"# {str(section.get('title') or section.get('section_id') or 'Section').strip() or 'Section'}",
|
|
2433
|
+
"",
|
|
2434
|
+
f"- Section id: `{str(section.get('section_id') or 'unknown').strip() or 'unknown'}`",
|
|
2435
|
+
f"- Paper role: `{str(section.get('paper_role') or 'main_text').strip() or 'main_text'}`",
|
|
2436
|
+
f"- Status: `{str(section.get('status') or 'planned').strip() or 'planned'}`",
|
|
2437
|
+
"",
|
|
2438
|
+
"## Claims",
|
|
2439
|
+
"",
|
|
2440
|
+
]
|
|
2441
|
+
claims = self._normalize_string_list(section.get("claims"))
|
|
2442
|
+
lines.extend([f"- `{item}`" for item in claims] or ["- None recorded."])
|
|
2443
|
+
lines.extend(["", "## Required Items", ""])
|
|
2444
|
+
required_items = self._normalize_string_list(section.get("required_items"))
|
|
2445
|
+
lines.extend([f"- `{item}`" for item in required_items] or ["- None recorded."])
|
|
2446
|
+
lines.extend(["", "## Optional Items", ""])
|
|
2447
|
+
optional_items = self._normalize_string_list(section.get("optional_items"))
|
|
2448
|
+
lines.extend([f"- `{item}`" for item in optional_items] or ["- None recorded."])
|
|
2449
|
+
lines.extend(["", "## Note", "", str(section.get("note") or "Not recorded."), ""])
|
|
2450
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
2451
|
+
|
|
2452
|
+
def _render_outline_section_setup_markdown(self, section: dict[str, Any]) -> str:
|
|
2453
|
+
rows = [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)]
|
|
2454
|
+
setup_notes = []
|
|
2455
|
+
for row in rows:
|
|
2456
|
+
setup_text = str(row.get("setup_note") or row.get("setup") or "").strip()
|
|
2457
|
+
if setup_text and setup_text not in setup_notes:
|
|
2458
|
+
setup_notes.append(setup_text)
|
|
2459
|
+
lines = [
|
|
2460
|
+
f"# Setup · {str(section.get('title') or section.get('section_id') or 'Section').strip() or 'Section'}",
|
|
2461
|
+
"",
|
|
2462
|
+
"## Recorded Setup Notes",
|
|
2463
|
+
"",
|
|
2464
|
+
]
|
|
2465
|
+
lines.extend([f"- {item}" for item in setup_notes] or ["- None recorded yet."])
|
|
2466
|
+
lines.append("")
|
|
2467
|
+
return "\n".join(lines)
|
|
2468
|
+
|
|
2469
|
+
def _render_outline_section_findings_markdown(self, section: dict[str, Any]) -> str:
|
|
2470
|
+
rows = [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)]
|
|
2471
|
+
lines = [
|
|
2472
|
+
f"# Findings · {str(section.get('title') or section.get('section_id') or 'Section').strip() or 'Section'}",
|
|
2473
|
+
"",
|
|
2474
|
+
"## Result Highlights",
|
|
2475
|
+
"",
|
|
2476
|
+
]
|
|
2477
|
+
highlights = []
|
|
2478
|
+
for row in rows:
|
|
2479
|
+
result_summary = str(row.get("result_summary") or "").strip()
|
|
2480
|
+
metric_summary = str(row.get("metric_summary") or "").strip()
|
|
2481
|
+
title = str(row.get("title") or row.get("item_id") or "item").strip() or "item"
|
|
2482
|
+
if result_summary or metric_summary:
|
|
2483
|
+
suffix = f" ({metric_summary})" if metric_summary else ""
|
|
2484
|
+
highlights.append(f"- `{title}`: {result_summary or 'No summary recorded.'}{suffix}")
|
|
2485
|
+
lines.extend(highlights or ["- None recorded yet."])
|
|
2486
|
+
lines.append("")
|
|
2487
|
+
return "\n".join(lines)
|
|
2488
|
+
|
|
2489
|
+
def _render_outline_section_impact_markdown(self, section: dict[str, Any]) -> str:
|
|
2490
|
+
rows = [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)]
|
|
2491
|
+
impacts = []
|
|
2492
|
+
for row in rows:
|
|
2493
|
+
impact_text = str(row.get("impact_summary") or row.get("claim_impact") or "").strip()
|
|
2494
|
+
title = str(row.get("title") or row.get("item_id") or "item").strip() or "item"
|
|
2495
|
+
if impact_text:
|
|
2496
|
+
impacts.append(f"- `{title}`: {impact_text}")
|
|
2497
|
+
lines = [
|
|
2498
|
+
f"# Impact · {str(section.get('title') or section.get('section_id') or 'Section').strip() or 'Section'}",
|
|
2499
|
+
"",
|
|
2500
|
+
"## Claim Links",
|
|
2501
|
+
"",
|
|
2502
|
+
]
|
|
2503
|
+
claim_links = self._normalize_string_list(section.get("claims"))
|
|
2504
|
+
lines.extend([f"- `{item}`" for item in claim_links] or ["- None recorded."])
|
|
2505
|
+
lines.extend(["", "## Impact Notes", ""])
|
|
2506
|
+
lines.extend(impacts or ["- None recorded yet."])
|
|
2507
|
+
lines.append("")
|
|
2508
|
+
return "\n".join(lines)
|
|
2509
|
+
|
|
2510
|
+
def _outline_folder_exists(self, quest_root: Path, *, workspace_root: Path | None = None) -> bool:
|
|
2511
|
+
root = self._paper_outline_root(quest_root, workspace_root=workspace_root, create=False)
|
|
2512
|
+
return (root / "manifest.json").exists()
|
|
2513
|
+
|
|
2514
|
+
def _write_outline_folder_from_record(
|
|
2515
|
+
self,
|
|
2516
|
+
quest_root: Path,
|
|
2517
|
+
record: dict[str, Any],
|
|
2518
|
+
*,
|
|
2519
|
+
workspace_root: Path | None = None,
|
|
2520
|
+
) -> None:
|
|
2521
|
+
sections = self._normalize_outline_sections(
|
|
2522
|
+
record.get("sections"),
|
|
2523
|
+
experimental_designs=self._normalize_string_list(
|
|
2524
|
+
((record.get("detailed_outline") or {}) if isinstance(record.get("detailed_outline"), dict) else {}).get(
|
|
2525
|
+
"experimental_designs"
|
|
2526
|
+
)
|
|
2527
|
+
),
|
|
2528
|
+
)
|
|
2529
|
+
manifest = {
|
|
2530
|
+
"schema_version": 1,
|
|
2531
|
+
"outline_id": str(record.get("outline_id") or "").strip() or None,
|
|
2532
|
+
"status": str(record.get("status") or "selected").strip() or "selected",
|
|
2533
|
+
"title": str(record.get("title") or "").strip() or None,
|
|
2534
|
+
"note": str(record.get("note") or "").strip() or None,
|
|
2535
|
+
"story": str(record.get("story") or "").strip() or None,
|
|
2536
|
+
"ten_questions": self._normalize_string_list(record.get("ten_questions")),
|
|
2537
|
+
"detailed_outline": (
|
|
2538
|
+
dict(record.get("detailed_outline") or {})
|
|
2539
|
+
if isinstance(record.get("detailed_outline"), dict)
|
|
2540
|
+
else {}
|
|
2541
|
+
),
|
|
2542
|
+
"evidence_contract": self._normalize_outline_evidence_contract(record.get("evidence_contract")),
|
|
2543
|
+
"section_order": [
|
|
2544
|
+
str(section.get("section_id") or "").strip()
|
|
2545
|
+
for section in sections
|
|
2546
|
+
if str(section.get("section_id") or "").strip()
|
|
2547
|
+
],
|
|
2548
|
+
"sections": [
|
|
2549
|
+
{
|
|
2550
|
+
"section_id": str(section.get("section_id") or "").strip() or None,
|
|
2551
|
+
"title": str(section.get("title") or "").strip() or None,
|
|
2552
|
+
"paper_role": str(section.get("paper_role") or "main_text").strip() or "main_text",
|
|
2553
|
+
"claims": self._normalize_string_list(section.get("claims")),
|
|
2554
|
+
"required_items": self._normalize_string_list(section.get("required_items")),
|
|
2555
|
+
"optional_items": self._normalize_string_list(section.get("optional_items")),
|
|
2556
|
+
"status": str(section.get("status") or "planned").strip() or "planned",
|
|
2557
|
+
"note": str(section.get("note") or "").strip() or None,
|
|
2558
|
+
}
|
|
2559
|
+
for section in sections
|
|
2560
|
+
],
|
|
2561
|
+
"created_at": str(record.get("created_at") or "").strip() or utc_now(),
|
|
2562
|
+
"updated_at": utc_now(),
|
|
2563
|
+
}
|
|
2564
|
+
for paper_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
|
|
2565
|
+
outline_root = ensure_dir(paper_root / "outline")
|
|
2566
|
+
write_json(outline_root / "manifest.json", manifest)
|
|
2567
|
+
sections_root = ensure_dir(outline_root / "sections")
|
|
2568
|
+
expected: set[str] = set()
|
|
2569
|
+
for section in sections:
|
|
2570
|
+
section_id = str(section.get("section_id") or "").strip()
|
|
2571
|
+
if not section_id:
|
|
2572
|
+
continue
|
|
2573
|
+
expected.add(section_id)
|
|
2574
|
+
section_dir = ensure_dir(sections_root / section_id)
|
|
2575
|
+
write_text(section_dir / "section.md", self._render_outline_section_markdown(section))
|
|
2576
|
+
write_json(
|
|
2577
|
+
section_dir / "result_table.json",
|
|
2578
|
+
{
|
|
2579
|
+
"schema_version": 1,
|
|
2580
|
+
"section_id": section_id,
|
|
2581
|
+
"title": str(section.get("title") or section_id).strip() or section_id,
|
|
2582
|
+
"rows": [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)],
|
|
2583
|
+
"updated_at": utc_now(),
|
|
2584
|
+
},
|
|
2585
|
+
)
|
|
2586
|
+
write_text(section_dir / "experiment_setup.md", self._render_outline_section_setup_markdown(section))
|
|
2587
|
+
write_text(section_dir / "findings.md", self._render_outline_section_findings_markdown(section))
|
|
2588
|
+
write_text(section_dir / "impact.md", self._render_outline_section_impact_markdown(section))
|
|
2589
|
+
for existing in sorted(sections_root.iterdir()):
|
|
2590
|
+
if not existing.is_dir() or existing.name in expected:
|
|
2591
|
+
continue
|
|
2592
|
+
shutil.rmtree(existing)
|
|
2593
|
+
|
|
2594
|
+
def _read_outline_folder_to_record(
|
|
2595
|
+
self,
|
|
2596
|
+
quest_root: Path,
|
|
2597
|
+
*,
|
|
2598
|
+
workspace_root: Path | None = None,
|
|
2599
|
+
) -> dict[str, Any]:
|
|
2600
|
+
outline_root = self._paper_outline_root(quest_root, workspace_root=workspace_root, create=False)
|
|
2601
|
+
manifest_path = outline_root / "manifest.json"
|
|
2602
|
+
manifest = read_json(manifest_path, {})
|
|
2603
|
+
if not isinstance(manifest, dict) or not manifest:
|
|
2604
|
+
return {}
|
|
2605
|
+
manifest_sections = [dict(item) for item in (manifest.get("sections") or []) if isinstance(item, dict)]
|
|
2606
|
+
section_order = [
|
|
2607
|
+
str(item).strip() for item in (manifest.get("section_order") or []) if str(item).strip()
|
|
2608
|
+
]
|
|
2609
|
+
by_id = {
|
|
2610
|
+
str(item.get("section_id") or "").strip(): dict(item)
|
|
2611
|
+
for item in manifest_sections
|
|
2612
|
+
if str(item.get("section_id") or "").strip()
|
|
2613
|
+
}
|
|
2614
|
+
for section_id in section_order:
|
|
2615
|
+
by_id.setdefault(
|
|
2616
|
+
section_id,
|
|
2617
|
+
{
|
|
2618
|
+
"section_id": section_id,
|
|
2619
|
+
"title": section_id,
|
|
2620
|
+
"paper_role": "main_text",
|
|
2621
|
+
"claims": [],
|
|
2622
|
+
"required_items": [],
|
|
2623
|
+
"optional_items": [],
|
|
2624
|
+
"status": "planned",
|
|
2625
|
+
},
|
|
2626
|
+
)
|
|
2627
|
+
sections_root = outline_root / "sections"
|
|
2628
|
+
if sections_root.exists():
|
|
2629
|
+
for section_dir in sorted(sections_root.iterdir()):
|
|
2630
|
+
if not section_dir.is_dir():
|
|
2631
|
+
continue
|
|
2632
|
+
section_id = section_dir.name
|
|
2633
|
+
section = dict(by_id.get(section_id) or {})
|
|
2634
|
+
section.setdefault("section_id", section_id)
|
|
2635
|
+
section.setdefault("title", section_id)
|
|
2636
|
+
result_table_payload = read_json(section_dir / "result_table.json", {})
|
|
2637
|
+
rows = result_table_payload.get("rows") if isinstance(result_table_payload, dict) else []
|
|
2638
|
+
section["result_table"] = self._normalize_outline_result_table(rows)
|
|
2639
|
+
by_id[section_id] = section
|
|
2640
|
+
ordered_sections: list[dict[str, Any]] = []
|
|
2641
|
+
emitted: set[str] = set()
|
|
2642
|
+
for section_id in section_order:
|
|
2643
|
+
section = by_id.get(section_id)
|
|
2644
|
+
if section is None:
|
|
2645
|
+
continue
|
|
2646
|
+
ordered_sections.append(section)
|
|
2647
|
+
emitted.add(section_id)
|
|
2648
|
+
for section_id, section in by_id.items():
|
|
2649
|
+
if section_id in emitted:
|
|
2650
|
+
continue
|
|
2651
|
+
ordered_sections.append(section)
|
|
2652
|
+
record = {
|
|
2653
|
+
"schema_version": 1,
|
|
2654
|
+
"outline_id": str(manifest.get("outline_id") or "").strip() or None,
|
|
2655
|
+
"status": str(manifest.get("status") or "selected").strip() or "selected",
|
|
2656
|
+
"title": str(manifest.get("title") or "").strip() or None,
|
|
2657
|
+
"note": str(manifest.get("note") or "").strip() or None,
|
|
2658
|
+
"story": str(manifest.get("story") or "").strip() or None,
|
|
2659
|
+
"ten_questions": self._normalize_string_list(manifest.get("ten_questions")),
|
|
2660
|
+
"detailed_outline": (
|
|
2661
|
+
dict(manifest.get("detailed_outline") or {})
|
|
2662
|
+
if isinstance(manifest.get("detailed_outline"), dict)
|
|
2663
|
+
else {}
|
|
2664
|
+
),
|
|
2665
|
+
"sections": ordered_sections,
|
|
2666
|
+
"evidence_contract": self._normalize_outline_evidence_contract(manifest.get("evidence_contract")),
|
|
2667
|
+
"created_at": str(manifest.get("created_at") or "").strip() or None,
|
|
2668
|
+
"updated_at": str(manifest.get("updated_at") or "").strip() or utc_now(),
|
|
2669
|
+
}
|
|
2670
|
+
return record
|
|
2671
|
+
|
|
2672
|
+
def _read_selected_outline_record(
|
|
2673
|
+
self,
|
|
2674
|
+
quest_root: Path,
|
|
2675
|
+
*,
|
|
2676
|
+
workspace_root: Path | None = None,
|
|
2677
|
+
) -> tuple[Path | None, dict[str, Any]]:
|
|
2678
|
+
candidates: list[tuple[tuple[str, float], Path, dict[str, Any]]] = []
|
|
2679
|
+
for paper_root in self._paper_sync_roots(quest_root):
|
|
2680
|
+
outline_root = paper_root / "outline"
|
|
2681
|
+
manifest_path = outline_root / "manifest.json"
|
|
2682
|
+
if manifest_path.exists():
|
|
2683
|
+
record = self._read_outline_folder_to_record(quest_root, workspace_root=paper_root.parent)
|
|
2684
|
+
if record:
|
|
2685
|
+
candidates.append(
|
|
2686
|
+
(
|
|
2687
|
+
(
|
|
2688
|
+
str(record.get("updated_at") or record.get("created_at") or ""),
|
|
2689
|
+
manifest_path.stat().st_mtime if manifest_path.exists() else 0.0,
|
|
2690
|
+
),
|
|
2691
|
+
manifest_path,
|
|
2692
|
+
record,
|
|
2693
|
+
)
|
|
2694
|
+
)
|
|
2695
|
+
continue
|
|
2696
|
+
selected_outline_path = paper_root / "selected_outline.json"
|
|
2697
|
+
if not selected_outline_path.exists():
|
|
2698
|
+
continue
|
|
2699
|
+
payload = read_json(selected_outline_path, {})
|
|
2700
|
+
if not isinstance(payload, dict) or not payload:
|
|
2701
|
+
continue
|
|
2702
|
+
candidates.append(
|
|
2703
|
+
(
|
|
2704
|
+
(
|
|
2705
|
+
str(payload.get("updated_at") or payload.get("created_at") or ""),
|
|
2706
|
+
selected_outline_path.stat().st_mtime if selected_outline_path.exists() else 0.0,
|
|
2707
|
+
),
|
|
2708
|
+
selected_outline_path,
|
|
2709
|
+
payload,
|
|
2710
|
+
)
|
|
2711
|
+
)
|
|
2712
|
+
if not candidates:
|
|
2713
|
+
return None, {}
|
|
2714
|
+
candidates.sort(key=lambda item: item[0])
|
|
2715
|
+
_, path, payload = candidates[-1]
|
|
2716
|
+
return path, payload
|
|
2717
|
+
|
|
2718
|
+
def _compile_selected_outline_json(
|
|
2719
|
+
self,
|
|
2720
|
+
quest_root: Path,
|
|
2721
|
+
*,
|
|
2722
|
+
workspace_root: Path | None = None,
|
|
2723
|
+
) -> dict[str, Any]:
|
|
2724
|
+
record = self._read_outline_folder_to_record(quest_root, workspace_root=workspace_root)
|
|
2725
|
+
if not record:
|
|
2726
|
+
return {}
|
|
2727
|
+
for paper_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
|
|
2728
|
+
write_json(paper_root / "selected_outline.json", record)
|
|
2729
|
+
return record
|
|
2730
|
+
|
|
2731
|
+
def _read_paper_evidence_ledger(self, quest_root: Path) -> dict[str, Any]:
|
|
2732
|
+
candidates: list[tuple[tuple[str, float], dict[str, Any]]] = []
|
|
2733
|
+
for paper_root in self._paper_sync_roots(quest_root):
|
|
2734
|
+
path = paper_root / "evidence_ledger.json"
|
|
2735
|
+
if not path.exists():
|
|
2736
|
+
continue
|
|
2737
|
+
payload = read_json(path, {})
|
|
2738
|
+
if not isinstance(payload, dict) or not payload:
|
|
2739
|
+
continue
|
|
2740
|
+
candidates.append(
|
|
2741
|
+
(
|
|
2742
|
+
(
|
|
2743
|
+
str(payload.get("updated_at") or payload.get("created_at") or ""),
|
|
2744
|
+
path.stat().st_mtime if path.exists() else 0.0,
|
|
2745
|
+
),
|
|
2746
|
+
payload,
|
|
2747
|
+
)
|
|
2748
|
+
)
|
|
2749
|
+
if not candidates:
|
|
2750
|
+
return {
|
|
2751
|
+
"schema_version": 1,
|
|
2752
|
+
"selected_outline_ref": None,
|
|
2753
|
+
"items": [],
|
|
2754
|
+
"updated_at": utc_now(),
|
|
2755
|
+
}
|
|
2756
|
+
candidates.sort(key=lambda item: item[0])
|
|
2757
|
+
payload = candidates[-1][1]
|
|
2758
|
+
items = [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)]
|
|
2759
|
+
return {
|
|
2760
|
+
"schema_version": 1,
|
|
2761
|
+
"selected_outline_ref": str(payload.get("selected_outline_ref") or "").strip() or None,
|
|
2762
|
+
"items": items,
|
|
2763
|
+
"updated_at": str(payload.get("updated_at") or payload.get("created_at") or "").strip() or utc_now(),
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
def _paper_evidence_key_metrics(
|
|
2767
|
+
self,
|
|
2768
|
+
*,
|
|
2769
|
+
metric_rows: list[dict[str, Any]] | None = None,
|
|
2770
|
+
metrics_summary: dict[str, Any] | None = None,
|
|
2771
|
+
) -> list[dict[str, Any]]:
|
|
2772
|
+
rows: list[dict[str, Any]] = []
|
|
2773
|
+
seen: set[str] = set()
|
|
2774
|
+
for row in metric_rows or []:
|
|
2775
|
+
if not isinstance(row, dict):
|
|
2776
|
+
continue
|
|
2777
|
+
metric_id = str(row.get("metric_id") or row.get("name") or "").strip()
|
|
2778
|
+
if not metric_id or metric_id in seen:
|
|
2779
|
+
continue
|
|
2780
|
+
seen.add(metric_id)
|
|
2781
|
+
rows.append(
|
|
2782
|
+
{
|
|
2783
|
+
"metric_id": metric_id,
|
|
2784
|
+
"value": row.get("value"),
|
|
2785
|
+
"direction": str(row.get("direction") or "").strip() or None,
|
|
2786
|
+
"decimals": row.get("decimals"),
|
|
2787
|
+
}
|
|
2788
|
+
)
|
|
2789
|
+
if len(rows) >= 6:
|
|
2790
|
+
return rows
|
|
2791
|
+
for metric_id, value in (metrics_summary or {}).items():
|
|
2792
|
+
text = str(metric_id or "").strip()
|
|
2793
|
+
if not text or text in seen:
|
|
2794
|
+
continue
|
|
2795
|
+
seen.add(text)
|
|
2796
|
+
rows.append({"metric_id": text, "value": value, "direction": None, "decimals": None})
|
|
2797
|
+
if len(rows) >= 6:
|
|
2798
|
+
break
|
|
2799
|
+
return rows
|
|
2800
|
+
|
|
2801
|
+
def _paper_metric_summary_text(self, key_metrics: list[dict[str, Any]] | None) -> str | None:
|
|
2802
|
+
parts: list[str] = []
|
|
2803
|
+
for item in key_metrics or []:
|
|
2804
|
+
if not isinstance(item, dict):
|
|
2805
|
+
continue
|
|
2806
|
+
metric_id = str(item.get("metric_id") or "").strip()
|
|
2807
|
+
if not metric_id:
|
|
2808
|
+
continue
|
|
2809
|
+
parts.append(
|
|
2810
|
+
f"{metric_id}={self._format_metric_value(item.get('value'), item.get('decimals'))}"
|
|
2811
|
+
)
|
|
2812
|
+
return "; ".join(parts) if parts else None
|
|
2813
|
+
|
|
2814
|
+
def _render_paper_evidence_ledger_markdown(self, payload: dict[str, Any]) -> str:
|
|
2815
|
+
items = [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)]
|
|
2816
|
+
lines = [
|
|
2817
|
+
"# Paper Evidence Ledger",
|
|
2818
|
+
"",
|
|
2819
|
+
f"- Selected outline: `{str(payload.get('selected_outline_ref') or 'none').strip() or 'none'}`",
|
|
2820
|
+
f"- Item count: `{len(items)}`",
|
|
2821
|
+
f"- Updated at: `{str(payload.get('updated_at') or utc_now()).strip() or utc_now()}`",
|
|
2822
|
+
"",
|
|
2823
|
+
"| Item | Kind | Section | Role | Status | Metrics | Source |",
|
|
2824
|
+
"|---|---|---|---|---|---|---|",
|
|
2825
|
+
]
|
|
2826
|
+
for item in items:
|
|
2827
|
+
metrics_text = self._paper_metric_summary_text(
|
|
2828
|
+
[dict(metric) for metric in (item.get("key_metrics") or []) if isinstance(metric, dict)]
|
|
2829
|
+
) or "-"
|
|
2830
|
+
source_paths = [str(path).strip() for path in (item.get("source_paths") or []) if str(path).strip()]
|
|
2831
|
+
source_text = ", ".join(f"`{path}`" for path in source_paths[:2]) or "-"
|
|
2832
|
+
lines.append(
|
|
2833
|
+
"| "
|
|
2834
|
+
+ " | ".join(
|
|
2835
|
+
[
|
|
2836
|
+
f"`{str(item.get('item_id') or 'unknown').strip() or 'unknown'}`",
|
|
2837
|
+
str(item.get("kind") or "-"),
|
|
2838
|
+
str(item.get("section_id") or "-"),
|
|
2839
|
+
str(item.get("paper_role") or "-"),
|
|
2840
|
+
str(item.get("status") or "-"),
|
|
2841
|
+
metrics_text,
|
|
2842
|
+
source_text,
|
|
2843
|
+
]
|
|
2844
|
+
)
|
|
2845
|
+
+ " |"
|
|
2846
|
+
)
|
|
2847
|
+
if not items:
|
|
2848
|
+
lines.extend(["| - | - | - | - | - | - | - |", ""])
|
|
2849
|
+
else:
|
|
2850
|
+
lines.append("")
|
|
2851
|
+
for item in items:
|
|
2852
|
+
lines.extend(
|
|
2853
|
+
[
|
|
2854
|
+
f"## {str(item.get('item_id') or 'unknown').strip() or 'unknown'}",
|
|
2855
|
+
"",
|
|
2856
|
+
f"- Title: {str(item.get('title') or item.get('item_id') or 'Unknown').strip() or 'Unknown'}",
|
|
2857
|
+
f"- Kind: `{str(item.get('kind') or 'unknown').strip() or 'unknown'}`",
|
|
2858
|
+
f"- Section: `{str(item.get('section_id') or 'unmapped').strip() or 'unmapped'}`",
|
|
2859
|
+
f"- Role: `{str(item.get('paper_role') or 'unmapped').strip() or 'unmapped'}`",
|
|
2860
|
+
f"- Status: `{str(item.get('status') or 'unknown').strip() or 'unknown'}`",
|
|
2861
|
+
f"- Claims: {', '.join(str(value).strip() for value in (item.get('claim_links') or []) if str(value).strip()) or 'none'}",
|
|
2862
|
+
"",
|
|
2863
|
+
"### Setup",
|
|
2864
|
+
"",
|
|
2865
|
+
str(item.get("setup") or "Not recorded."),
|
|
2866
|
+
"",
|
|
2867
|
+
"### Result Summary",
|
|
2868
|
+
"",
|
|
2869
|
+
str(item.get("result_summary") or "Not recorded."),
|
|
2870
|
+
"",
|
|
2871
|
+
"### Source Paths",
|
|
2872
|
+
"",
|
|
2873
|
+
]
|
|
2874
|
+
)
|
|
2875
|
+
if source_paths := [str(path).strip() for path in (item.get("source_paths") or []) if str(path).strip()]:
|
|
2876
|
+
lines.extend([f"- `{path}`" for path in source_paths])
|
|
2877
|
+
else:
|
|
2878
|
+
lines.append("- None recorded.")
|
|
2879
|
+
lines.append("")
|
|
2880
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
2881
|
+
|
|
2882
|
+
def _write_paper_evidence_ledger(
|
|
2883
|
+
self,
|
|
2884
|
+
quest_root: Path,
|
|
2885
|
+
payload: dict[str, Any],
|
|
2886
|
+
*,
|
|
2887
|
+
workspace_root: Path | None = None,
|
|
2888
|
+
) -> dict[str, Any]:
|
|
2889
|
+
normalized = {
|
|
2890
|
+
"schema_version": 1,
|
|
2891
|
+
"selected_outline_ref": str(payload.get("selected_outline_ref") or "").strip() or None,
|
|
2892
|
+
"items": [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)],
|
|
2893
|
+
"updated_at": utc_now(),
|
|
2894
|
+
}
|
|
2895
|
+
markdown = self._render_paper_evidence_ledger_markdown(normalized)
|
|
2896
|
+
for paper_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
|
|
2897
|
+
write_json(paper_root / "evidence_ledger.json", normalized)
|
|
2898
|
+
write_text(paper_root / "evidence_ledger.md", markdown)
|
|
2899
|
+
return normalized
|
|
2900
|
+
|
|
2901
|
+
def _read_selected_outline_for_sync(
|
|
2902
|
+
self,
|
|
2903
|
+
quest_root: Path,
|
|
2904
|
+
*,
|
|
2905
|
+
workspace_root: Path | None = None,
|
|
2906
|
+
) -> tuple[Path | None, dict[str, Any]]:
|
|
2907
|
+
return self._read_selected_outline_record(quest_root, workspace_root=workspace_root)
|
|
2908
|
+
|
|
2909
|
+
def _write_selected_outline_sync(
|
|
2910
|
+
self,
|
|
2911
|
+
quest_root: Path,
|
|
2912
|
+
payload: dict[str, Any],
|
|
2913
|
+
*,
|
|
2914
|
+
workspace_root: Path | None = None,
|
|
2915
|
+
) -> None:
|
|
2916
|
+
outline_id = str(payload.get("outline_id") or "").strip()
|
|
2917
|
+
self._write_outline_folder_from_record(quest_root, payload, workspace_root=workspace_root)
|
|
2918
|
+
compiled = self._compile_selected_outline_json(quest_root, workspace_root=workspace_root) or dict(payload)
|
|
2919
|
+
canonical_path = ensure_dir(quest_root / "paper") / "selected_outline.json"
|
|
2920
|
+
write_json(canonical_path, compiled)
|
|
2921
|
+
for paper_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
|
|
2922
|
+
path = paper_root / "selected_outline.json"
|
|
2923
|
+
if path.resolve() == canonical_path.resolve():
|
|
2924
|
+
continue
|
|
2925
|
+
existing = read_json(path, {}) if path.exists() else {}
|
|
2926
|
+
if path.exists():
|
|
2927
|
+
existing_outline_id = str(existing.get("outline_id") or "").strip() if isinstance(existing, dict) else ""
|
|
2928
|
+
if existing_outline_id and existing_outline_id != outline_id:
|
|
2929
|
+
continue
|
|
2930
|
+
write_json(path, compiled)
|
|
2931
|
+
|
|
2932
|
+
def _outline_status_from_rows(self, section: dict[str, Any]) -> str:
|
|
2933
|
+
rows = [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)]
|
|
2934
|
+
by_item = {
|
|
2935
|
+
str(item.get("item_id") or "").strip(): str(item.get("status") or "").strip() or "pending"
|
|
2936
|
+
for item in rows
|
|
2937
|
+
if str(item.get("item_id") or "").strip()
|
|
2938
|
+
}
|
|
2939
|
+
required_items = self._normalize_string_list(section.get("required_items"))
|
|
2940
|
+
if required_items:
|
|
2941
|
+
ready_count = sum(1 for item_id in required_items if self._paper_ready_status(by_item.get(item_id)))
|
|
2942
|
+
present_count = sum(1 for item_id in required_items if item_id in by_item)
|
|
2943
|
+
if ready_count == len(required_items):
|
|
2944
|
+
return "ready"
|
|
2945
|
+
if ready_count > 0:
|
|
2946
|
+
return "partial"
|
|
2947
|
+
if present_count > 0:
|
|
2948
|
+
return "pending"
|
|
2949
|
+
return "planned"
|
|
2950
|
+
if any(self._paper_ready_status(item.get("status")) for item in rows):
|
|
2951
|
+
return "ready"
|
|
2952
|
+
if rows:
|
|
2953
|
+
return "pending"
|
|
2954
|
+
return str(section.get("status") or "planned").strip() or "planned"
|
|
2955
|
+
|
|
2956
|
+
def _sync_outline_sections(
|
|
2957
|
+
self,
|
|
2958
|
+
quest_root: Path,
|
|
2959
|
+
*,
|
|
2960
|
+
items: list[dict[str, Any]],
|
|
2961
|
+
workspace_root: Path | None = None,
|
|
2962
|
+
) -> dict[str, Any] | None:
|
|
2963
|
+
outline_path, record = self._read_selected_outline_for_sync(quest_root, workspace_root=workspace_root)
|
|
2964
|
+
if outline_path is None or not record:
|
|
2965
|
+
return None
|
|
2966
|
+
detailed_outline = (
|
|
2967
|
+
dict(record.get("detailed_outline") or {})
|
|
2968
|
+
if isinstance(record.get("detailed_outline"), dict)
|
|
2969
|
+
else {}
|
|
2970
|
+
)
|
|
2971
|
+
sections = self._normalize_outline_sections(
|
|
2972
|
+
record.get("sections"),
|
|
2973
|
+
experimental_designs=self._normalize_string_list(detailed_outline.get("experimental_designs")),
|
|
2974
|
+
)
|
|
2975
|
+
if not sections:
|
|
2976
|
+
sections = self._normalize_outline_sections([], experimental_designs=["Main Results"])
|
|
2977
|
+
by_id = {
|
|
2978
|
+
str(section.get("section_id") or "").strip(): dict(section)
|
|
2979
|
+
for section in sections
|
|
2980
|
+
if str(section.get("section_id") or "").strip()
|
|
2981
|
+
}
|
|
2982
|
+
for raw in items:
|
|
2983
|
+
if not isinstance(raw, dict):
|
|
2984
|
+
continue
|
|
2985
|
+
item_id = str(raw.get("item_id") or "").strip()
|
|
2986
|
+
if not item_id:
|
|
2987
|
+
continue
|
|
2988
|
+
section_id = str(raw.get("section_id") or "").strip() or "main-results"
|
|
2989
|
+
section = dict(by_id.get(section_id) or {})
|
|
2990
|
+
if not section:
|
|
2991
|
+
section = {
|
|
2992
|
+
"section_id": section_id,
|
|
2993
|
+
"title": str(raw.get("section_title") or raw.get("title") or section_id).strip() or section_id,
|
|
2994
|
+
"paper_role": str(raw.get("paper_role") or "main_text").strip() or "main_text",
|
|
2995
|
+
"claims": [],
|
|
2996
|
+
"required_items": [],
|
|
2997
|
+
"optional_items": [],
|
|
2998
|
+
"status": "planned",
|
|
2999
|
+
"note": None,
|
|
3000
|
+
"result_table": [],
|
|
3001
|
+
}
|
|
3002
|
+
claims = self._normalize_string_list(section.get("claims"))
|
|
3003
|
+
for claim in self._normalize_string_list(raw.get("claim_links")):
|
|
3004
|
+
if claim not in claims:
|
|
3005
|
+
claims.append(claim)
|
|
3006
|
+
section["claims"] = claims
|
|
3007
|
+
paper_role = str(raw.get("paper_role") or section.get("paper_role") or "main_text").strip() or "main_text"
|
|
3008
|
+
section["paper_role"] = paper_role
|
|
3009
|
+
required_items = self._normalize_string_list(section.get("required_items"))
|
|
3010
|
+
optional_items = self._normalize_string_list(section.get("optional_items"))
|
|
3011
|
+
target_list = required_items if paper_role == "main_text" else optional_items
|
|
3012
|
+
if item_id not in target_list:
|
|
3013
|
+
target_list.append(item_id)
|
|
3014
|
+
if paper_role == "main_text":
|
|
3015
|
+
optional_items = [value for value in optional_items if value != item_id]
|
|
3016
|
+
else:
|
|
3017
|
+
required_items = [value for value in required_items if value != item_id]
|
|
3018
|
+
section["required_items"] = required_items
|
|
3019
|
+
section["optional_items"] = optional_items
|
|
3020
|
+
row = {
|
|
3021
|
+
"item_id": item_id,
|
|
3022
|
+
"title": str(raw.get("title") or item_id).strip() or item_id,
|
|
3023
|
+
"kind": str(raw.get("kind") or "").strip() or None,
|
|
3024
|
+
"paper_role": paper_role,
|
|
3025
|
+
"status": str(raw.get("status") or "pending").strip() or "pending",
|
|
3026
|
+
"claim_links": self._normalize_string_list(raw.get("claim_links")),
|
|
3027
|
+
"setup_note": str(raw.get("setup_note") or raw.get("setup") or "").strip() or None,
|
|
3028
|
+
"metric_summary": str(raw.get("metric_summary") or "").strip() or None,
|
|
3029
|
+
"result_summary": str(raw.get("result_summary") or "").strip() or None,
|
|
3030
|
+
"impact_summary": str(raw.get("impact_summary") or raw.get("claim_impact") or "").strip() or None,
|
|
3031
|
+
"source_paths": self._normalize_string_list(raw.get("source_paths")),
|
|
3032
|
+
"updated_at": utc_now(),
|
|
3033
|
+
}
|
|
3034
|
+
existing_rows = [
|
|
3035
|
+
dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)
|
|
3036
|
+
]
|
|
3037
|
+
merged_rows: list[dict[str, Any]] = []
|
|
3038
|
+
replaced = False
|
|
3039
|
+
for existing in existing_rows:
|
|
3040
|
+
if str(existing.get("item_id") or "").strip() != item_id:
|
|
3041
|
+
merged_rows.append(existing)
|
|
3042
|
+
continue
|
|
3043
|
+
merged = dict(existing)
|
|
3044
|
+
for key, value in row.items():
|
|
3045
|
+
if value in (None, "", []):
|
|
3046
|
+
continue
|
|
3047
|
+
merged[key] = value
|
|
3048
|
+
merged_rows.append(merged)
|
|
3049
|
+
replaced = True
|
|
3050
|
+
if not replaced:
|
|
3051
|
+
merged_rows.append(row)
|
|
3052
|
+
section["result_table"] = merged_rows
|
|
3053
|
+
section["status"] = self._outline_status_from_rows(section)
|
|
3054
|
+
by_id[section_id] = section
|
|
3055
|
+
record["sections"] = list(by_id.values())
|
|
3056
|
+
record["evidence_contract"] = self._normalize_outline_evidence_contract(record.get("evidence_contract"))
|
|
3057
|
+
record["updated_at"] = utc_now()
|
|
3058
|
+
self._write_selected_outline_sync(quest_root, record, workspace_root=workspace_root)
|
|
3059
|
+
return record
|
|
3060
|
+
|
|
3061
|
+
@staticmethod
|
|
3062
|
+
def _paper_evidence_item_key(item: dict[str, Any]) -> tuple[str, str, str]:
|
|
3063
|
+
return (
|
|
3064
|
+
str(item.get("item_id") or "").strip(),
|
|
3065
|
+
str(item.get("campaign_id") or "").strip(),
|
|
3066
|
+
str(item.get("slice_id") or item.get("run_id") or "").strip(),
|
|
3067
|
+
)
|
|
3068
|
+
|
|
3069
|
+
def _upsert_paper_evidence_item(
|
|
3070
|
+
self,
|
|
3071
|
+
quest_root: Path,
|
|
3072
|
+
item: dict[str, Any],
|
|
3073
|
+
*,
|
|
3074
|
+
workspace_root: Path | None = None,
|
|
3075
|
+
) -> dict[str, Any]:
|
|
3076
|
+
ledger = self._read_paper_evidence_ledger(quest_root)
|
|
3077
|
+
items = [dict(entry) for entry in (ledger.get("items") or []) if isinstance(entry, dict)]
|
|
3078
|
+
key = self._paper_evidence_item_key(item)
|
|
3079
|
+
merged_items: list[dict[str, Any]] = []
|
|
3080
|
+
replaced = False
|
|
3081
|
+
for existing in items:
|
|
3082
|
+
if self._paper_evidence_item_key(existing) != key:
|
|
3083
|
+
merged_items.append(existing)
|
|
3084
|
+
continue
|
|
3085
|
+
merged = dict(existing)
|
|
3086
|
+
for field, value in item.items():
|
|
3087
|
+
if value in (None, "", []):
|
|
3088
|
+
continue
|
|
3089
|
+
merged[field] = value
|
|
3090
|
+
merged["updated_at"] = utc_now()
|
|
3091
|
+
merged_items.append(merged)
|
|
3092
|
+
replaced = True
|
|
3093
|
+
if not replaced:
|
|
3094
|
+
merged_items.append({**item, "created_at": utc_now(), "updated_at": utc_now()})
|
|
3095
|
+
merged_items.sort(
|
|
3096
|
+
key=lambda payload: (
|
|
3097
|
+
str(payload.get("section_id") or ""),
|
|
3098
|
+
str(payload.get("item_id") or ""),
|
|
3099
|
+
str(payload.get("updated_at") or ""),
|
|
3100
|
+
)
|
|
3101
|
+
)
|
|
3102
|
+
written = self._write_paper_evidence_ledger(
|
|
3103
|
+
quest_root,
|
|
3104
|
+
{
|
|
3105
|
+
"selected_outline_ref": item.get("selected_outline_ref") or ledger.get("selected_outline_ref"),
|
|
3106
|
+
"items": merged_items,
|
|
3107
|
+
},
|
|
3108
|
+
workspace_root=workspace_root,
|
|
3109
|
+
)
|
|
3110
|
+
metric_summary = self._paper_metric_summary_text(
|
|
3111
|
+
[dict(metric) for metric in (item.get("key_metrics") or []) if isinstance(metric, dict)]
|
|
3112
|
+
)
|
|
3113
|
+
self._sync_outline_sections(
|
|
3114
|
+
quest_root,
|
|
3115
|
+
items=[
|
|
3116
|
+
{
|
|
3117
|
+
"item_id": item.get("item_id"),
|
|
3118
|
+
"title": item.get("title"),
|
|
3119
|
+
"kind": item.get("kind"),
|
|
3120
|
+
"paper_role": item.get("paper_role"),
|
|
3121
|
+
"status": item.get("status"),
|
|
3122
|
+
"claim_links": item.get("claim_links"),
|
|
3123
|
+
"section_id": item.get("section_id"),
|
|
3124
|
+
"setup": item.get("setup"),
|
|
3125
|
+
"metric_summary": metric_summary,
|
|
3126
|
+
"result_summary": item.get("result_summary"),
|
|
3127
|
+
"claim_impact": item.get("claim_impact"),
|
|
3128
|
+
"source_paths": item.get("source_paths"),
|
|
3129
|
+
}
|
|
3130
|
+
],
|
|
3131
|
+
workspace_root=workspace_root,
|
|
3132
|
+
)
|
|
3133
|
+
self._write_paper_line_state(quest_root, workspace_root=workspace_root)
|
|
3134
|
+
return written
|
|
3135
|
+
|
|
3136
|
+
def _paper_bundle_gate_status(self, quest_root: Path, *, workspace_root: Path | None = None) -> dict[str, Any]:
|
|
3137
|
+
outline_path, selected_outline = self._read_selected_outline_for_sync(quest_root, workspace_root=workspace_root)
|
|
3138
|
+
selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
|
|
3139
|
+
detailed_outline = (
|
|
3140
|
+
dict(selected_outline.get("detailed_outline") or {})
|
|
3141
|
+
if isinstance(selected_outline.get("detailed_outline"), dict)
|
|
3142
|
+
else {}
|
|
3143
|
+
)
|
|
3144
|
+
sections = self._normalize_outline_sections(
|
|
3145
|
+
selected_outline.get("sections"),
|
|
3146
|
+
experimental_designs=self._normalize_string_list(detailed_outline.get("experimental_designs")),
|
|
3147
|
+
)
|
|
3148
|
+
ledger = self._read_paper_evidence_ledger(quest_root)
|
|
3149
|
+
ledger_items = [dict(item) for item in (ledger.get("items") or []) if isinstance(item, dict)]
|
|
3150
|
+
ledger_by_item = {
|
|
3151
|
+
str(item.get("item_id") or "").strip(): dict(item)
|
|
3152
|
+
for item in ledger_items
|
|
3153
|
+
if str(item.get("item_id") or "").strip()
|
|
3154
|
+
}
|
|
3155
|
+
unresolved_required_items: list[dict[str, Any]] = []
|
|
3156
|
+
ready_sections = 0
|
|
3157
|
+
for section in sections:
|
|
3158
|
+
required_items = self._normalize_string_list(section.get("required_items"))
|
|
3159
|
+
section_ready = True
|
|
3160
|
+
for item_id in required_items:
|
|
3161
|
+
ledger_item = ledger_by_item.get(item_id)
|
|
3162
|
+
if ledger_item is None or not self._paper_ready_status(ledger_item.get("status")):
|
|
3163
|
+
unresolved_required_items.append(
|
|
3164
|
+
{
|
|
3165
|
+
"section_id": section.get("section_id"),
|
|
3166
|
+
"section_title": section.get("title"),
|
|
3167
|
+
"item_id": item_id,
|
|
3168
|
+
"status": ledger_item.get("status") if isinstance(ledger_item, dict) else None,
|
|
3169
|
+
}
|
|
3170
|
+
)
|
|
3171
|
+
section_ready = False
|
|
3172
|
+
if section_ready and required_items:
|
|
3173
|
+
ready_sections += 1
|
|
3174
|
+
selected_outline_ref = str(selected_outline.get("outline_id") or ledger.get("selected_outline_ref") or "").strip() or None
|
|
3175
|
+
completed_analysis: list[dict[str, Any]] = []
|
|
3176
|
+
campaigns_root = quest_root / ".ds" / "analysis_campaigns"
|
|
3177
|
+
if campaigns_root.exists():
|
|
3178
|
+
for path in sorted(campaigns_root.glob("analysis-*.json")):
|
|
3179
|
+
manifest = read_json(path, {})
|
|
3180
|
+
if not isinstance(manifest, dict) or not manifest:
|
|
3181
|
+
continue
|
|
3182
|
+
manifest_outline_ref = str(manifest.get("selected_outline_ref") or "").strip() or None
|
|
3183
|
+
if selected_outline_ref and manifest_outline_ref != selected_outline_ref:
|
|
3184
|
+
continue
|
|
3185
|
+
for slice_item in manifest.get("slices") or []:
|
|
3186
|
+
if not isinstance(slice_item, dict):
|
|
3187
|
+
continue
|
|
3188
|
+
status = str(slice_item.get("status") or "").strip().lower()
|
|
3189
|
+
if status in {"", "pending"}:
|
|
3190
|
+
continue
|
|
3191
|
+
completed_analysis.append(
|
|
3192
|
+
{
|
|
3193
|
+
"campaign_id": str(manifest.get("campaign_id") or "").strip() or None,
|
|
3194
|
+
"slice_id": str(slice_item.get("slice_id") or "").strip() or None,
|
|
3195
|
+
"item_id": str(slice_item.get("item_id") or "").strip() or None,
|
|
3196
|
+
"section_id": str(slice_item.get("section_id") or "").strip() or None,
|
|
3197
|
+
"status": status,
|
|
3198
|
+
"title": str(slice_item.get("title") or slice_item.get("slice_id") or "").strip() or None,
|
|
3199
|
+
}
|
|
3200
|
+
)
|
|
3201
|
+
unmapped_completed_items: list[dict[str, Any]] = []
|
|
3202
|
+
for item in completed_analysis:
|
|
3203
|
+
item_id = str(item.get("item_id") or "").strip()
|
|
3204
|
+
if not item_id:
|
|
3205
|
+
unmapped_completed_items.append(item)
|
|
3206
|
+
continue
|
|
3207
|
+
ledger_item = ledger_by_item.get(item_id)
|
|
3208
|
+
if ledger_item is None or not str(ledger_item.get("section_id") or "").strip():
|
|
3209
|
+
unmapped_completed_items.append(item)
|
|
3210
|
+
return {
|
|
3211
|
+
"ok": not unresolved_required_items and not unmapped_completed_items,
|
|
3212
|
+
"outline_path": str(outline_path) if outline_path else None,
|
|
3213
|
+
"selected_outline_ref": selected_outline_ref,
|
|
3214
|
+
"section_count": len(sections),
|
|
3215
|
+
"ready_section_count": ready_sections,
|
|
3216
|
+
"ledger_item_count": len(ledger_items),
|
|
3217
|
+
"unresolved_required_items": unresolved_required_items,
|
|
3218
|
+
"unmapped_completed_items": unmapped_completed_items,
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
def _paper_contract_health_payload(
|
|
3222
|
+
self,
|
|
3223
|
+
quest_root: Path,
|
|
3224
|
+
*,
|
|
3225
|
+
workspace_root: Path | None = None,
|
|
3226
|
+
pending_slices: int | None = None,
|
|
3227
|
+
) -> dict[str, Any]:
|
|
3228
|
+
gate_status = self._paper_bundle_gate_status(quest_root, workspace_root=workspace_root)
|
|
3229
|
+
paper_root = self._paper_root(quest_root, workspace_root=workspace_root, create=True)
|
|
3230
|
+
bundle_manifest_path = paper_root / "paper_bundle_manifest.json"
|
|
3231
|
+
bundle_manifest = read_json(bundle_manifest_path, {}) if bundle_manifest_path.exists() else {}
|
|
3232
|
+
bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
|
|
3233
|
+
submission_checklist_path = paper_root / "review" / "submission_checklist.json"
|
|
3234
|
+
submission_checklist = read_json(submission_checklist_path, {}) if submission_checklist_path.exists() else {}
|
|
3235
|
+
submission_checklist = submission_checklist if isinstance(submission_checklist, dict) else {}
|
|
3236
|
+
unresolved_required_items = [
|
|
3237
|
+
dict(item) for item in (gate_status.get("unresolved_required_items") or []) if isinstance(item, dict)
|
|
3238
|
+
]
|
|
3239
|
+
unmapped_completed_items = [
|
|
3240
|
+
dict(item) for item in (gate_status.get("unmapped_completed_items") or []) if isinstance(item, dict)
|
|
3241
|
+
]
|
|
3242
|
+
normalized_pending_slices = max(0, int(pending_slices or 0))
|
|
3243
|
+
blocking_reasons: list[str] = []
|
|
3244
|
+
if unmapped_completed_items:
|
|
3245
|
+
blocking_reasons.append("completed analysis remains unmapped into the paper contract")
|
|
3246
|
+
if unresolved_required_items:
|
|
3247
|
+
blocking_reasons.append("required outline items are still unresolved")
|
|
3248
|
+
if normalized_pending_slices > 0:
|
|
3249
|
+
blocking_reasons.append("paper-facing supplementary slices are still pending")
|
|
3250
|
+
|
|
3251
|
+
if unmapped_completed_items:
|
|
3252
|
+
recommended_next_stage = "write"
|
|
3253
|
+
recommended_action = "sync_paper_contract"
|
|
3254
|
+
elif unresolved_required_items or normalized_pending_slices > 0:
|
|
3255
|
+
recommended_next_stage = "analysis-campaign"
|
|
3256
|
+
recommended_action = "complete_required_supplementary"
|
|
3257
|
+
else:
|
|
3258
|
+
recommended_next_stage = "write"
|
|
3259
|
+
recommended_action = "continue_writing"
|
|
3260
|
+
|
|
3261
|
+
contract_ok = not unresolved_required_items and not unmapped_completed_items
|
|
3262
|
+
writing_ready = contract_ok and normalized_pending_slices == 0
|
|
3263
|
+
overall_status = str(submission_checklist.get("overall_status") or bundle_manifest.get("status") or "").strip().lower()
|
|
3264
|
+
delivered_at = str(
|
|
3265
|
+
bundle_manifest.get("paper_delivered_to_user_at")
|
|
3266
|
+
or bundle_manifest.get("delivered_at")
|
|
3267
|
+
or submission_checklist.get("paper_delivered_to_user_at")
|
|
3268
|
+
or ""
|
|
3269
|
+
).strip() or None
|
|
3270
|
+
bundle_present = bundle_manifest_path.exists()
|
|
3271
|
+
delivery_state = "not_ready"
|
|
3272
|
+
closure_state = "bundle_not_ready"
|
|
3273
|
+
keep_bundle_fixed_by_default = False
|
|
3274
|
+
if bundle_present:
|
|
3275
|
+
delivery_state = "bundle_ready"
|
|
3276
|
+
closure_state = "delivery_ready"
|
|
3277
|
+
if delivered_at or "delivered" in overall_status:
|
|
3278
|
+
delivery_state = "delivered"
|
|
3279
|
+
closure_state = "delivered_continue_research" if "continue" in overall_status else "delivered_parked"
|
|
3280
|
+
keep_bundle_fixed_by_default = True
|
|
3281
|
+
|
|
3282
|
+
return {
|
|
3283
|
+
"contract_ok": contract_ok,
|
|
3284
|
+
"writing_ready": writing_ready,
|
|
3285
|
+
"finalize_ready": writing_ready and bundle_present,
|
|
3286
|
+
"bundle_present": bundle_present,
|
|
3287
|
+
"delivery_state": delivery_state,
|
|
3288
|
+
"closure_state": closure_state,
|
|
3289
|
+
"delivered_at": delivered_at,
|
|
3290
|
+
"keep_bundle_fixed_by_default": keep_bundle_fixed_by_default,
|
|
3291
|
+
"selected_outline_ref": gate_status.get("selected_outline_ref"),
|
|
3292
|
+
"section_count": int(gate_status.get("section_count") or 0),
|
|
3293
|
+
"ready_section_count": int(gate_status.get("ready_section_count") or 0),
|
|
3294
|
+
"ledger_item_count": int(gate_status.get("ledger_item_count") or 0),
|
|
3295
|
+
"unresolved_required_count": len(unresolved_required_items),
|
|
3296
|
+
"unmapped_completed_count": len(unmapped_completed_items),
|
|
3297
|
+
"open_supplementary_count": normalized_pending_slices,
|
|
3298
|
+
"blocking_reasons": blocking_reasons,
|
|
3299
|
+
"recommended_next_stage": recommended_next_stage,
|
|
3300
|
+
"recommended_action": recommended_action,
|
|
3301
|
+
"unresolved_required_items": unresolved_required_items[:12],
|
|
3302
|
+
"unmapped_completed_items": unmapped_completed_items[:12],
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
def _write_paper_line_state(
|
|
1602
3306
|
self,
|
|
3307
|
+
quest_root: Path,
|
|
1603
3308
|
*,
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
ten_questions: list[object] | None,
|
|
1609
|
-
detailed_outline: dict[str, Any] | None,
|
|
1610
|
-
review_result: str | None,
|
|
1611
|
-
status: str,
|
|
1612
|
-
created_at: str | None = None,
|
|
3309
|
+
workspace_root: Path | None = None,
|
|
3310
|
+
source_branch: str | None = None,
|
|
3311
|
+
source_run_id: str | None = None,
|
|
3312
|
+
source_idea_id: str | None = None,
|
|
1613
3313
|
) -> dict[str, Any]:
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
3314
|
+
state = self.quest_service.read_research_state(quest_root)
|
|
3315
|
+
selected_outline_path, selected_outline = self._read_selected_outline_for_sync(
|
|
3316
|
+
quest_root,
|
|
3317
|
+
workspace_root=workspace_root,
|
|
1618
3318
|
)
|
|
1619
|
-
|
|
3319
|
+
selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
|
|
3320
|
+
detailed_outline = (
|
|
3321
|
+
dict(selected_outline.get("detailed_outline") or {})
|
|
3322
|
+
if isinstance(selected_outline.get("detailed_outline"), dict)
|
|
3323
|
+
else {}
|
|
3324
|
+
)
|
|
3325
|
+
sections = self._normalize_outline_sections(
|
|
3326
|
+
selected_outline.get("sections"),
|
|
3327
|
+
experimental_designs=self._normalize_string_list(detailed_outline.get("experimental_designs")),
|
|
3328
|
+
)
|
|
3329
|
+
paper_root = self._paper_root(quest_root, workspace_root=workspace_root, create=True)
|
|
3330
|
+
paper_branch = current_branch(paper_root.parent)
|
|
3331
|
+
ledger = self._read_paper_evidence_ledger(quest_root)
|
|
3332
|
+
draft_path = paper_root / "draft.md"
|
|
3333
|
+
bundle_path = paper_root / "paper_bundle_manifest.json"
|
|
3334
|
+
pending_slices = 0
|
|
3335
|
+
campaigns_root = quest_root / ".ds" / "analysis_campaigns"
|
|
3336
|
+
selected_outline_ref = str(selected_outline.get("outline_id") or ledger.get("selected_outline_ref") or "").strip() or None
|
|
3337
|
+
if campaigns_root.exists():
|
|
3338
|
+
for path in sorted(campaigns_root.glob("analysis-*.json")):
|
|
3339
|
+
manifest = read_json(path, {})
|
|
3340
|
+
if not isinstance(manifest, dict) or not manifest:
|
|
3341
|
+
continue
|
|
3342
|
+
manifest_outline_ref = str(manifest.get("selected_outline_ref") or "").strip() or None
|
|
3343
|
+
if selected_outline_ref and manifest_outline_ref != selected_outline_ref:
|
|
3344
|
+
continue
|
|
3345
|
+
pending_slices += sum(
|
|
3346
|
+
1
|
|
3347
|
+
for item in (manifest.get("slices") or [])
|
|
3348
|
+
if isinstance(item, dict) and str(item.get("status") or "pending").strip() == "pending"
|
|
3349
|
+
)
|
|
3350
|
+
health = self._paper_contract_health_payload(
|
|
3351
|
+
quest_root,
|
|
3352
|
+
workspace_root=workspace_root,
|
|
3353
|
+
pending_slices=pending_slices,
|
|
3354
|
+
)
|
|
3355
|
+
required_count = sum(len(self._normalize_string_list(section.get("required_items"))) for section in sections)
|
|
3356
|
+
ready_required_count = required_count - int(health.get("unresolved_required_count") or 0)
|
|
3357
|
+
payload = {
|
|
1620
3358
|
"schema_version": 1,
|
|
1621
|
-
"
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
"
|
|
1627
|
-
"
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
"
|
|
1636
|
-
"
|
|
3359
|
+
"paper_line_id": self._paper_line_id(
|
|
3360
|
+
paper_branch=paper_branch,
|
|
3361
|
+
outline_id=selected_outline_ref,
|
|
3362
|
+
source_run_id=source_run_id or str(state.get("paper_parent_run_id") or "").strip() or None,
|
|
3363
|
+
),
|
|
3364
|
+
"paper_branch": paper_branch,
|
|
3365
|
+
"paper_root": str(paper_root),
|
|
3366
|
+
"workspace_root": str(paper_root.parent),
|
|
3367
|
+
"source_branch": source_branch or str(state.get("paper_parent_branch") or "").strip() or None,
|
|
3368
|
+
"source_run_id": source_run_id or str(state.get("paper_parent_run_id") or "").strip() or None,
|
|
3369
|
+
"source_idea_id": source_idea_id or str(state.get("active_idea_id") or "").strip() or None,
|
|
3370
|
+
"selected_outline_ref": selected_outline_ref,
|
|
3371
|
+
"selected_outline_path": str(selected_outline_path) if selected_outline_path else None,
|
|
3372
|
+
"title": str(selected_outline.get("title") or "").strip() or None,
|
|
3373
|
+
"required_count": required_count,
|
|
3374
|
+
"ready_required_count": max(0, ready_required_count),
|
|
3375
|
+
"section_count": len(sections),
|
|
3376
|
+
"ready_section_count": int(health.get("ready_section_count") or 0),
|
|
3377
|
+
"unmapped_count": int(health.get("unmapped_completed_count") or 0),
|
|
3378
|
+
"open_supplementary_count": pending_slices,
|
|
3379
|
+
"contract_ok": bool(health.get("contract_ok")),
|
|
3380
|
+
"writing_ready": bool(health.get("writing_ready")),
|
|
3381
|
+
"finalize_ready": bool(health.get("finalize_ready")),
|
|
3382
|
+
"closure_state": str(health.get("closure_state") or "").strip() or None,
|
|
3383
|
+
"delivery_state": str(health.get("delivery_state") or "").strip() or None,
|
|
3384
|
+
"delivered_at": str(health.get("delivered_at") or "").strip() or None,
|
|
3385
|
+
"keep_bundle_fixed_by_default": bool(health.get("keep_bundle_fixed_by_default")),
|
|
3386
|
+
"unresolved_required_count": int(health.get("unresolved_required_count") or 0),
|
|
3387
|
+
"unmapped_completed_count": int(health.get("unmapped_completed_count") or 0),
|
|
3388
|
+
"blocking_reasons": list(health.get("blocking_reasons") or []),
|
|
3389
|
+
"recommended_next_stage": str(health.get("recommended_next_stage") or "").strip() or None,
|
|
3390
|
+
"recommended_action": str(health.get("recommended_action") or "").strip() or None,
|
|
3391
|
+
"draft_status": "present" if draft_path.exists() else "missing",
|
|
3392
|
+
"bundle_status": "present" if bundle_path.exists() else "missing",
|
|
1637
3393
|
"updated_at": utc_now(),
|
|
1638
3394
|
}
|
|
1639
|
-
|
|
3395
|
+
for paper_sync_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
|
|
3396
|
+
write_json(paper_sync_root / "paper_line_state.json", payload)
|
|
3397
|
+
return payload
|
|
1640
3398
|
|
|
1641
3399
|
def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path | None = None) -> dict[str, Any] | None:
|
|
1642
3400
|
target_root = self._workspace_root_for(quest_root, workspace_root)
|
|
@@ -2110,6 +3868,353 @@ class ArtifactService:
|
|
|
2110
3868
|
)
|
|
2111
3869
|
return records
|
|
2112
3870
|
|
|
3871
|
+
@staticmethod
|
|
3872
|
+
def _semantic_stage_fingerprint(snapshot: dict[str, Any]) -> str:
|
|
3873
|
+
paper_health = (
|
|
3874
|
+
dict(snapshot.get("paper_contract_health") or {})
|
|
3875
|
+
if isinstance(snapshot.get("paper_contract_health"), dict)
|
|
3876
|
+
else {}
|
|
3877
|
+
)
|
|
3878
|
+
payload = {
|
|
3879
|
+
"active_anchor": str(snapshot.get("active_anchor") or "").strip() or None,
|
|
3880
|
+
"active_run_id": str(snapshot.get("active_run_id") or "").strip() or None,
|
|
3881
|
+
"active_analysis_campaign_id": str(snapshot.get("active_analysis_campaign_id") or "").strip() or None,
|
|
3882
|
+
"next_pending_slice_id": str(snapshot.get("next_pending_slice_id") or "").strip() or None,
|
|
3883
|
+
"current_workspace_branch": str(snapshot.get("current_workspace_branch") or "").strip() or None,
|
|
3884
|
+
"continuation_policy": str(snapshot.get("continuation_policy") or "").strip() or None,
|
|
3885
|
+
"paper": {
|
|
3886
|
+
"closure_state": str(paper_health.get("closure_state") or "").strip() or None,
|
|
3887
|
+
"delivery_state": str(paper_health.get("delivery_state") or "").strip() or None,
|
|
3888
|
+
"blocking_reasons": list(paper_health.get("blocking_reasons") or []),
|
|
3889
|
+
"recommended_next_stage": str(paper_health.get("recommended_next_stage") or "").strip() or None,
|
|
3890
|
+
"recommended_action": str(paper_health.get("recommended_action") or "").strip() or None,
|
|
3891
|
+
"writing_ready": bool(paper_health.get("writing_ready")),
|
|
3892
|
+
"finalize_ready": bool(paper_health.get("finalize_ready")),
|
|
3893
|
+
"keep_bundle_fixed_by_default": bool(paper_health.get("keep_bundle_fixed_by_default")),
|
|
3894
|
+
},
|
|
3895
|
+
}
|
|
3896
|
+
return sha256_text(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
3897
|
+
|
|
3898
|
+
def _semantic_record_key(self, quest_root: Path, payload: dict[str, Any], *, workspace_root: Path | None = None) -> str | None:
|
|
3899
|
+
explicit = str(payload.get("semantic_key") or "").strip()
|
|
3900
|
+
if explicit:
|
|
3901
|
+
return explicit
|
|
3902
|
+
kind = str(payload.get("kind") or "").strip()
|
|
3903
|
+
if kind not in {"decision", "report"}:
|
|
3904
|
+
return None
|
|
3905
|
+
snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
|
|
3906
|
+
fingerprint = self._semantic_stage_fingerprint(snapshot)
|
|
3907
|
+
stage = str(payload.get("stage") or payload.get("anchor") or snapshot.get("active_anchor") or "").strip() or "unknown"
|
|
3908
|
+
if kind == "decision":
|
|
3909
|
+
action = str(payload.get("action") or "").strip() or "none"
|
|
3910
|
+
verdict = str(payload.get("verdict") or "").strip() or "none"
|
|
3911
|
+
return f"decision:{stage}:{action}:{verdict}:{fingerprint}"
|
|
3912
|
+
report_type = str(payload.get("report_type") or payload.get("flow_type") or "").strip() or "report"
|
|
3913
|
+
protocol_step = str(payload.get("protocol_step") or "").strip() or "none"
|
|
3914
|
+
return f"report:{stage}:{report_type}:{protocol_step}:{fingerprint}"
|
|
3915
|
+
|
|
3916
|
+
def _latest_semantically_equivalent_artifact(
|
|
3917
|
+
self,
|
|
3918
|
+
quest_root: Path,
|
|
3919
|
+
*,
|
|
3920
|
+
kind: str,
|
|
3921
|
+
semantic_key: str,
|
|
3922
|
+
) -> dict[str, Any] | None:
|
|
3923
|
+
candidates: list[dict[str, Any]] = []
|
|
3924
|
+
for item in self.quest_service._collect_artifacts(quest_root):
|
|
3925
|
+
payload = dict(item.get("payload") or {}) if isinstance(item.get("payload"), dict) else {}
|
|
3926
|
+
if not payload or str(payload.get("kind") or "").strip() != kind:
|
|
3927
|
+
continue
|
|
3928
|
+
if str(payload.get("semantic_key") or "").strip() != semantic_key:
|
|
3929
|
+
continue
|
|
3930
|
+
candidates.append(
|
|
3931
|
+
{
|
|
3932
|
+
"path": str(item.get("path") or "").strip() or None,
|
|
3933
|
+
"payload": payload,
|
|
3934
|
+
}
|
|
3935
|
+
)
|
|
3936
|
+
if not candidates:
|
|
3937
|
+
return None
|
|
3938
|
+
candidates.sort(
|
|
3939
|
+
key=lambda item: (
|
|
3940
|
+
str(((item.get("payload") or {}).get("updated_at") or "")),
|
|
3941
|
+
str(((item.get("payload") or {}).get("created_at") or "")),
|
|
3942
|
+
str(item.get("path") or ""),
|
|
3943
|
+
)
|
|
3944
|
+
)
|
|
3945
|
+
return candidates[-1]
|
|
3946
|
+
|
|
3947
|
+
def _idea_candidate_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
|
|
3948
|
+
records: list[dict[str, Any]] = []
|
|
3949
|
+
for record in self._idea_artifacts(quest_root):
|
|
3950
|
+
details = dict(record.get("details") or {}) if isinstance(record.get("details"), dict) else {}
|
|
3951
|
+
protocol_step = str(record.get("protocol_step") or "").strip().lower()
|
|
3952
|
+
submission_mode = str(
|
|
3953
|
+
details.get("submission_mode") or record.get("submission_mode") or ""
|
|
3954
|
+
).strip().lower()
|
|
3955
|
+
if protocol_step != "candidate" and submission_mode != "candidate":
|
|
3956
|
+
continue
|
|
3957
|
+
paths = dict(record.get("paths") or {}) if isinstance(record.get("paths"), dict) else {}
|
|
3958
|
+
records.append(
|
|
3959
|
+
{
|
|
3960
|
+
"idea_id": str(record.get("idea_id") or "").strip() or None,
|
|
3961
|
+
"title": str(details.get("title") or "").strip() or None,
|
|
3962
|
+
"problem": str(details.get("problem") or "").strip() or None,
|
|
3963
|
+
"hypothesis": str(details.get("hypothesis") or "").strip() or None,
|
|
3964
|
+
"mechanism": str(details.get("mechanism") or "").strip() or None,
|
|
3965
|
+
"method_brief": str(details.get("method_brief") or "").strip() or None,
|
|
3966
|
+
"selection_scores": self._normalize_selection_scores(details.get("selection_scores")),
|
|
3967
|
+
"mechanism_family": str(details.get("mechanism_family") or "").strip() or None,
|
|
3968
|
+
"change_layer": str(details.get("change_layer") or "").strip() or None,
|
|
3969
|
+
"source_lens": str(details.get("source_lens") or "").strip() or None,
|
|
3970
|
+
"expected_gain": str(details.get("expected_gain") or "").strip() or None,
|
|
3971
|
+
"next_target": str(details.get("next_target") or record.get("next_target") or "").strip() or None,
|
|
3972
|
+
"lineage_intent": str(record.get("lineage_intent") or details.get("lineage_intent") or "").strip() or None,
|
|
3973
|
+
"parent_branch": str(record.get("parent_branch") or details.get("parent_branch") or "").strip() or None,
|
|
3974
|
+
"foundation_ref": record.get("foundation_ref") or details.get("foundation_ref"),
|
|
3975
|
+
"foundation_reason": str(record.get("foundation_reason") or details.get("foundation_reason") or "").strip() or None,
|
|
3976
|
+
"candidate_root": str(paths.get("candidate_root") or "").strip() or None,
|
|
3977
|
+
"idea_md_path": str(paths.get("idea_md") or "").strip() or None,
|
|
3978
|
+
"idea_draft_path": str(paths.get("idea_draft_md") or "").strip() or None,
|
|
3979
|
+
"status": str(record.get("status") or "").strip() or None,
|
|
3980
|
+
"updated_at": str(record.get("updated_at") or record.get("created_at") or "").strip() or None,
|
|
3981
|
+
}
|
|
3982
|
+
)
|
|
3983
|
+
records.sort(key=lambda item: str(item.get("updated_at") or ""))
|
|
3984
|
+
return records
|
|
3985
|
+
|
|
3986
|
+
def _optimization_candidate_reports(self, quest_root: Path) -> list[dict[str, Any]]:
|
|
3987
|
+
records: list[dict[str, Any]] = []
|
|
3988
|
+
for item in self.quest_service._collect_artifacts(quest_root):
|
|
3989
|
+
payload = dict(item.get("payload") or {}) if isinstance(item.get("payload"), dict) else {}
|
|
3990
|
+
if not payload:
|
|
3991
|
+
continue
|
|
3992
|
+
if str(payload.get("kind") or "").strip() != "report":
|
|
3993
|
+
continue
|
|
3994
|
+
report_type = str(payload.get("report_type") or "").strip().lower()
|
|
3995
|
+
if report_type != "optimization_candidate":
|
|
3996
|
+
continue
|
|
3997
|
+
details = dict(payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}
|
|
3998
|
+
artifact_path = str(item.get("path") or "").strip() or None
|
|
3999
|
+
records.append(
|
|
4000
|
+
{
|
|
4001
|
+
"artifact_id": str(payload.get("artifact_id") or payload.get("id") or "").strip() or None,
|
|
4002
|
+
"candidate_id": str(payload.get("candidate_id") or details.get("candidate_id") or "").strip() or None,
|
|
4003
|
+
"parent_candidate_id": str(payload.get("parent_candidate_id") or details.get("parent_candidate_id") or "").strip() or None,
|
|
4004
|
+
"idea_id": str(payload.get("idea_id") or details.get("idea_id") or "").strip() or None,
|
|
4005
|
+
"branch": str(payload.get("branch") or details.get("branch") or "").strip() or None,
|
|
4006
|
+
"strategy": str(payload.get("strategy") or details.get("strategy") or "").strip() or None,
|
|
4007
|
+
"status": str(payload.get("status") or details.get("status") or "").strip() or None,
|
|
4008
|
+
"mechanism_family": str(payload.get("mechanism_family") or details.get("mechanism_family") or "").strip() or None,
|
|
4009
|
+
"change_layer": str(payload.get("change_layer") or details.get("change_layer") or "").strip() or None,
|
|
4010
|
+
"source_lens": str(payload.get("source_lens") or details.get("source_lens") or "").strip() or None,
|
|
4011
|
+
"summary": str(payload.get("summary") or "").strip() or None,
|
|
4012
|
+
"change_plan": str(payload.get("change_plan") or details.get("change_plan") or "").strip() or None,
|
|
4013
|
+
"expected_gain": str(payload.get("expected_gain") or details.get("expected_gain") or "").strip() or None,
|
|
4014
|
+
"linked_run_id": str(payload.get("linked_run_id") or details.get("linked_run_id") or "").strip() or None,
|
|
4015
|
+
"failure_kind": str(payload.get("failure_kind") or details.get("failure_kind") or "").strip() or None,
|
|
4016
|
+
"metrics_snapshot": payload.get("metrics_snapshot") or details.get("metrics_snapshot"),
|
|
4017
|
+
"updated_at": str(payload.get("updated_at") or payload.get("created_at") or "").strip() or None,
|
|
4018
|
+
"artifact_path": artifact_path,
|
|
4019
|
+
}
|
|
4020
|
+
)
|
|
4021
|
+
records.sort(key=lambda item: str(item.get("updated_at") or ""))
|
|
4022
|
+
return records
|
|
4023
|
+
|
|
4024
|
+
@staticmethod
|
|
4025
|
+
def _frontier_branch_rank(branch: dict[str, Any]) -> tuple[int, int, float, str, str]:
|
|
4026
|
+
latest = dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}
|
|
4027
|
+
recommended_route = str(latest.get("recommended_next_route") or "").strip().lower()
|
|
4028
|
+
route_score = {
|
|
4029
|
+
"iterate": 4,
|
|
4030
|
+
"analysis_or_write": 4,
|
|
4031
|
+
"continue": 3,
|
|
4032
|
+
"revise_idea": 1,
|
|
4033
|
+
}.get(recommended_route, 0)
|
|
4034
|
+
breakthrough = 1 if bool(latest.get("breakthrough")) else 0
|
|
4035
|
+
delta = to_number(latest.get("delta_vs_baseline"))
|
|
4036
|
+
delta_score = float(delta) if delta is not None else float("-inf")
|
|
4037
|
+
return (
|
|
4038
|
+
1 if bool(branch.get("has_main_result")) else 0,
|
|
4039
|
+
route_score + breakthrough,
|
|
4040
|
+
delta_score,
|
|
4041
|
+
str(branch.get("updated_at") or ""),
|
|
4042
|
+
str(branch.get("branch_name") or ""),
|
|
4043
|
+
)
|
|
4044
|
+
|
|
4045
|
+
def _optimization_frontier_state(self, quest_root: Path) -> dict[str, Any]:
|
|
4046
|
+
return {
|
|
4047
|
+
"artifact_projection": self.quest_service._json_compatible_state(
|
|
4048
|
+
self.quest_service._path_state(self.quest_service._artifact_projection_path(quest_root))
|
|
4049
|
+
),
|
|
4050
|
+
"research_state": self.quest_service._json_compatible_state(
|
|
4051
|
+
self.quest_service._path_state(self.quest_service._research_state_path(quest_root))
|
|
4052
|
+
),
|
|
4053
|
+
"quest_yaml": self.quest_service._json_compatible_state(
|
|
4054
|
+
self.quest_service._path_state(self.quest_service._quest_yaml_path(quest_root))
|
|
4055
|
+
),
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
def get_optimization_frontier(self, quest_root: Path) -> dict[str, Any]:
|
|
4059
|
+
cache_key = str(quest_root.resolve())
|
|
4060
|
+
state = self._optimization_frontier_state(quest_root)
|
|
4061
|
+
with self._optimization_frontier_cache_lock:
|
|
4062
|
+
cached = self._optimization_frontier_cache.get(cache_key)
|
|
4063
|
+
if cached and cached.get("state") == state:
|
|
4064
|
+
return copy.deepcopy(cached.get("payload") or {"ok": False})
|
|
4065
|
+
|
|
4066
|
+
snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
|
|
4067
|
+
branches_payload = self.list_research_branches(quest_root)
|
|
4068
|
+
branches = [dict(item) for item in (branches_payload.get("branches") or []) if isinstance(item, dict)]
|
|
4069
|
+
candidate_briefs = self._idea_candidate_artifacts(quest_root)
|
|
4070
|
+
implementation_candidates = self._optimization_candidate_reports(quest_root)
|
|
4071
|
+
|
|
4072
|
+
branches.sort(key=self._frontier_branch_rank, reverse=True)
|
|
4073
|
+
top_branches = branches[:3]
|
|
4074
|
+
|
|
4075
|
+
active_candidate_statuses = {"proposed", "smoke_running", "smoke_passed", "promoted", "full_eval_running"}
|
|
4076
|
+
active_implementation_candidates = [
|
|
4077
|
+
item for item in implementation_candidates if str(item.get("status") or "").strip().lower() in active_candidate_statuses
|
|
4078
|
+
]
|
|
4079
|
+
|
|
4080
|
+
stagnant_branch_names: set[str] = set()
|
|
4081
|
+
branch_candidate_failures: dict[str, int] = {}
|
|
4082
|
+
for item in implementation_candidates:
|
|
4083
|
+
branch_name = str(item.get("branch") or "").strip()
|
|
4084
|
+
if not branch_name:
|
|
4085
|
+
continue
|
|
4086
|
+
status = str(item.get("status") or "").strip().lower()
|
|
4087
|
+
if status in {"failed", "smoke_failed", "archived"}:
|
|
4088
|
+
branch_candidate_failures[branch_name] = branch_candidate_failures.get(branch_name, 0) + 1
|
|
4089
|
+
for branch in branches:
|
|
4090
|
+
branch_name = str(branch.get("branch_name") or "").strip()
|
|
4091
|
+
latest = dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}
|
|
4092
|
+
recommended_route = str(latest.get("recommended_next_route") or "").strip().lower()
|
|
4093
|
+
if branch_candidate_failures.get(branch_name, 0) >= 2:
|
|
4094
|
+
stagnant_branch_names.add(branch_name)
|
|
4095
|
+
elif recommended_route in {"continue", "revise_idea"} and bool(branch.get("has_main_result")):
|
|
4096
|
+
stagnant_branch_names.add(branch_name)
|
|
4097
|
+
stagnant_branches = [branch for branch in branches if str(branch.get("branch_name") or "") in stagnant_branch_names]
|
|
4098
|
+
|
|
4099
|
+
successful_branches = [branch for branch in branches if bool(branch.get("has_main_result"))]
|
|
4100
|
+
fusion_candidates = [
|
|
4101
|
+
{
|
|
4102
|
+
"branch_name": str(branch.get("branch_name") or "").strip() or None,
|
|
4103
|
+
"idea_id": branch.get("idea_id"),
|
|
4104
|
+
"idea_title": branch.get("idea_title"),
|
|
4105
|
+
"latest_main_run_id": str((dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}).get("run_id") or "").strip() or None,
|
|
4106
|
+
"strength_signal": {
|
|
4107
|
+
"recommended_next_route": str((dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}).get("recommended_next_route") or "").strip() or None,
|
|
4108
|
+
"delta_vs_baseline": (dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}).get("delta_vs_baseline"),
|
|
4109
|
+
"breakthrough": (dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}).get("breakthrough"),
|
|
4110
|
+
},
|
|
4111
|
+
}
|
|
4112
|
+
for branch in successful_branches[:3]
|
|
4113
|
+
]
|
|
4114
|
+
|
|
4115
|
+
if active_implementation_candidates:
|
|
4116
|
+
mode = "exploit"
|
|
4117
|
+
reason = "At least one implementation-level candidate is already active, so the frontier should stay focused on execution and result conversion."
|
|
4118
|
+
elif candidate_briefs and len(top_branches) <= 1:
|
|
4119
|
+
mode = "explore"
|
|
4120
|
+
reason = "Candidate briefs exist but the durable line set is still thin, so widening or ranking the brief pool is the best next move."
|
|
4121
|
+
elif len(fusion_candidates) >= 2 and stagnant_branches:
|
|
4122
|
+
mode = "fusion"
|
|
4123
|
+
reason = "Multiple result-bearing branches exist and at least one line is stagnating, so cross-line fusion is now justified."
|
|
4124
|
+
elif top_branches:
|
|
4125
|
+
mode = "exploit"
|
|
4126
|
+
reason = "A durable line already exists and no broader frontier condition dominates, so focus should stay on the strongest current line."
|
|
4127
|
+
else:
|
|
4128
|
+
mode = "stop"
|
|
4129
|
+
reason = "No durable optimization line or candidate pool is active, so there is no meaningful frontier to continue automatically."
|
|
4130
|
+
|
|
4131
|
+
recommended_next_actions: list[str] = []
|
|
4132
|
+
if mode == "explore":
|
|
4133
|
+
recommended_next_actions.extend(
|
|
4134
|
+
[
|
|
4135
|
+
"Create or refine candidate briefs before promoting new lines.",
|
|
4136
|
+
"Rank the candidate brief pool and promote only the strongest 1 to 3 directions.",
|
|
4137
|
+
]
|
|
4138
|
+
)
|
|
4139
|
+
elif mode == "fusion":
|
|
4140
|
+
recommended_next_actions.extend(
|
|
4141
|
+
[
|
|
4142
|
+
"Compare the strongest result-bearing lines for complementary mechanisms.",
|
|
4143
|
+
"Open a fusion candidate only if the line strengths are complementary rather than redundant.",
|
|
4144
|
+
]
|
|
4145
|
+
)
|
|
4146
|
+
elif mode == "exploit":
|
|
4147
|
+
recommended_next_actions.extend(
|
|
4148
|
+
[
|
|
4149
|
+
"Keep the active line focused and advance the most promising implementation candidates first.",
|
|
4150
|
+
"Use smoke checks before promoting more candidates into full evaluation.",
|
|
4151
|
+
]
|
|
4152
|
+
)
|
|
4153
|
+
else:
|
|
4154
|
+
recommended_next_actions.append("Record a stop or park decision before closing the optimization loop.")
|
|
4155
|
+
|
|
4156
|
+
candidate_backlog = {
|
|
4157
|
+
"candidate_brief_count": len(candidate_briefs),
|
|
4158
|
+
"implementation_candidate_count": len(implementation_candidates),
|
|
4159
|
+
"active_implementation_candidate_count": len(active_implementation_candidates),
|
|
4160
|
+
"failed_implementation_candidate_count": sum(
|
|
4161
|
+
1 for item in implementation_candidates if str(item.get("status") or "").strip().lower() in {"failed", "smoke_failed", "archived"}
|
|
4162
|
+
),
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
best_branch = top_branches[0] if top_branches else None
|
|
4166
|
+
best_run = (
|
|
4167
|
+
dict(best_branch.get("latest_main_experiment") or {})
|
|
4168
|
+
if isinstance((best_branch or {}).get("latest_main_experiment"), dict)
|
|
4169
|
+
else None
|
|
4170
|
+
)
|
|
4171
|
+
best_branch_name = str((best_branch or {}).get("branch_name") or "").strip() or None
|
|
4172
|
+
best_idea_id = str((best_branch or {}).get("idea_id") or "").strip() or None
|
|
4173
|
+
best_branch_recent_candidates = [
|
|
4174
|
+
{
|
|
4175
|
+
"candidate_id": str(item.get("candidate_id") or "").strip() or None,
|
|
4176
|
+
"strategy": str(item.get("strategy") or "").strip() or None,
|
|
4177
|
+
"status": str(item.get("status") or "").strip() or None,
|
|
4178
|
+
"mechanism_family": str(item.get("mechanism_family") or "").strip() or None,
|
|
4179
|
+
"change_layer": str(item.get("change_layer") or "").strip() or None,
|
|
4180
|
+
"source_lens": str(item.get("source_lens") or "").strip() or None,
|
|
4181
|
+
"change_plan": str(item.get("change_plan") or "").strip() or None,
|
|
4182
|
+
"failure_kind": str(item.get("failure_kind") or "").strip() or None,
|
|
4183
|
+
"linked_run_id": str(item.get("linked_run_id") or "").strip() or None,
|
|
4184
|
+
"updated_at": str(item.get("updated_at") or "").strip() or None,
|
|
4185
|
+
}
|
|
4186
|
+
for item in implementation_candidates
|
|
4187
|
+
if (
|
|
4188
|
+
(best_branch_name and str(item.get("branch") or "").strip() == best_branch_name)
|
|
4189
|
+
or (best_idea_id and str(item.get("idea_id") or "").strip() == best_idea_id)
|
|
4190
|
+
)
|
|
4191
|
+
][-4:]
|
|
4192
|
+
|
|
4193
|
+
payload = {
|
|
4194
|
+
"ok": True,
|
|
4195
|
+
"optimization_frontier": {
|
|
4196
|
+
"mode": mode,
|
|
4197
|
+
"frontier_reason": reason,
|
|
4198
|
+
"active_anchor": snapshot.get("active_anchor"),
|
|
4199
|
+
"best_branch": best_branch,
|
|
4200
|
+
"best_run": best_run,
|
|
4201
|
+
"top_branches": top_branches,
|
|
4202
|
+
"candidate_briefs": candidate_briefs,
|
|
4203
|
+
"implementation_candidates": implementation_candidates[-8:],
|
|
4204
|
+
"best_branch_recent_candidates": best_branch_recent_candidates,
|
|
4205
|
+
"candidate_backlog": candidate_backlog,
|
|
4206
|
+
"stagnant_branches": stagnant_branches,
|
|
4207
|
+
"fusion_candidates": fusion_candidates,
|
|
4208
|
+
"recommended_next_actions": recommended_next_actions,
|
|
4209
|
+
},
|
|
4210
|
+
}
|
|
4211
|
+
with self._optimization_frontier_cache_lock:
|
|
4212
|
+
self._optimization_frontier_cache[cache_key] = {
|
|
4213
|
+
"state": copy.deepcopy(state),
|
|
4214
|
+
"payload": copy.deepcopy(payload),
|
|
4215
|
+
}
|
|
4216
|
+
return payload
|
|
4217
|
+
|
|
2113
4218
|
@staticmethod
|
|
2114
4219
|
def _format_branch_number(index: int) -> str:
|
|
2115
4220
|
if index < 1000:
|
|
@@ -2777,6 +4882,11 @@ class ArtifactService:
|
|
|
2777
4882
|
"idea_id": record.get("idea_id"),
|
|
2778
4883
|
"title": details.get("title"),
|
|
2779
4884
|
"problem": details.get("problem"),
|
|
4885
|
+
"method_brief": details.get("method_brief"),
|
|
4886
|
+
"selection_scores": self._normalize_selection_scores(details.get("selection_scores")),
|
|
4887
|
+
"mechanism_family": details.get("mechanism_family"),
|
|
4888
|
+
"change_layer": details.get("change_layer"),
|
|
4889
|
+
"source_lens": details.get("source_lens"),
|
|
2780
4890
|
"next_target": details.get("next_target") or record.get("next_target"),
|
|
2781
4891
|
"lineage_intent": record.get("lineage_intent") or details.get("lineage_intent"),
|
|
2782
4892
|
"protocol_step": record.get("protocol_step"),
|
|
@@ -2885,6 +4995,11 @@ class ArtifactService:
|
|
|
2885
4995
|
"idea_id": latest_idea.get("idea_id") or (latest_experiment.get("idea_id") if isinstance(latest_experiment, dict) else None),
|
|
2886
4996
|
"idea_title": latest_idea.get("title"),
|
|
2887
4997
|
"idea_problem": latest_idea.get("problem"),
|
|
4998
|
+
"method_brief": latest_idea.get("method_brief"),
|
|
4999
|
+
"selection_scores": self._normalize_selection_scores(latest_idea.get("selection_scores")),
|
|
5000
|
+
"mechanism_family": latest_idea.get("mechanism_family"),
|
|
5001
|
+
"change_layer": latest_idea.get("change_layer"),
|
|
5002
|
+
"source_lens": latest_idea.get("source_lens"),
|
|
2888
5003
|
"next_target": latest_idea.get("next_target"),
|
|
2889
5004
|
"lineage_intent": latest_idea.get("lineage_intent"),
|
|
2890
5005
|
"parent_branch": resolved_parent_branch,
|
|
@@ -2933,7 +5048,7 @@ class ArtifactService:
|
|
|
2933
5048
|
research_head_branch = str(state.get("research_head_branch") or "").strip() or None
|
|
2934
5049
|
canonical_branch = analysis_parent_branch or paper_parent_branch or current_workspace_branch or research_head_branch
|
|
2935
5050
|
latest_main_run = self._latest_main_run_for_branch(quest_root, canonical_branch or "")
|
|
2936
|
-
selected_outline =
|
|
5051
|
+
_, selected_outline = self._read_selected_outline_record(quest_root)
|
|
2937
5052
|
selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
|
|
2938
5053
|
active_campaign = (
|
|
2939
5054
|
self._read_analysis_manifest(quest_root, active_campaign_id)
|
|
@@ -2948,22 +5063,414 @@ class ArtifactService:
|
|
|
2948
5063
|
)
|
|
2949
5064
|
return {
|
|
2950
5065
|
"ok": True,
|
|
2951
|
-
"active_idea_id": str(state.get("active_idea_id") or "").strip() or None,
|
|
2952
|
-
"research_head_branch": research_head_branch,
|
|
2953
|
-
"research_head_worktree_root": str(state.get("research_head_worktree_root") or "").strip() or None,
|
|
2954
|
-
"current_workspace_branch": current_workspace_branch,
|
|
2955
|
-
"current_workspace_root": str(state.get("current_workspace_root") or "").strip() or None,
|
|
2956
|
-
"analysis_parent_branch": analysis_parent_branch,
|
|
2957
|
-
"analysis_parent_worktree_root": str(state.get("analysis_parent_worktree_root") or "").strip() or None,
|
|
2958
|
-
"current_canonical_branch": canonical_branch,
|
|
2959
|
-
"active_analysis_campaign_id": active_campaign_id,
|
|
2960
|
-
"active_campaign_title": str(active_campaign.get("title") or "").strip() or None,
|
|
2961
|
-
"next_pending_slice_id": str(state.get("next_pending_slice_id") or "").strip() or None,
|
|
2962
|
-
"latest_main_run_id": str((latest_main_run or {}).get("run_id") or "").strip() or None,
|
|
2963
|
-
"latest_main_run_branch": str((latest_main_run or {}).get("branch") or "").strip() or None,
|
|
2964
|
-
"latest_main_result_json": str(latest_paths.get("result_json") or "").strip() or None,
|
|
2965
|
-
"selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
|
|
2966
|
-
"default_reply_interaction_id": str(snapshot.get("default_reply_interaction_id") or "").strip() or None,
|
|
5066
|
+
"active_idea_id": str(state.get("active_idea_id") or "").strip() or None,
|
|
5067
|
+
"research_head_branch": research_head_branch,
|
|
5068
|
+
"research_head_worktree_root": str(state.get("research_head_worktree_root") or "").strip() or None,
|
|
5069
|
+
"current_workspace_branch": current_workspace_branch,
|
|
5070
|
+
"current_workspace_root": str(state.get("current_workspace_root") or "").strip() or None,
|
|
5071
|
+
"analysis_parent_branch": analysis_parent_branch,
|
|
5072
|
+
"analysis_parent_worktree_root": str(state.get("analysis_parent_worktree_root") or "").strip() or None,
|
|
5073
|
+
"current_canonical_branch": canonical_branch,
|
|
5074
|
+
"active_analysis_campaign_id": active_campaign_id,
|
|
5075
|
+
"active_campaign_title": str(active_campaign.get("title") or "").strip() or None,
|
|
5076
|
+
"next_pending_slice_id": str(state.get("next_pending_slice_id") or "").strip() or None,
|
|
5077
|
+
"latest_main_run_id": str((latest_main_run or {}).get("run_id") or "").strip() or None,
|
|
5078
|
+
"latest_main_run_branch": str((latest_main_run or {}).get("branch") or "").strip() or None,
|
|
5079
|
+
"latest_main_result_json": str(latest_paths.get("result_json") or "").strip() or None,
|
|
5080
|
+
"selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
|
|
5081
|
+
"default_reply_interaction_id": str(snapshot.get("default_reply_interaction_id") or "").strip() or None,
|
|
5082
|
+
}
|
|
5083
|
+
|
|
5084
|
+
def get_paper_contract_health(
|
|
5085
|
+
self,
|
|
5086
|
+
quest_root: Path,
|
|
5087
|
+
*,
|
|
5088
|
+
detail: str = "summary",
|
|
5089
|
+
) -> dict[str, Any]:
|
|
5090
|
+
normalized_detail = str(detail or "summary").strip().lower() or "summary"
|
|
5091
|
+
if normalized_detail not in {"summary", "full"}:
|
|
5092
|
+
raise ValueError("get_paper_contract_health detail must be `summary` or `full`.")
|
|
5093
|
+
workspace_root = self.quest_service.active_workspace_root(quest_root)
|
|
5094
|
+
paper_contract = self.quest_service._paper_contract_payload(quest_root, workspace_root)
|
|
5095
|
+
paper_evidence = self.quest_service._paper_evidence_payload(quest_root, workspace_root)
|
|
5096
|
+
analysis_inventory = self.quest_service._analysis_inventory_payload(quest_root, workspace_root)
|
|
5097
|
+
paper_lines, active_paper_line_ref = self.quest_service._paper_lines_payload(quest_root, workspace_root)
|
|
5098
|
+
if not paper_contract:
|
|
5099
|
+
return {
|
|
5100
|
+
"ok": False,
|
|
5101
|
+
"message": "No active paper contract is available for the current quest state.",
|
|
5102
|
+
"active_paper_line_ref": active_paper_line_ref,
|
|
5103
|
+
"paper_lines": paper_lines,
|
|
5104
|
+
}
|
|
5105
|
+
selected_outline_ref = str(paper_contract.get("selected_outline_ref") or "").strip() or None
|
|
5106
|
+
if not selected_outline_ref:
|
|
5107
|
+
return {
|
|
5108
|
+
"ok": False,
|
|
5109
|
+
"message": "No selected outline is available for the current quest state.",
|
|
5110
|
+
"active_paper_line_ref": active_paper_line_ref,
|
|
5111
|
+
"paper_lines": paper_lines,
|
|
5112
|
+
}
|
|
5113
|
+
payload = self.quest_service._paper_contract_health_payload(
|
|
5114
|
+
paper_contract=paper_contract,
|
|
5115
|
+
paper_evidence=paper_evidence,
|
|
5116
|
+
analysis_inventory=analysis_inventory,
|
|
5117
|
+
paper_lines=paper_lines,
|
|
5118
|
+
active_paper_line_ref=active_paper_line_ref,
|
|
5119
|
+
)
|
|
5120
|
+
payload = dict(payload or {})
|
|
5121
|
+
if normalized_detail == "summary":
|
|
5122
|
+
payload.pop("unresolved_required_items", None)
|
|
5123
|
+
payload.pop("unmapped_completed_items", None)
|
|
5124
|
+
payload.pop("blocking_pending_slices", None)
|
|
5125
|
+
return {
|
|
5126
|
+
"ok": True,
|
|
5127
|
+
"detail": normalized_detail,
|
|
5128
|
+
"active_paper_line_ref": active_paper_line_ref,
|
|
5129
|
+
"active_workspace_root": str(workspace_root),
|
|
5130
|
+
"paper_contract_health": payload,
|
|
5131
|
+
}
|
|
5132
|
+
|
|
5133
|
+
def get_quest_state(
|
|
5134
|
+
self,
|
|
5135
|
+
quest_root: Path,
|
|
5136
|
+
*,
|
|
5137
|
+
detail: str = "summary",
|
|
5138
|
+
) -> dict[str, Any]:
|
|
5139
|
+
normalized_detail = str(detail or "summary").strip().lower() or "summary"
|
|
5140
|
+
if normalized_detail not in {"summary", "full"}:
|
|
5141
|
+
raise ValueError("get_quest_state detail must be `summary` or `full`.")
|
|
5142
|
+
snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
|
|
5143
|
+
payload: dict[str, Any] = {
|
|
5144
|
+
"quest_id": snapshot.get("quest_id"),
|
|
5145
|
+
"title": snapshot.get("title"),
|
|
5146
|
+
"active_anchor": snapshot.get("active_anchor"),
|
|
5147
|
+
"continuation_policy": snapshot.get("continuation_policy"),
|
|
5148
|
+
"continuation_anchor": snapshot.get("continuation_anchor"),
|
|
5149
|
+
"continuation_reason": snapshot.get("continuation_reason"),
|
|
5150
|
+
"baseline_gate": snapshot.get("baseline_gate"),
|
|
5151
|
+
"active_baseline_id": snapshot.get("active_baseline_id"),
|
|
5152
|
+
"active_baseline_variant_id": snapshot.get("active_baseline_variant_id"),
|
|
5153
|
+
"active_run_id": snapshot.get("active_run_id"),
|
|
5154
|
+
"active_idea_id": snapshot.get("active_idea_id"),
|
|
5155
|
+
"active_analysis_campaign_id": snapshot.get("active_analysis_campaign_id"),
|
|
5156
|
+
"active_idea_line_ref": snapshot.get("active_idea_line_ref"),
|
|
5157
|
+
"active_paper_line_ref": snapshot.get("active_paper_line_ref"),
|
|
5158
|
+
"current_workspace_branch": snapshot.get("current_workspace_branch"),
|
|
5159
|
+
"current_workspace_root": snapshot.get("current_workspace_root"),
|
|
5160
|
+
"research_head_branch": snapshot.get("research_head_branch"),
|
|
5161
|
+
"research_head_worktree_root": snapshot.get("research_head_worktree_root"),
|
|
5162
|
+
"workspace_mode": snapshot.get("workspace_mode"),
|
|
5163
|
+
"runtime_status": snapshot.get("runtime_status"),
|
|
5164
|
+
"display_status": snapshot.get("display_status"),
|
|
5165
|
+
"waiting_interaction_id": snapshot.get("waiting_interaction_id"),
|
|
5166
|
+
"pending_user_message_count": snapshot.get("pending_user_message_count"),
|
|
5167
|
+
"next_pending_slice_id": snapshot.get("next_pending_slice_id"),
|
|
5168
|
+
"paper_contract_health": snapshot.get("paper_contract_health"),
|
|
5169
|
+
}
|
|
5170
|
+
if normalized_detail == "full":
|
|
5171
|
+
payload.update(
|
|
5172
|
+
{
|
|
5173
|
+
"startup_contract": snapshot.get("startup_contract"),
|
|
5174
|
+
"requested_baseline_ref": snapshot.get("requested_baseline_ref"),
|
|
5175
|
+
"confirmed_baseline_ref": snapshot.get("confirmed_baseline_ref"),
|
|
5176
|
+
"counts": snapshot.get("counts"),
|
|
5177
|
+
"paths": snapshot.get("paths"),
|
|
5178
|
+
"active_interactions": snapshot.get("active_interactions"),
|
|
5179
|
+
"recent_reply_threads": snapshot.get("recent_reply_threads"),
|
|
5180
|
+
"recent_artifacts": snapshot.get("recent_artifacts"),
|
|
5181
|
+
"recent_runs": snapshot.get("recent_runs"),
|
|
5182
|
+
"idea_lines": snapshot.get("idea_lines"),
|
|
5183
|
+
"paper_lines": snapshot.get("paper_lines"),
|
|
5184
|
+
}
|
|
5185
|
+
)
|
|
5186
|
+
return {
|
|
5187
|
+
"ok": True,
|
|
5188
|
+
"detail": normalized_detail,
|
|
5189
|
+
"quest_state": payload,
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5192
|
+
def get_global_status(
|
|
5193
|
+
self,
|
|
5194
|
+
quest_root: Path,
|
|
5195
|
+
*,
|
|
5196
|
+
detail: str = "brief",
|
|
5197
|
+
locale: str = "zh",
|
|
5198
|
+
) -> dict[str, Any]:
|
|
5199
|
+
normalized_detail = str(detail or "brief").strip().lower() or "brief"
|
|
5200
|
+
if normalized_detail not in {"brief", "full"}:
|
|
5201
|
+
raise ValueError("get_global_status detail must be `brief` or `full`.")
|
|
5202
|
+
normalized_locale = str(locale or "zh").strip().lower() or "zh"
|
|
5203
|
+
snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
|
|
5204
|
+
scoreboard = self.refresh_method_scoreboard(quest_root)
|
|
5205
|
+
scoreboard_payload = dict(scoreboard.get("scoreboard") or {}) if isinstance(scoreboard.get("scoreboard"), dict) else {}
|
|
5206
|
+
counts = dict(snapshot.get("counts") or {}) if isinstance(snapshot.get("counts"), dict) else {}
|
|
5207
|
+
paper_health = (
|
|
5208
|
+
dict(snapshot.get("paper_contract_health") or {})
|
|
5209
|
+
if isinstance(snapshot.get("paper_contract_health"), dict)
|
|
5210
|
+
else {}
|
|
5211
|
+
)
|
|
5212
|
+
recent_runs = [dict(item) for item in (snapshot.get("recent_runs") or []) if isinstance(item, dict)]
|
|
5213
|
+
latest_run = recent_runs[-1] if recent_runs else {}
|
|
5214
|
+
latest_run_summary = str(latest_run.get("summary") or "").strip() or None
|
|
5215
|
+
status_line = (
|
|
5216
|
+
str(((snapshot.get("summary") or {}) if isinstance(snapshot.get("summary"), dict) else {}).get("status_line") or "").strip()
|
|
5217
|
+
or None
|
|
5218
|
+
)
|
|
5219
|
+
claim_boundary = {
|
|
5220
|
+
"supported": int(paper_health.get("supported_claim_count") or 0),
|
|
5221
|
+
"partial": int(paper_health.get("partial_claim_count") or 0),
|
|
5222
|
+
"unsupported": int(paper_health.get("unsupported_claim_count") or 0),
|
|
5223
|
+
"deferred": int(paper_health.get("deferred_claim_count") or 0),
|
|
5224
|
+
}
|
|
5225
|
+
paper_ready = bool(paper_health.get("writing_ready"))
|
|
5226
|
+
bundle_ready = bool(paper_health.get("finalize_ready"))
|
|
5227
|
+
closure_state = str(paper_health.get("closure_state") or "").strip() or None
|
|
5228
|
+
delivery_state = str(paper_health.get("delivery_state") or "").strip() or None
|
|
5229
|
+
stage = str(snapshot.get("active_anchor") or "decision").strip() or "decision"
|
|
5230
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip() or "auto"
|
|
5231
|
+
next_stage = str(paper_health.get("recommended_next_stage") or stage).strip() or stage
|
|
5232
|
+
next_action = str(paper_health.get("recommended_action") or "continue").strip() or "continue"
|
|
5233
|
+
brief_summary_zh = (
|
|
5234
|
+
f"当前阶段是 `{stage}`。"
|
|
5235
|
+
f"{(' 论文线当前状态是 `' + closure_state + '`。' if closure_state else '')}"
|
|
5236
|
+
f"{(' 论文线已达到 bundle-ready。' if bundle_ready and not closure_state else '')}"
|
|
5237
|
+
f"{(' 当前是停驻等待新消息,不会继续自动空转。' if continuation_policy == 'wait_for_user_or_resume' else '')}"
|
|
5238
|
+
f"{(' 最近主结果:' + latest_run_summary) if latest_run_summary else ''}"
|
|
5239
|
+
).strip()
|
|
5240
|
+
brief_summary_en = (
|
|
5241
|
+
f"Current stage: `{stage}`."
|
|
5242
|
+
f"{(' Paper closure state: `' + closure_state + '`.' if closure_state else '')}"
|
|
5243
|
+
f"{(' The paper line is bundle-ready.' if bundle_ready and not closure_state else '')}"
|
|
5244
|
+
f"{(' The quest is currently parked and will not auto-spin until a new user message or resume.' if continuation_policy == 'wait_for_user_or_resume' else '')}"
|
|
5245
|
+
f"{(' Latest run: ' + latest_run_summary) if latest_run_summary else ''}"
|
|
5246
|
+
).strip()
|
|
5247
|
+
payload: dict[str, Any] = {
|
|
5248
|
+
"quest_id": snapshot.get("quest_id"),
|
|
5249
|
+
"title": snapshot.get("title"),
|
|
5250
|
+
"current_stage": stage,
|
|
5251
|
+
"continuation_policy": continuation_policy,
|
|
5252
|
+
"continuation_anchor": snapshot.get("continuation_anchor"),
|
|
5253
|
+
"status_line": status_line,
|
|
5254
|
+
"latest_run_id": latest_run.get("run_id"),
|
|
5255
|
+
"latest_run_summary": latest_run_summary,
|
|
5256
|
+
"method_scoreboard_path": scoreboard.get("json_path"),
|
|
5257
|
+
"incumbent_method": scoreboard_payload.get("incumbent_title"),
|
|
5258
|
+
"paper_contract_health": {
|
|
5259
|
+
"writing_ready": paper_ready,
|
|
5260
|
+
"finalize_ready": bundle_ready,
|
|
5261
|
+
"closure_state": closure_state,
|
|
5262
|
+
"delivery_state": delivery_state,
|
|
5263
|
+
"delivered_at": paper_health.get("delivered_at"),
|
|
5264
|
+
"keep_bundle_fixed_by_default": bool(paper_health.get("keep_bundle_fixed_by_default")),
|
|
5265
|
+
"recommended_next_stage": next_stage,
|
|
5266
|
+
"recommended_action": next_action,
|
|
5267
|
+
},
|
|
5268
|
+
"claim_boundary": claim_boundary,
|
|
5269
|
+
"pending_user_message_count": int(snapshot.get("pending_user_message_count") or 0),
|
|
5270
|
+
"bash_running_count": int(counts.get("bash_running_count") or 0),
|
|
5271
|
+
"summary_text": brief_summary_zh if normalized_locale.startswith("zh") else brief_summary_en,
|
|
5272
|
+
}
|
|
5273
|
+
if normalized_detail == "full":
|
|
5274
|
+
payload.update(
|
|
5275
|
+
{
|
|
5276
|
+
"runtime_status": snapshot.get("runtime_status"),
|
|
5277
|
+
"display_status": snapshot.get("display_status"),
|
|
5278
|
+
"baseline_gate": snapshot.get("baseline_gate"),
|
|
5279
|
+
"active_baseline_id": snapshot.get("active_baseline_id"),
|
|
5280
|
+
"active_idea_id": snapshot.get("active_idea_id"),
|
|
5281
|
+
"active_analysis_campaign_id": snapshot.get("active_analysis_campaign_id"),
|
|
5282
|
+
"current_workspace_branch": snapshot.get("current_workspace_branch"),
|
|
5283
|
+
"recent_runs": recent_runs[-5:],
|
|
5284
|
+
"paper_lines": snapshot.get("paper_lines"),
|
|
5285
|
+
"idea_lines": snapshot.get("idea_lines"),
|
|
5286
|
+
"method_scoreboard": scoreboard_payload,
|
|
5287
|
+
}
|
|
5288
|
+
)
|
|
5289
|
+
return {
|
|
5290
|
+
"ok": True,
|
|
5291
|
+
"detail": normalized_detail,
|
|
5292
|
+
"locale": normalized_locale,
|
|
5293
|
+
"global_status": payload,
|
|
5294
|
+
}
|
|
5295
|
+
|
|
5296
|
+
def refresh_method_scoreboard(self, quest_root: Path) -> dict[str, Any]:
|
|
5297
|
+
snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
|
|
5298
|
+
ideas = self._idea_artifacts(quest_root)
|
|
5299
|
+
main_runs = self._main_run_artifacts(quest_root)
|
|
5300
|
+
entries_by_key: dict[str, dict[str, Any]] = {}
|
|
5301
|
+
idea_titles_by_id: dict[str, str] = {}
|
|
5302
|
+
|
|
5303
|
+
for idea in ideas:
|
|
5304
|
+
idea_id = str(idea.get("idea_id") or "").strip()
|
|
5305
|
+
if not idea_id:
|
|
5306
|
+
continue
|
|
5307
|
+
idea_details = dict(idea.get("details") or {}) if isinstance(idea.get("details"), dict) else {}
|
|
5308
|
+
idea_title = str(idea.get("title") or idea_details.get("title") or idea_id).strip() or idea_id
|
|
5309
|
+
idea_titles_by_id[idea_id] = idea_title
|
|
5310
|
+
entries_by_key[idea_id] = {
|
|
5311
|
+
"line_key": idea_id,
|
|
5312
|
+
"idea_id": idea_id,
|
|
5313
|
+
"title": idea_title,
|
|
5314
|
+
"branch": str(idea.get("branch") or "").strip() or None,
|
|
5315
|
+
"status": "candidate",
|
|
5316
|
+
"latest_run_id": None,
|
|
5317
|
+
"latest_run_summary": None,
|
|
5318
|
+
"updated_at": str(idea.get("updated_at") or idea.get("created_at") or "").strip() or None,
|
|
5319
|
+
"incumbent": False,
|
|
5320
|
+
}
|
|
5321
|
+
|
|
5322
|
+
for run in main_runs:
|
|
5323
|
+
idea_id = str(run.get("idea_id") or "").strip()
|
|
5324
|
+
key = idea_id or str(run.get("branch") or run.get("run_id") or "run").strip()
|
|
5325
|
+
entry = dict(entries_by_key.get(key) or {})
|
|
5326
|
+
entry.update(
|
|
5327
|
+
{
|
|
5328
|
+
"line_key": key,
|
|
5329
|
+
"idea_id": idea_id or entry.get("idea_id"),
|
|
5330
|
+
"title": str(
|
|
5331
|
+
entry.get("title")
|
|
5332
|
+
or idea_titles_by_id.get(idea_id or "")
|
|
5333
|
+
or run.get("title")
|
|
5334
|
+
or run.get("run_id")
|
|
5335
|
+
or key
|
|
5336
|
+
).strip()
|
|
5337
|
+
or key,
|
|
5338
|
+
"branch": str(run.get("branch") or entry.get("branch") or "").strip() or None,
|
|
5339
|
+
"status": "main_verified" if str(run.get("status") or "").strip() == "completed" else "candidate",
|
|
5340
|
+
"latest_run_id": str(run.get("run_id") or "").strip() or None,
|
|
5341
|
+
"latest_run_summary": str(run.get("summary") or "").strip() or None,
|
|
5342
|
+
"updated_at": str(run.get("updated_at") or run.get("created_at") or "").strip() or entry.get("updated_at"),
|
|
5343
|
+
"incumbent": False,
|
|
5344
|
+
}
|
|
5345
|
+
)
|
|
5346
|
+
entries_by_key[key] = entry
|
|
5347
|
+
|
|
5348
|
+
entries = sorted(
|
|
5349
|
+
entries_by_key.values(),
|
|
5350
|
+
key=lambda item: str(item.get("updated_at") or ""),
|
|
5351
|
+
)
|
|
5352
|
+
if entries:
|
|
5353
|
+
entries[-1]["incumbent"] = True
|
|
5354
|
+
incumbent = next((item for item in entries if item.get("incumbent")), None)
|
|
5355
|
+
|
|
5356
|
+
scoreboard_payload = {
|
|
5357
|
+
"schema_version": 1,
|
|
5358
|
+
"quest_id": snapshot.get("quest_id"),
|
|
5359
|
+
"updated_at": utc_now(),
|
|
5360
|
+
"entry_count": len(entries),
|
|
5361
|
+
"incumbent_line_key": str((incumbent or {}).get("line_key") or "").strip() or None,
|
|
5362
|
+
"incumbent_title": str((incumbent or {}).get("title") or "").strip() or None,
|
|
5363
|
+
"entries": entries,
|
|
5364
|
+
}
|
|
5365
|
+
status_root = ensure_dir(quest_root / "artifacts" / "status")
|
|
5366
|
+
json_path = status_root / "method_scoreboard.json"
|
|
5367
|
+
md_path = status_root / "method_scoreboard.md"
|
|
5368
|
+
write_json(json_path, scoreboard_payload)
|
|
5369
|
+
lines = [
|
|
5370
|
+
"# Method Scoreboard",
|
|
5371
|
+
"",
|
|
5372
|
+
f"- Updated at: {scoreboard_payload['updated_at']}",
|
|
5373
|
+
f"- Incumbent: {scoreboard_payload.get('incumbent_title') or 'none'}",
|
|
5374
|
+
"",
|
|
5375
|
+
"## Entries",
|
|
5376
|
+
"",
|
|
5377
|
+
]
|
|
5378
|
+
if entries:
|
|
5379
|
+
for item in entries:
|
|
5380
|
+
incumbent_tag = " [incumbent]" if item.get("incumbent") else ""
|
|
5381
|
+
lines.append(
|
|
5382
|
+
f"- `{item.get('line_key')}`{incumbent_tag}: {item.get('title') or item.get('line_key')} | status={item.get('status') or 'unknown'} | branch={item.get('branch') or 'none'} | latest_run={item.get('latest_run_id') or 'none'}"
|
|
5383
|
+
)
|
|
5384
|
+
if item.get("latest_run_summary"):
|
|
5385
|
+
lines.append(f" - summary: {item['latest_run_summary']}")
|
|
5386
|
+
else:
|
|
5387
|
+
lines.append("- none")
|
|
5388
|
+
write_text(md_path, "\n".join(lines).rstrip() + "\n")
|
|
5389
|
+
return {
|
|
5390
|
+
"ok": True,
|
|
5391
|
+
"json_path": str(json_path),
|
|
5392
|
+
"md_path": str(md_path),
|
|
5393
|
+
"scoreboard": scoreboard_payload,
|
|
5394
|
+
}
|
|
5395
|
+
|
|
5396
|
+
def read_quest_documents(
|
|
5397
|
+
self,
|
|
5398
|
+
quest_root: Path,
|
|
5399
|
+
*,
|
|
5400
|
+
names: list[str] | None = None,
|
|
5401
|
+
mode: str = "excerpt",
|
|
5402
|
+
max_lines: int = 12,
|
|
5403
|
+
) -> dict[str, Any]:
|
|
5404
|
+
normalized_mode = str(mode or "excerpt").strip().lower() or "excerpt"
|
|
5405
|
+
if normalized_mode not in {"excerpt", "full"}:
|
|
5406
|
+
raise ValueError("read_quest_documents mode must be `excerpt` or `full`.")
|
|
5407
|
+
requested = [str(item or "").strip().lower() for item in (names or []) if str(item or "").strip()]
|
|
5408
|
+
if not requested:
|
|
5409
|
+
requested = ["brief", "plan", "status", "summary", "active_user_requirements"]
|
|
5410
|
+
document_paths = {
|
|
5411
|
+
"brief": quest_root / "brief.md",
|
|
5412
|
+
"plan": quest_root / "plan.md",
|
|
5413
|
+
"status": quest_root / "status.md",
|
|
5414
|
+
"summary": quest_root / "SUMMARY.md",
|
|
5415
|
+
"active_user_requirements": self.quest_service._active_user_requirements_path(quest_root),
|
|
5416
|
+
}
|
|
5417
|
+
items: list[dict[str, Any]] = []
|
|
5418
|
+
for name in requested:
|
|
5419
|
+
path = document_paths.get(name)
|
|
5420
|
+
if path is None:
|
|
5421
|
+
continue
|
|
5422
|
+
exists = path.exists()
|
|
5423
|
+
text = read_text(path, "") if exists else ""
|
|
5424
|
+
if normalized_mode == "excerpt":
|
|
5425
|
+
lines = [line.rstrip() for line in text.splitlines() if line.strip()]
|
|
5426
|
+
content = "\n".join(lines[:max_lines]).strip()
|
|
5427
|
+
else:
|
|
5428
|
+
content = text.strip()
|
|
5429
|
+
items.append(
|
|
5430
|
+
{
|
|
5431
|
+
"name": name,
|
|
5432
|
+
"path": str(path),
|
|
5433
|
+
"exists": exists,
|
|
5434
|
+
"content": content or None,
|
|
5435
|
+
}
|
|
5436
|
+
)
|
|
5437
|
+
return {
|
|
5438
|
+
"ok": True,
|
|
5439
|
+
"mode": normalized_mode,
|
|
5440
|
+
"count": len(items),
|
|
5441
|
+
"items": items,
|
|
5442
|
+
}
|
|
5443
|
+
|
|
5444
|
+
def get_conversation_context(
|
|
5445
|
+
self,
|
|
5446
|
+
quest_root: Path,
|
|
5447
|
+
*,
|
|
5448
|
+
limit: int = 12,
|
|
5449
|
+
include_attachments: bool = False,
|
|
5450
|
+
) -> dict[str, Any]:
|
|
5451
|
+
quest_id = self._quest_id(quest_root)
|
|
5452
|
+
records = self.quest_service.history(quest_id, limit=max(1, limit))
|
|
5453
|
+
items: list[dict[str, Any]] = []
|
|
5454
|
+
for record in records[-max(1, limit) :]:
|
|
5455
|
+
item = {
|
|
5456
|
+
"id": record.get("id"),
|
|
5457
|
+
"role": record.get("role"),
|
|
5458
|
+
"source": record.get("source"),
|
|
5459
|
+
"content": record.get("content"),
|
|
5460
|
+
"created_at": record.get("created_at"),
|
|
5461
|
+
"reply_to_interaction_id": record.get("reply_to_interaction_id"),
|
|
5462
|
+
"run_id": record.get("run_id"),
|
|
5463
|
+
"skill_id": record.get("skill_id"),
|
|
5464
|
+
}
|
|
5465
|
+
if include_attachments:
|
|
5466
|
+
item["attachments"] = [dict(value) for value in (record.get("attachments") or []) if isinstance(value, dict)]
|
|
5467
|
+
items.append(item)
|
|
5468
|
+
latest_user = next((item for item in reversed(items) if str(item.get("role") or "") == "user"), None)
|
|
5469
|
+
return {
|
|
5470
|
+
"ok": True,
|
|
5471
|
+
"count": len(items),
|
|
5472
|
+
"items": items,
|
|
5473
|
+
"latest_user_message": latest_user,
|
|
2967
5474
|
}
|
|
2968
5475
|
|
|
2969
5476
|
def get_analysis_campaign(self, quest_root: Path, campaign_id: str | None = None) -> dict[str, Any]:
|
|
@@ -2987,6 +5494,9 @@ class ArtifactService:
|
|
|
2987
5494
|
"parent_run_id": str(manifest.get("parent_run_id") or "").strip() or None,
|
|
2988
5495
|
"parent_branch": str(manifest.get("parent_branch") or "").strip() or None,
|
|
2989
5496
|
"parent_worktree_root": str(manifest.get("parent_worktree_root") or "").strip() or None,
|
|
5497
|
+
"paper_line_id": str(manifest.get("paper_line_id") or "").strip() or None,
|
|
5498
|
+
"paper_line_branch": str(manifest.get("paper_line_branch") or "").strip() or None,
|
|
5499
|
+
"paper_line_root": str(manifest.get("paper_line_root") or "").strip() or None,
|
|
2990
5500
|
"selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
|
|
2991
5501
|
"campaign_origin": dict(manifest.get("campaign_origin") or {}) if isinstance(manifest.get("campaign_origin"), dict) else None,
|
|
2992
5502
|
"todo_items": [dict(item) for item in (manifest.get("todo_items") or []) if isinstance(item, dict)],
|
|
@@ -2998,15 +5508,8 @@ class ArtifactService:
|
|
|
2998
5508
|
}
|
|
2999
5509
|
|
|
3000
5510
|
def list_paper_outlines(self, quest_root: Path) -> dict[str, Any]:
|
|
3001
|
-
selected_outline_path = self.
|
|
3002
|
-
selected_outline = read_json(selected_outline_path, {})
|
|
5511
|
+
selected_outline_path, selected_outline = self._read_selected_outline_record(quest_root)
|
|
3003
5512
|
selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
|
|
3004
|
-
if not selected_outline:
|
|
3005
|
-
fallback_selected_outline_path = quest_root / "paper" / "selected_outline.json"
|
|
3006
|
-
fallback_selected_outline = read_json(fallback_selected_outline_path, {})
|
|
3007
|
-
if isinstance(fallback_selected_outline, dict) and fallback_selected_outline:
|
|
3008
|
-
selected_outline = fallback_selected_outline
|
|
3009
|
-
selected_outline_path = fallback_selected_outline_path
|
|
3010
5513
|
|
|
3011
5514
|
selected_outline_id = str(selected_outline.get("outline_id") or "").strip()
|
|
3012
5515
|
status_rank = {"candidate": 1, "revised": 2, "selected": 3}
|
|
@@ -3446,7 +5949,46 @@ class ArtifactService:
|
|
|
3446
5949
|
}
|
|
3447
5950
|
|
|
3448
5951
|
write_root = self._workspace_root_for(quest_root, workspace_root)
|
|
5952
|
+
semantic_key = self._semantic_record_key(quest_root, payload, workspace_root=write_root)
|
|
5953
|
+
suppress_equivalent = (
|
|
5954
|
+
bool(payload.get("suppress_if_semantically_equivalent"))
|
|
5955
|
+
if "suppress_if_semantically_equivalent" in payload
|
|
5956
|
+
else str(payload.get("kind") or "").strip() in {"decision", "report"}
|
|
5957
|
+
)
|
|
5958
|
+
if suppress_equivalent and semantic_key:
|
|
5959
|
+
existing = self._latest_semantically_equivalent_artifact(
|
|
5960
|
+
quest_root,
|
|
5961
|
+
kind=str(payload.get("kind") or "").strip(),
|
|
5962
|
+
semantic_key=semantic_key,
|
|
5963
|
+
)
|
|
5964
|
+
if existing is not None:
|
|
5965
|
+
existing_record = dict(existing.get("payload") or {})
|
|
5966
|
+
guidance_vm = dict(existing_record.get("guidance_vm") or {}) if isinstance(existing_record.get("guidance_vm"), dict) else {}
|
|
5967
|
+
guidance_text = guidance_summary(guidance_vm) or guidance_for_kind(str(existing_record.get("kind") or payload.get("kind") or "report"))
|
|
5968
|
+
return {
|
|
5969
|
+
"ok": True,
|
|
5970
|
+
"status": "semantically_equivalent",
|
|
5971
|
+
"artifact_id": existing_record.get("artifact_id"),
|
|
5972
|
+
"path": str(existing.get("path") or ""),
|
|
5973
|
+
"guidance": guidance_text,
|
|
5974
|
+
"guidance_vm": guidance_vm,
|
|
5975
|
+
"next_anchor": str(guidance_vm.get("recommended_skill") or "").strip() or None,
|
|
5976
|
+
"recommended_skill_reads": [str(guidance_vm.get("recommended_skill") or "").strip()] if str(guidance_vm.get("recommended_skill") or "").strip() else [],
|
|
5977
|
+
"suggested_artifact_calls": guidance_vm.get("suggested_artifact_calls") if isinstance(guidance_vm.get("suggested_artifact_calls"), list) else [],
|
|
5978
|
+
"next_instruction": guidance_text,
|
|
5979
|
+
"graph": None,
|
|
5980
|
+
"recorded": existing_record.get("kind"),
|
|
5981
|
+
"record": existing_record,
|
|
5982
|
+
"workspace_root": str(existing_record.get("workspace_root") or write_root),
|
|
5983
|
+
"artifact_path": str(existing.get("path") or ""),
|
|
5984
|
+
"checkpoint": None,
|
|
5985
|
+
"baseline_registry_entry": None,
|
|
5986
|
+
"semantic_key": semantic_key,
|
|
5987
|
+
"suppressed": True,
|
|
5988
|
+
}
|
|
3449
5989
|
record = self._build_record(quest_root, payload, workspace_root=write_root)
|
|
5990
|
+
if semantic_key:
|
|
5991
|
+
record["semantic_key"] = semantic_key
|
|
3450
5992
|
guidance_vm = build_guidance_for_record(record)
|
|
3451
5993
|
record["guidance_vm"] = guidance_vm
|
|
3452
5994
|
guidance_text = guidance_summary(guidance_vm) or guidance_for_kind(record["kind"])
|
|
@@ -3465,8 +6007,31 @@ class ArtifactService:
|
|
|
3465
6007
|
next_instruction = guidance_text
|
|
3466
6008
|
artifact_id = record["artifact_id"]
|
|
3467
6009
|
artifact_path = self._artifact_path(write_root, record["kind"], artifact_id)
|
|
6010
|
+
previous_projection_state_kind, previous_projection_state = self.quest_service._artifact_projection_state(quest_root)
|
|
3468
6011
|
write_json(artifact_path, record)
|
|
3469
6012
|
append_jsonl(write_root / "artifacts" / "_index.jsonl", self._index_line(record, artifact_path))
|
|
6013
|
+
current_projection_state_kind, current_projection_state = self.quest_service._artifact_projection_state(quest_root)
|
|
6014
|
+
try:
|
|
6015
|
+
self.quest_service.update_artifact_projection(
|
|
6016
|
+
quest_root,
|
|
6017
|
+
record=record,
|
|
6018
|
+
artifact_path=artifact_path,
|
|
6019
|
+
workspace_root=write_root,
|
|
6020
|
+
previous_state_kind=previous_projection_state_kind,
|
|
6021
|
+
previous_state=previous_projection_state,
|
|
6022
|
+
current_state_kind=current_projection_state_kind,
|
|
6023
|
+
current_state=current_projection_state,
|
|
6024
|
+
)
|
|
6025
|
+
except Exception:
|
|
6026
|
+
pass
|
|
6027
|
+
try:
|
|
6028
|
+
self.quest_service.schedule_projection_refresh(
|
|
6029
|
+
quest_root,
|
|
6030
|
+
kinds=("details", "canvas"),
|
|
6031
|
+
throttle_seconds=0.0,
|
|
6032
|
+
)
|
|
6033
|
+
except Exception:
|
|
6034
|
+
pass
|
|
3470
6035
|
|
|
3471
6036
|
should_checkpoint = self._should_checkpoint(record["kind"]) if checkpoint is None else checkpoint
|
|
3472
6037
|
checkpoint_result = None
|
|
@@ -4026,12 +6591,18 @@ class ArtifactService:
|
|
|
4026
6591
|
quest_root: Path,
|
|
4027
6592
|
*,
|
|
4028
6593
|
mode: str = "create",
|
|
6594
|
+
submission_mode: str = "line",
|
|
4029
6595
|
idea_id: str | None = None,
|
|
4030
6596
|
lineage_intent: str | None = None,
|
|
4031
6597
|
title: str,
|
|
4032
6598
|
problem: str = "",
|
|
4033
6599
|
hypothesis: str = "",
|
|
4034
6600
|
mechanism: str = "",
|
|
6601
|
+
method_brief: str = "",
|
|
6602
|
+
selection_scores: dict[str, Any] | None = None,
|
|
6603
|
+
mechanism_family: str = "",
|
|
6604
|
+
change_layer: str = "",
|
|
6605
|
+
source_lens: str = "",
|
|
4035
6606
|
expected_gain: str = "",
|
|
4036
6607
|
evidence_paths: list[str] | None = None,
|
|
4037
6608
|
risks: list[str] | None = None,
|
|
@@ -4040,18 +6611,30 @@ class ArtifactService:
|
|
|
4040
6611
|
foundation_reason: str = "",
|
|
4041
6612
|
next_target: str = "experiment",
|
|
4042
6613
|
draft_markdown: str = "",
|
|
6614
|
+
source_candidate_id: str | None = None,
|
|
4043
6615
|
) -> dict[str, Any]:
|
|
4044
6616
|
normalized_mode = str(mode or "create").strip().lower()
|
|
4045
6617
|
if normalized_mode not in {"create", "revise"}:
|
|
4046
6618
|
raise ValueError("submit_idea mode must be `create` or `revise`.")
|
|
6619
|
+
normalized_submission_mode = str(submission_mode or "line").strip().lower() or "line"
|
|
6620
|
+
if normalized_submission_mode not in {"line", "candidate"}:
|
|
6621
|
+
raise ValueError("submit_idea submission_mode must be `line` or `candidate`.")
|
|
4047
6622
|
self._require_baseline_gate_open(quest_root, action="submit_idea")
|
|
4048
6623
|
|
|
4049
6624
|
quest_id = self._quest_id(quest_root)
|
|
4050
6625
|
state = self.quest_service.read_research_state(quest_root)
|
|
6626
|
+
normalized_method_brief = str(method_brief or "").strip()
|
|
6627
|
+
normalized_selection_scores = self._normalize_selection_scores(selection_scores)
|
|
6628
|
+
normalized_mechanism_family = str(mechanism_family or "").strip() or None
|
|
6629
|
+
normalized_change_layer = str(change_layer or "").strip() or None
|
|
6630
|
+
normalized_source_lens = str(source_lens or "").strip() or None
|
|
4051
6631
|
evidence_paths = [str(item).strip() for item in (evidence_paths or []) if str(item).strip()]
|
|
4052
6632
|
risks = [str(item).strip() for item in (risks or []) if str(item).strip()]
|
|
4053
6633
|
next_target = str(next_target or "experiment").strip().lower() or "experiment"
|
|
4054
6634
|
normalized_lineage_intent = self._normalize_lineage_intent(lineage_intent)
|
|
6635
|
+
from ..prompts.builder import STANDARD_SKILLS
|
|
6636
|
+
|
|
6637
|
+
next_anchor = next_target if next_target in STANDARD_SKILLS else "experiment"
|
|
4055
6638
|
|
|
4056
6639
|
if normalized_mode == "create":
|
|
4057
6640
|
resolved_idea_id = str(idea_id or generate_id("idea")).strip()
|
|
@@ -4082,6 +6665,171 @@ class ArtifactService:
|
|
|
4082
6665
|
)
|
|
4083
6666
|
if not parent_branch:
|
|
4084
6667
|
raise ValueError("Unable to resolve a starting branch for the new idea.")
|
|
6668
|
+
if normalized_submission_mode == "candidate":
|
|
6669
|
+
candidate_root = self._idea_candidate_root(quest_root, resolved_idea_id)
|
|
6670
|
+
idea_md_path = candidate_root / "idea.md"
|
|
6671
|
+
idea_draft_path = candidate_root / "draft.md"
|
|
6672
|
+
markdown = self._build_candidate_idea_markdown(
|
|
6673
|
+
idea_id=resolved_idea_id,
|
|
6674
|
+
quest_id=quest_id,
|
|
6675
|
+
title=title,
|
|
6676
|
+
problem=problem,
|
|
6677
|
+
hypothesis=hypothesis,
|
|
6678
|
+
mechanism=mechanism,
|
|
6679
|
+
expected_gain=expected_gain,
|
|
6680
|
+
risks=risks,
|
|
6681
|
+
evidence_paths=evidence_paths,
|
|
6682
|
+
decision_reason=decision_reason,
|
|
6683
|
+
next_target=next_target,
|
|
6684
|
+
candidate_root=candidate_root,
|
|
6685
|
+
method_brief=normalized_method_brief,
|
|
6686
|
+
selection_scores=normalized_selection_scores,
|
|
6687
|
+
mechanism_family=normalized_mechanism_family or "",
|
|
6688
|
+
change_layer=normalized_change_layer or "",
|
|
6689
|
+
source_lens=normalized_source_lens or "",
|
|
6690
|
+
foundation_ref=foundation,
|
|
6691
|
+
foundation_reason=foundation_reason,
|
|
6692
|
+
lineage_intent=normalized_lineage_intent,
|
|
6693
|
+
)
|
|
6694
|
+
draft = self._build_candidate_idea_draft_markdown(
|
|
6695
|
+
idea_id=resolved_idea_id,
|
|
6696
|
+
quest_id=quest_id,
|
|
6697
|
+
title=title,
|
|
6698
|
+
problem=problem,
|
|
6699
|
+
hypothesis=hypothesis,
|
|
6700
|
+
mechanism=mechanism,
|
|
6701
|
+
expected_gain=expected_gain,
|
|
6702
|
+
risks=risks,
|
|
6703
|
+
evidence_paths=evidence_paths,
|
|
6704
|
+
decision_reason=decision_reason,
|
|
6705
|
+
next_target=next_target,
|
|
6706
|
+
candidate_root=candidate_root,
|
|
6707
|
+
method_brief=normalized_method_brief,
|
|
6708
|
+
selection_scores=normalized_selection_scores,
|
|
6709
|
+
mechanism_family=normalized_mechanism_family or "",
|
|
6710
|
+
change_layer=normalized_change_layer or "",
|
|
6711
|
+
source_lens=normalized_source_lens or "",
|
|
6712
|
+
foundation_ref=foundation,
|
|
6713
|
+
foundation_reason=foundation_reason,
|
|
6714
|
+
lineage_intent=normalized_lineage_intent,
|
|
6715
|
+
draft_markdown=draft_markdown,
|
|
6716
|
+
)
|
|
6717
|
+
write_text(idea_md_path, markdown)
|
|
6718
|
+
write_text(idea_draft_path, draft)
|
|
6719
|
+
artifact = self.record(
|
|
6720
|
+
quest_root,
|
|
6721
|
+
{
|
|
6722
|
+
"kind": "idea",
|
|
6723
|
+
"status": "candidate",
|
|
6724
|
+
"summary": f"Idea candidate `{resolved_idea_id}` recorded for later ranking or promotion.",
|
|
6725
|
+
"reason": decision_reason or "A candidate optimization direction was recorded before promotion into a durable branch.",
|
|
6726
|
+
"idea_id": resolved_idea_id,
|
|
6727
|
+
"lineage_intent": normalized_lineage_intent,
|
|
6728
|
+
"branch": None,
|
|
6729
|
+
"parent_branch": parent_branch,
|
|
6730
|
+
"foundation_ref": foundation,
|
|
6731
|
+
"foundation_reason": foundation_reason.strip() or None,
|
|
6732
|
+
"flow_type": "idea_submission",
|
|
6733
|
+
"protocol_step": "candidate",
|
|
6734
|
+
"paths": {
|
|
6735
|
+
"idea_md": str(idea_md_path),
|
|
6736
|
+
"idea_draft_md": str(idea_draft_path),
|
|
6737
|
+
"candidate_root": str(candidate_root),
|
|
6738
|
+
},
|
|
6739
|
+
"details": {
|
|
6740
|
+
"title": title,
|
|
6741
|
+
"problem": problem,
|
|
6742
|
+
"hypothesis": hypothesis,
|
|
6743
|
+
"mechanism": mechanism,
|
|
6744
|
+
"method_brief": normalized_method_brief or None,
|
|
6745
|
+
"selection_scores": normalized_selection_scores or None,
|
|
6746
|
+
"mechanism_family": normalized_mechanism_family,
|
|
6747
|
+
"change_layer": normalized_change_layer,
|
|
6748
|
+
"source_lens": normalized_source_lens,
|
|
6749
|
+
"expected_gain": expected_gain,
|
|
6750
|
+
"next_target": next_target,
|
|
6751
|
+
"lineage_intent": normalized_lineage_intent,
|
|
6752
|
+
"parent_branch": parent_branch,
|
|
6753
|
+
"foundation_ref": foundation,
|
|
6754
|
+
"foundation_reason": foundation_reason.strip() or None,
|
|
6755
|
+
"idea_draft_path": str(idea_draft_path),
|
|
6756
|
+
"evidence_paths": evidence_paths,
|
|
6757
|
+
"risks": risks,
|
|
6758
|
+
"submission_mode": normalized_submission_mode,
|
|
6759
|
+
"source_candidate_id": str(source_candidate_id or "").strip() or None,
|
|
6760
|
+
},
|
|
6761
|
+
},
|
|
6762
|
+
checkpoint=False,
|
|
6763
|
+
workspace_root=quest_root,
|
|
6764
|
+
)
|
|
6765
|
+
interaction = self.interact(
|
|
6766
|
+
quest_root,
|
|
6767
|
+
kind="progress",
|
|
6768
|
+
message=self._build_idea_interaction_message(
|
|
6769
|
+
quest_root=quest_root,
|
|
6770
|
+
action="candidate",
|
|
6771
|
+
idea_id=resolved_idea_id,
|
|
6772
|
+
title=title,
|
|
6773
|
+
mechanism=mechanism,
|
|
6774
|
+
method_brief=normalized_method_brief,
|
|
6775
|
+
foundation_label=self._format_foundation_label(
|
|
6776
|
+
foundation,
|
|
6777
|
+
fallback=foundation.get("branch") or "current head",
|
|
6778
|
+
),
|
|
6779
|
+
branch_name="candidate",
|
|
6780
|
+
change_layer=normalized_change_layer,
|
|
6781
|
+
source_lens=normalized_source_lens,
|
|
6782
|
+
expected_gain=expected_gain,
|
|
6783
|
+
next_target=next_target,
|
|
6784
|
+
),
|
|
6785
|
+
deliver_to_bound_conversations=True,
|
|
6786
|
+
include_recent_inbound_messages=False,
|
|
6787
|
+
attachments=[
|
|
6788
|
+
{
|
|
6789
|
+
"kind": "idea_candidate",
|
|
6790
|
+
"idea_id": resolved_idea_id,
|
|
6791
|
+
"candidate_root": str(candidate_root),
|
|
6792
|
+
"parent_branch": parent_branch,
|
|
6793
|
+
"foundation_ref": foundation,
|
|
6794
|
+
"submission_mode": normalized_submission_mode,
|
|
6795
|
+
"source_candidate_id": str(source_candidate_id or "").strip() or None,
|
|
6796
|
+
"method_brief": normalized_method_brief or None,
|
|
6797
|
+
"selection_scores": normalized_selection_scores or None,
|
|
6798
|
+
"mechanism_family": normalized_mechanism_family,
|
|
6799
|
+
"change_layer": normalized_change_layer,
|
|
6800
|
+
"source_lens": normalized_source_lens,
|
|
6801
|
+
"next_target": next_target,
|
|
6802
|
+
}
|
|
6803
|
+
],
|
|
6804
|
+
)
|
|
6805
|
+
return {
|
|
6806
|
+
"ok": True,
|
|
6807
|
+
"mode": normalized_mode,
|
|
6808
|
+
"submission_mode": normalized_submission_mode,
|
|
6809
|
+
"guidance": artifact.get("guidance"),
|
|
6810
|
+
"guidance_vm": artifact.get("guidance_vm"),
|
|
6811
|
+
"next_anchor": artifact.get("next_anchor"),
|
|
6812
|
+
"recommended_skill_reads": artifact.get("recommended_skill_reads"),
|
|
6813
|
+
"suggested_artifact_calls": artifact.get("suggested_artifact_calls"),
|
|
6814
|
+
"next_instruction": artifact.get("next_instruction"),
|
|
6815
|
+
"idea_id": resolved_idea_id,
|
|
6816
|
+
"lineage_intent": normalized_lineage_intent,
|
|
6817
|
+
"parent_branch": parent_branch,
|
|
6818
|
+
"foundation_ref": foundation,
|
|
6819
|
+
"foundation_reason": foundation_reason.strip() or None,
|
|
6820
|
+
"candidate_root": str(candidate_root),
|
|
6821
|
+
"idea_md_path": str(idea_md_path),
|
|
6822
|
+
"idea_draft_path": str(idea_draft_path),
|
|
6823
|
+
"artifact": artifact,
|
|
6824
|
+
"interaction": interaction,
|
|
6825
|
+
"promotable": True,
|
|
6826
|
+
"source_candidate_id": str(source_candidate_id or "").strip() or None,
|
|
6827
|
+
"method_brief": normalized_method_brief or None,
|
|
6828
|
+
"selection_scores": normalized_selection_scores or None,
|
|
6829
|
+
"mechanism_family": normalized_mechanism_family,
|
|
6830
|
+
"change_layer": normalized_change_layer,
|
|
6831
|
+
"source_lens": normalized_source_lens,
|
|
6832
|
+
}
|
|
4085
6833
|
branch_name = f"idea/{quest_id}-{resolved_idea_id}"
|
|
4086
6834
|
worktree_root = canonical_worktree_root(quest_root, f"idea-{resolved_idea_id}")
|
|
4087
6835
|
branch_result = ensure_branch(quest_root, branch_name, start_point=parent_branch, checkout=False)
|
|
@@ -4108,6 +6856,11 @@ class ArtifactService:
|
|
|
4108
6856
|
next_target=next_target,
|
|
4109
6857
|
branch=branch_name,
|
|
4110
6858
|
worktree_root=worktree_root,
|
|
6859
|
+
method_brief=normalized_method_brief,
|
|
6860
|
+
selection_scores=normalized_selection_scores,
|
|
6861
|
+
mechanism_family=normalized_mechanism_family or "",
|
|
6862
|
+
change_layer=normalized_change_layer or "",
|
|
6863
|
+
source_lens=normalized_source_lens or "",
|
|
4111
6864
|
foundation_ref=foundation,
|
|
4112
6865
|
foundation_reason=foundation_reason,
|
|
4113
6866
|
lineage_intent=normalized_lineage_intent,
|
|
@@ -4126,6 +6879,11 @@ class ArtifactService:
|
|
|
4126
6879
|
next_target=next_target,
|
|
4127
6880
|
branch=branch_name,
|
|
4128
6881
|
worktree_root=worktree_root,
|
|
6882
|
+
method_brief=normalized_method_brief,
|
|
6883
|
+
selection_scores=normalized_selection_scores,
|
|
6884
|
+
mechanism_family=normalized_mechanism_family or "",
|
|
6885
|
+
change_layer=normalized_change_layer or "",
|
|
6886
|
+
source_lens=normalized_source_lens or "",
|
|
4129
6887
|
foundation_ref=foundation,
|
|
4130
6888
|
foundation_reason=foundation_reason,
|
|
4131
6889
|
lineage_intent=normalized_lineage_intent,
|
|
@@ -4161,6 +6919,11 @@ class ArtifactService:
|
|
|
4161
6919
|
"problem": problem,
|
|
4162
6920
|
"hypothesis": hypothesis,
|
|
4163
6921
|
"mechanism": mechanism,
|
|
6922
|
+
"method_brief": normalized_method_brief or None,
|
|
6923
|
+
"selection_scores": normalized_selection_scores or None,
|
|
6924
|
+
"mechanism_family": normalized_mechanism_family,
|
|
6925
|
+
"change_layer": normalized_change_layer,
|
|
6926
|
+
"source_lens": normalized_source_lens,
|
|
4164
6927
|
"expected_gain": expected_gain,
|
|
4165
6928
|
"next_target": next_target,
|
|
4166
6929
|
"branch_no": branch_no,
|
|
@@ -4171,6 +6934,8 @@ class ArtifactService:
|
|
|
4171
6934
|
"idea_draft_path": str(idea_draft_path),
|
|
4172
6935
|
"evidence_paths": evidence_paths,
|
|
4173
6936
|
"risks": risks,
|
|
6937
|
+
"submission_mode": normalized_submission_mode,
|
|
6938
|
+
"source_candidate_id": str(source_candidate_id or "").strip() or None,
|
|
4174
6939
|
},
|
|
4175
6940
|
},
|
|
4176
6941
|
checkpoint=False,
|
|
@@ -4192,31 +6957,30 @@ class ArtifactService:
|
|
|
4192
6957
|
workspace_mode="idea",
|
|
4193
6958
|
last_flow_type="idea_submission",
|
|
4194
6959
|
)
|
|
4195
|
-
self.quest_service.update_settings(quest_id, active_anchor=
|
|
6960
|
+
self.quest_service.update_settings(quest_id, active_anchor=next_anchor)
|
|
4196
6961
|
checkpoint_result = self._checkpoint_with_optional_push(
|
|
4197
6962
|
worktree_root,
|
|
4198
6963
|
message=f"idea: create {resolved_idea_id}",
|
|
4199
6964
|
)
|
|
4200
|
-
idea_md_rel_path = self._workspace_relative(quest_root, idea_md_path)
|
|
4201
|
-
idea_draft_rel_path = self._workspace_relative(quest_root, idea_draft_path)
|
|
4202
6965
|
interaction = self.interact(
|
|
4203
6966
|
quest_root,
|
|
4204
6967
|
kind="milestone",
|
|
4205
6968
|
message=self._build_idea_interaction_message(
|
|
6969
|
+
quest_root=quest_root,
|
|
4206
6970
|
action="create",
|
|
4207
6971
|
idea_id=resolved_idea_id,
|
|
4208
6972
|
title=title,
|
|
4209
|
-
problem=problem,
|
|
4210
|
-
hypothesis=hypothesis,
|
|
4211
6973
|
mechanism=mechanism,
|
|
6974
|
+
method_brief=normalized_method_brief,
|
|
4212
6975
|
foundation_label=self._format_foundation_label(
|
|
4213
6976
|
foundation,
|
|
4214
6977
|
fallback=foundation.get("branch") or "current head",
|
|
4215
6978
|
),
|
|
4216
6979
|
branch_name=branch_name,
|
|
6980
|
+
change_layer=normalized_change_layer,
|
|
6981
|
+
source_lens=normalized_source_lens,
|
|
6982
|
+
expected_gain=expected_gain,
|
|
4217
6983
|
next_target=next_target,
|
|
4218
|
-
idea_md_rel_path=idea_md_rel_path,
|
|
4219
|
-
draft_md_rel_path=idea_draft_rel_path,
|
|
4220
6984
|
),
|
|
4221
6985
|
deliver_to_bound_conversations=True,
|
|
4222
6986
|
include_recent_inbound_messages=False,
|
|
@@ -4233,6 +6997,13 @@ class ArtifactService:
|
|
|
4233
6997
|
"worktree_root": str(worktree_root),
|
|
4234
6998
|
"idea_md_path": str(idea_md_path),
|
|
4235
6999
|
"idea_draft_path": str(idea_draft_path),
|
|
7000
|
+
"submission_mode": normalized_submission_mode,
|
|
7001
|
+
"source_candidate_id": str(source_candidate_id or "").strip() or None,
|
|
7002
|
+
"method_brief": normalized_method_brief or None,
|
|
7003
|
+
"selection_scores": normalized_selection_scores or None,
|
|
7004
|
+
"mechanism_family": normalized_mechanism_family,
|
|
7005
|
+
"change_layer": normalized_change_layer,
|
|
7006
|
+
"source_lens": normalized_source_lens,
|
|
4236
7007
|
"next_target": next_target,
|
|
4237
7008
|
}
|
|
4238
7009
|
],
|
|
@@ -4240,6 +7011,7 @@ class ArtifactService:
|
|
|
4240
7011
|
return {
|
|
4241
7012
|
"ok": True,
|
|
4242
7013
|
"mode": normalized_mode,
|
|
7014
|
+
"submission_mode": normalized_submission_mode,
|
|
4243
7015
|
"guidance": artifact.get("guidance"),
|
|
4244
7016
|
"guidance_vm": artifact.get("guidance_vm"),
|
|
4245
7017
|
"next_anchor": artifact.get("next_anchor"),
|
|
@@ -4262,11 +7034,19 @@ class ArtifactService:
|
|
|
4262
7034
|
"checkpoint": checkpoint_result,
|
|
4263
7035
|
"interaction": interaction,
|
|
4264
7036
|
"research_state": research_state,
|
|
7037
|
+
"source_candidate_id": str(source_candidate_id or "").strip() or None,
|
|
7038
|
+
"method_brief": normalized_method_brief or None,
|
|
7039
|
+
"selection_scores": normalized_selection_scores or None,
|
|
7040
|
+
"mechanism_family": normalized_mechanism_family,
|
|
7041
|
+
"change_layer": normalized_change_layer,
|
|
7042
|
+
"source_lens": normalized_source_lens,
|
|
4265
7043
|
}
|
|
4266
7044
|
|
|
4267
7045
|
resolved_idea_id = str(idea_id or state.get("active_idea_id") or "").strip()
|
|
4268
7046
|
if not resolved_idea_id:
|
|
4269
7047
|
raise ValueError("submit_idea(mode='revise') requires an existing active `idea_id`.")
|
|
7048
|
+
if normalized_submission_mode != "line":
|
|
7049
|
+
raise ValueError("submit_idea(mode='revise') currently only supports submission_mode='line'.")
|
|
4270
7050
|
if normalized_lineage_intent:
|
|
4271
7051
|
raise ValueError("submit_idea(mode='revise') does not accept `lineage_intent`; use mode='create' for new branch lineage.")
|
|
4272
7052
|
branch_name = str(
|
|
@@ -4288,6 +7068,11 @@ class ArtifactService:
|
|
|
4288
7068
|
draft_created_at = None
|
|
4289
7069
|
existing_foundation_ref = None
|
|
4290
7070
|
existing_foundation_reason = None
|
|
7071
|
+
existing_method_brief = None
|
|
7072
|
+
existing_selection_scores = None
|
|
7073
|
+
existing_mechanism_family = None
|
|
7074
|
+
existing_change_layer = None
|
|
7075
|
+
existing_source_lens = None
|
|
4291
7076
|
if idea_md_path.exists():
|
|
4292
7077
|
metadata, _body = load_markdown_document(idea_md_path)
|
|
4293
7078
|
created_at = metadata.get("created_at")
|
|
@@ -4297,9 +7082,29 @@ class ArtifactService:
|
|
|
4297
7082
|
else None
|
|
4298
7083
|
)
|
|
4299
7084
|
existing_foundation_reason = str(metadata.get("foundation_reason") or "").strip() or None
|
|
7085
|
+
existing_method_brief = str(metadata.get("method_brief") or "").strip() or None
|
|
7086
|
+
existing_selection_scores = self._normalize_selection_scores(metadata.get("selection_scores"))
|
|
7087
|
+
existing_mechanism_family = str(metadata.get("mechanism_family") or "").strip() or None
|
|
7088
|
+
existing_change_layer = str(metadata.get("change_layer") or "").strip() or None
|
|
7089
|
+
existing_source_lens = str(metadata.get("source_lens") or "").strip() or None
|
|
4300
7090
|
if idea_draft_path.exists():
|
|
4301
7091
|
draft_metadata, _draft_body = load_markdown_document(idea_draft_path)
|
|
4302
7092
|
draft_created_at = draft_metadata.get("created_at")
|
|
7093
|
+
if existing_method_brief is None:
|
|
7094
|
+
existing_method_brief = str(draft_metadata.get("method_brief") or "").strip() or None
|
|
7095
|
+
if existing_selection_scores is None:
|
|
7096
|
+
existing_selection_scores = self._normalize_selection_scores(draft_metadata.get("selection_scores"))
|
|
7097
|
+
if existing_mechanism_family is None:
|
|
7098
|
+
existing_mechanism_family = str(draft_metadata.get("mechanism_family") or "").strip() or None
|
|
7099
|
+
if existing_change_layer is None:
|
|
7100
|
+
existing_change_layer = str(draft_metadata.get("change_layer") or "").strip() or None
|
|
7101
|
+
if existing_source_lens is None:
|
|
7102
|
+
existing_source_lens = str(draft_metadata.get("source_lens") or "").strip() or None
|
|
7103
|
+
revised_method_brief = normalized_method_brief or existing_method_brief or ""
|
|
7104
|
+
revised_selection_scores = normalized_selection_scores or existing_selection_scores
|
|
7105
|
+
revised_mechanism_family = normalized_mechanism_family or existing_mechanism_family
|
|
7106
|
+
revised_change_layer = normalized_change_layer or existing_change_layer
|
|
7107
|
+
revised_source_lens = normalized_source_lens or existing_source_lens
|
|
4303
7108
|
markdown = self._build_idea_markdown(
|
|
4304
7109
|
idea_id=resolved_idea_id,
|
|
4305
7110
|
quest_id=quest_id,
|
|
@@ -4314,6 +7119,11 @@ class ArtifactService:
|
|
|
4314
7119
|
next_target=next_target,
|
|
4315
7120
|
branch=branch_name,
|
|
4316
7121
|
worktree_root=worktree_root,
|
|
7122
|
+
method_brief=revised_method_brief,
|
|
7123
|
+
selection_scores=revised_selection_scores,
|
|
7124
|
+
mechanism_family=revised_mechanism_family or "",
|
|
7125
|
+
change_layer=revised_change_layer or "",
|
|
7126
|
+
source_lens=revised_source_lens or "",
|
|
4317
7127
|
foundation_ref=existing_foundation_ref,
|
|
4318
7128
|
foundation_reason=foundation_reason.strip() or existing_foundation_reason or "",
|
|
4319
7129
|
lineage_intent=None,
|
|
@@ -4333,6 +7143,11 @@ class ArtifactService:
|
|
|
4333
7143
|
next_target=next_target,
|
|
4334
7144
|
branch=branch_name,
|
|
4335
7145
|
worktree_root=worktree_root,
|
|
7146
|
+
method_brief=revised_method_brief,
|
|
7147
|
+
selection_scores=revised_selection_scores,
|
|
7148
|
+
mechanism_family=revised_mechanism_family or "",
|
|
7149
|
+
change_layer=revised_change_layer or "",
|
|
7150
|
+
source_lens=revised_source_lens or "",
|
|
4336
7151
|
foundation_ref=existing_foundation_ref,
|
|
4337
7152
|
foundation_reason=foundation_reason.strip() or existing_foundation_reason or "",
|
|
4338
7153
|
lineage_intent=None,
|
|
@@ -4368,6 +7183,11 @@ class ArtifactService:
|
|
|
4368
7183
|
"problem": problem,
|
|
4369
7184
|
"hypothesis": hypothesis,
|
|
4370
7185
|
"mechanism": mechanism,
|
|
7186
|
+
"method_brief": revised_method_brief or None,
|
|
7187
|
+
"selection_scores": revised_selection_scores or None,
|
|
7188
|
+
"mechanism_family": revised_mechanism_family,
|
|
7189
|
+
"change_layer": revised_change_layer,
|
|
7190
|
+
"source_lens": revised_source_lens,
|
|
4371
7191
|
"expected_gain": expected_gain,
|
|
4372
7192
|
"next_target": next_target,
|
|
4373
7193
|
"parent_branch": parent_branch,
|
|
@@ -4376,6 +7196,7 @@ class ArtifactService:
|
|
|
4376
7196
|
"idea_draft_path": str(idea_draft_path),
|
|
4377
7197
|
"evidence_paths": evidence_paths,
|
|
4378
7198
|
"risks": risks,
|
|
7199
|
+
"submission_mode": normalized_submission_mode,
|
|
4379
7200
|
},
|
|
4380
7201
|
},
|
|
4381
7202
|
checkpoint=False,
|
|
@@ -4398,31 +7219,30 @@ class ArtifactService:
|
|
|
4398
7219
|
quest_root,
|
|
4399
7220
|
**research_state_updates,
|
|
4400
7221
|
)
|
|
4401
|
-
self.quest_service.update_settings(quest_id, active_anchor=
|
|
7222
|
+
self.quest_service.update_settings(quest_id, active_anchor=next_anchor)
|
|
4402
7223
|
checkpoint_result = self._checkpoint_with_optional_push(
|
|
4403
7224
|
worktree_root,
|
|
4404
7225
|
message=f"idea: revise {resolved_idea_id}",
|
|
4405
7226
|
)
|
|
4406
|
-
idea_md_rel_path = self._workspace_relative(quest_root, idea_md_path)
|
|
4407
|
-
idea_draft_rel_path = self._workspace_relative(quest_root, idea_draft_path)
|
|
4408
7227
|
interaction = self.interact(
|
|
4409
7228
|
quest_root,
|
|
4410
7229
|
kind="progress",
|
|
4411
7230
|
message=self._build_idea_interaction_message(
|
|
7231
|
+
quest_root=quest_root,
|
|
4412
7232
|
action="revise",
|
|
4413
7233
|
idea_id=resolved_idea_id,
|
|
4414
7234
|
title=title,
|
|
4415
|
-
problem=problem,
|
|
4416
|
-
hypothesis=hypothesis,
|
|
4417
7235
|
mechanism=mechanism,
|
|
7236
|
+
method_brief=revised_method_brief,
|
|
4418
7237
|
foundation_label=self._format_foundation_label(
|
|
4419
7238
|
existing_foundation_ref,
|
|
4420
7239
|
fallback=(existing_foundation_ref or {}).get("branch") or "current head",
|
|
4421
7240
|
),
|
|
4422
7241
|
branch_name=branch_name,
|
|
7242
|
+
change_layer=revised_change_layer,
|
|
7243
|
+
source_lens=revised_source_lens,
|
|
7244
|
+
expected_gain=expected_gain,
|
|
4423
7245
|
next_target=next_target,
|
|
4424
|
-
idea_md_rel_path=idea_md_rel_path,
|
|
4425
|
-
draft_md_rel_path=idea_draft_rel_path,
|
|
4426
7246
|
),
|
|
4427
7247
|
deliver_to_bound_conversations=True,
|
|
4428
7248
|
include_recent_inbound_messages=False,
|
|
@@ -4436,6 +7256,12 @@ class ArtifactService:
|
|
|
4436
7256
|
"worktree_root": str(worktree_root),
|
|
4437
7257
|
"idea_md_path": str(idea_md_path),
|
|
4438
7258
|
"idea_draft_path": str(idea_draft_path),
|
|
7259
|
+
"submission_mode": normalized_submission_mode,
|
|
7260
|
+
"method_brief": revised_method_brief or None,
|
|
7261
|
+
"selection_scores": revised_selection_scores or None,
|
|
7262
|
+
"mechanism_family": revised_mechanism_family,
|
|
7263
|
+
"change_layer": revised_change_layer,
|
|
7264
|
+
"source_lens": revised_source_lens,
|
|
4439
7265
|
"next_target": next_target,
|
|
4440
7266
|
}
|
|
4441
7267
|
],
|
|
@@ -4443,6 +7269,7 @@ class ArtifactService:
|
|
|
4443
7269
|
return {
|
|
4444
7270
|
"ok": True,
|
|
4445
7271
|
"mode": normalized_mode,
|
|
7272
|
+
"submission_mode": normalized_submission_mode,
|
|
4446
7273
|
"guidance": artifact.get("guidance"),
|
|
4447
7274
|
"guidance_vm": artifact.get("guidance_vm"),
|
|
4448
7275
|
"next_anchor": artifact.get("next_anchor"),
|
|
@@ -4457,6 +7284,11 @@ class ArtifactService:
|
|
|
4457
7284
|
"worktree_root": str(worktree_root),
|
|
4458
7285
|
"idea_md_path": str(idea_md_path),
|
|
4459
7286
|
"idea_draft_path": str(idea_draft_path),
|
|
7287
|
+
"method_brief": revised_method_brief or None,
|
|
7288
|
+
"selection_scores": revised_selection_scores or None,
|
|
7289
|
+
"mechanism_family": revised_mechanism_family,
|
|
7290
|
+
"change_layer": revised_change_layer,
|
|
7291
|
+
"source_lens": revised_source_lens,
|
|
4460
7292
|
"artifact": artifact,
|
|
4461
7293
|
"checkpoint": checkpoint_result,
|
|
4462
7294
|
"interaction": interaction,
|
|
@@ -4532,6 +7364,12 @@ class ArtifactService:
|
|
|
4532
7364
|
return dict(quest_data.get("startup_contract") or {})
|
|
4533
7365
|
return {}
|
|
4534
7366
|
|
|
7367
|
+
def _post_baseline_anchor(self, quest_root: Path) -> str:
|
|
7368
|
+
startup_contract = self._startup_contract(quest_root)
|
|
7369
|
+
raw_need_research_paper = startup_contract.get("need_research_paper")
|
|
7370
|
+
need_research_paper = raw_need_research_paper if isinstance(raw_need_research_paper, bool) else True
|
|
7371
|
+
return "idea" if need_research_paper else "optimize"
|
|
7372
|
+
|
|
4535
7373
|
def _decision_policy(self, quest_root: Path) -> str:
|
|
4536
7374
|
value = str(self._startup_contract(quest_root).get("decision_policy") or "").strip().lower()
|
|
4537
7375
|
if value in {"autonomous", "user_gated"}:
|
|
@@ -4862,6 +7700,7 @@ class ArtifactService:
|
|
|
4862
7700
|
"metric_validation": metric_validation,
|
|
4863
7701
|
}
|
|
4864
7702
|
write_json(result_json_path, result_payload)
|
|
7703
|
+
metric_charts: list[dict[str, Any]] = []
|
|
4865
7704
|
|
|
4866
7705
|
artifact = self.record(
|
|
4867
7706
|
quest_root,
|
|
@@ -4882,6 +7721,13 @@ class ArtifactService:
|
|
|
4882
7721
|
"paths": {
|
|
4883
7722
|
"run_md": str(run_md_path),
|
|
4884
7723
|
"result_json": str(result_json_path),
|
|
7724
|
+
**(
|
|
7725
|
+
{
|
|
7726
|
+
"connector_chart_dir": str(self._main_experiment_chart_dir(workspace_root, run_id=run_identifier))
|
|
7727
|
+
}
|
|
7728
|
+
if metric_charts
|
|
7729
|
+
else {}
|
|
7730
|
+
),
|
|
4885
7731
|
},
|
|
4886
7732
|
"details": {
|
|
4887
7733
|
"title": title.strip() or run_identifier,
|
|
@@ -4897,6 +7743,7 @@ class ArtifactService:
|
|
|
4897
7743
|
"auto_promoted_run_branch": auto_promoted_run_branch,
|
|
4898
7744
|
"changed_file_count": len(resolved_changed_files),
|
|
4899
7745
|
"evidence_count": len(resolved_evidence_paths),
|
|
7746
|
+
"connector_chart_count": 0,
|
|
4900
7747
|
"evaluation_summary": normalized_evaluation_summary,
|
|
4901
7748
|
},
|
|
4902
7749
|
"delivery_policy": delivery_policy,
|
|
@@ -4922,6 +7769,23 @@ class ArtifactService:
|
|
|
4922
7769
|
commit_message=f"experiment: record main {run_identifier}",
|
|
4923
7770
|
workspace_root=workspace_root,
|
|
4924
7771
|
)
|
|
7772
|
+
metric_charts = self._generate_main_experiment_metric_charts(
|
|
7773
|
+
quest_root,
|
|
7774
|
+
workspace_root=workspace_root,
|
|
7775
|
+
run_id=run_identifier,
|
|
7776
|
+
)
|
|
7777
|
+
if metric_charts:
|
|
7778
|
+
result_payload["connector_metric_charts"] = metric_charts
|
|
7779
|
+
write_json(result_json_path, result_payload)
|
|
7780
|
+
artifact_record = dict(artifact.get("record") or {}) if isinstance(artifact.get("record"), dict) else {}
|
|
7781
|
+
artifact_record["connector_metric_charts"] = metric_charts
|
|
7782
|
+
details = dict(artifact_record.get("details") or {}) if isinstance(artifact_record.get("details"), dict) else {}
|
|
7783
|
+
details["connector_chart_count"] = len(metric_charts)
|
|
7784
|
+
artifact_record["details"] = details
|
|
7785
|
+
artifact["record"] = artifact_record
|
|
7786
|
+
artifact_path = Path(str(artifact.get("path") or ""))
|
|
7787
|
+
if artifact_path:
|
|
7788
|
+
write_json(artifact_path, artifact_record)
|
|
4925
7789
|
interaction = self.interact(
|
|
4926
7790
|
quest_root,
|
|
4927
7791
|
kind="milestone",
|
|
@@ -4959,9 +7823,78 @@ class ArtifactService:
|
|
|
4959
7823
|
"need_research_paper": delivery_policy.get("need_research_paper"),
|
|
4960
7824
|
"recommended_next_route": delivery_policy.get("recommended_next_route"),
|
|
4961
7825
|
"evaluation_summary": normalized_evaluation_summary,
|
|
7826
|
+
"connector_metric_charts": metric_charts,
|
|
4962
7827
|
}
|
|
4963
7828
|
],
|
|
4964
7829
|
)
|
|
7830
|
+
chart_delivery = self._send_main_experiment_metric_charts(
|
|
7831
|
+
quest_root,
|
|
7832
|
+
run_id=run_identifier,
|
|
7833
|
+
title=title.strip() or run_identifier,
|
|
7834
|
+
charts=metric_charts,
|
|
7835
|
+
)
|
|
7836
|
+
outline_path, selected_outline = self._read_selected_outline_for_sync(quest_root)
|
|
7837
|
+
selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
|
|
7838
|
+
detailed_outline = (
|
|
7839
|
+
dict(selected_outline.get("detailed_outline") or {})
|
|
7840
|
+
if isinstance(selected_outline.get("detailed_outline"), dict)
|
|
7841
|
+
else {}
|
|
7842
|
+
)
|
|
7843
|
+
outline_sections = self._normalize_outline_sections(
|
|
7844
|
+
selected_outline.get("sections"),
|
|
7845
|
+
experimental_designs=self._normalize_string_list(detailed_outline.get("experimental_designs")),
|
|
7846
|
+
)
|
|
7847
|
+
section_id = next(
|
|
7848
|
+
(
|
|
7849
|
+
str(section.get("section_id") or "").strip()
|
|
7850
|
+
for section in outline_sections
|
|
7851
|
+
if run_identifier in self._normalize_string_list(section.get("required_items"))
|
|
7852
|
+
or run_identifier in self._normalize_string_list(section.get("optional_items"))
|
|
7853
|
+
),
|
|
7854
|
+
None,
|
|
7855
|
+
)
|
|
7856
|
+
if not section_id:
|
|
7857
|
+
section_id = next(
|
|
7858
|
+
(
|
|
7859
|
+
str(section.get("section_id") or "").strip()
|
|
7860
|
+
for section in outline_sections
|
|
7861
|
+
if str(section.get("paper_role") or "main_text").strip() == "main_text"
|
|
7862
|
+
),
|
|
7863
|
+
None,
|
|
7864
|
+
)
|
|
7865
|
+
if not section_id:
|
|
7866
|
+
section_id = str((outline_sections[0] or {}).get("section_id") or "").strip() if outline_sections else "main-results"
|
|
7867
|
+
paper_role = "main_text"
|
|
7868
|
+
self._upsert_paper_evidence_item(
|
|
7869
|
+
quest_root,
|
|
7870
|
+
{
|
|
7871
|
+
"item_id": run_identifier,
|
|
7872
|
+
"title": title.strip() or run_identifier,
|
|
7873
|
+
"kind": "main_experiment",
|
|
7874
|
+
"status": status,
|
|
7875
|
+
"paper_role": paper_role,
|
|
7876
|
+
"section_id": section_id,
|
|
7877
|
+
"claim_links": [],
|
|
7878
|
+
"setup": setup.strip() or None,
|
|
7879
|
+
"result_summary": results.strip() or conclusion.strip() or progress_eval.get("reason"),
|
|
7880
|
+
"key_metrics": self._paper_evidence_key_metrics(
|
|
7881
|
+
metric_rows=normalized_metric_rows,
|
|
7882
|
+
metrics_summary=normalized_metrics_summary,
|
|
7883
|
+
),
|
|
7884
|
+
"source_paths": [
|
|
7885
|
+
value
|
|
7886
|
+
for value in [
|
|
7887
|
+
self._workspace_relative(quest_root, run_md_path),
|
|
7888
|
+
self._workspace_relative(quest_root, result_json_path),
|
|
7889
|
+
*resolved_evidence_paths,
|
|
7890
|
+
]
|
|
7891
|
+
if value
|
|
7892
|
+
],
|
|
7893
|
+
"run_id": run_identifier,
|
|
7894
|
+
"selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
|
|
7895
|
+
"evaluation_summary": normalized_evaluation_summary,
|
|
7896
|
+
},
|
|
7897
|
+
)
|
|
4965
7898
|
self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="decision")
|
|
4966
7899
|
research_state = self.quest_service.update_research_state(
|
|
4967
7900
|
quest_root,
|
|
@@ -4995,6 +7928,8 @@ class ArtifactService:
|
|
|
4995
7928
|
"result_json_path": str(result_json_path),
|
|
4996
7929
|
"artifact": artifact,
|
|
4997
7930
|
"interaction": interaction,
|
|
7931
|
+
"connector_metric_charts": metric_charts,
|
|
7932
|
+
"connector_metric_chart_delivery": chart_delivery,
|
|
4998
7933
|
"research_state": research_state,
|
|
4999
7934
|
"metrics_summary": normalized_metrics_summary,
|
|
5000
7935
|
"baseline_comparisons": {
|
|
@@ -5095,6 +8030,45 @@ class ArtifactService:
|
|
|
5095
8030
|
"Writing-facing analysis campaigns require one todo item per slice. "
|
|
5096
8031
|
f"Missing todo items for: {', '.join(missing_slice_ids)}."
|
|
5097
8032
|
)
|
|
8033
|
+
missing_contract_fields: list[str] = []
|
|
8034
|
+
for item in normalized_todo_items:
|
|
8035
|
+
title = str(item.get("title") or item.get("slice_id") or item.get("todo_id") or "todo").strip() or "todo"
|
|
8036
|
+
for field in ("section_id", "item_id", "paper_role"):
|
|
8037
|
+
if str(item.get(field) or "").strip():
|
|
8038
|
+
continue
|
|
8039
|
+
missing_contract_fields.append(f"{title}:{field}")
|
|
8040
|
+
if not self._normalize_string_list(item.get("claim_links")):
|
|
8041
|
+
missing_contract_fields.append(f"{title}:claim_links")
|
|
8042
|
+
if missing_contract_fields:
|
|
8043
|
+
raise ValueError(
|
|
8044
|
+
"Writing-facing analysis campaigns require outline-bound paper contract fields for every todo item. "
|
|
8045
|
+
f"Missing: {', '.join(missing_contract_fields[:8])}."
|
|
8046
|
+
)
|
|
8047
|
+
paper_line_branch: str | None = None
|
|
8048
|
+
paper_line_root: str | None = None
|
|
8049
|
+
paper_line_id: str | None = None
|
|
8050
|
+
if writing_facing:
|
|
8051
|
+
paper_context = self._ensure_active_paper_workspace(
|
|
8052
|
+
quest_root,
|
|
8053
|
+
source_branch=parent_branch,
|
|
8054
|
+
source_run_id=resolved_parent_run_id,
|
|
8055
|
+
source_idea_id=active_idea_id,
|
|
8056
|
+
)
|
|
8057
|
+
paper_line_branch = str(paper_context.get("branch") or "").strip() or None
|
|
8058
|
+
paper_line_root = str(paper_context.get("worktree_root") or "").strip() or None
|
|
8059
|
+
paper_line_id = self._paper_line_id(
|
|
8060
|
+
paper_branch=paper_line_branch,
|
|
8061
|
+
outline_id=resolved_outline_ref,
|
|
8062
|
+
source_run_id=resolved_parent_run_id,
|
|
8063
|
+
)
|
|
8064
|
+
if paper_line_root:
|
|
8065
|
+
self._write_paper_line_state(
|
|
8066
|
+
quest_root,
|
|
8067
|
+
workspace_root=Path(paper_line_root),
|
|
8068
|
+
source_branch=parent_branch,
|
|
8069
|
+
source_run_id=resolved_parent_run_id,
|
|
8070
|
+
source_idea_id=active_idea_id,
|
|
8071
|
+
)
|
|
5098
8072
|
slice_contexts: list[dict[str, Any]] = []
|
|
5099
8073
|
inventory_entries: list[dict[str, Any]] = []
|
|
5100
8074
|
for index, raw in enumerate(slices, start=1):
|
|
@@ -5123,6 +8097,19 @@ class ArtifactService:
|
|
|
5123
8097
|
manuscript_targets = self._normalize_string_list(
|
|
5124
8098
|
raw.get("manuscript_targets") or matched_todo.get("manuscript_targets")
|
|
5125
8099
|
)
|
|
8100
|
+
section_id = str(raw.get("section_id") or matched_todo.get("section_id") or "").strip() or None
|
|
8101
|
+
item_id = str(raw.get("item_id") or matched_todo.get("item_id") or slice_id).strip() or slice_id
|
|
8102
|
+
claim_links = self._normalize_string_list(raw.get("claim_links") or matched_todo.get("claim_links"))
|
|
8103
|
+
paper_role = (
|
|
8104
|
+
str(raw.get("paper_role") or matched_todo.get("paper_role") or matched_todo.get("paper_placement") or "").strip()
|
|
8105
|
+
or None
|
|
8106
|
+
)
|
|
8107
|
+
paper_placement = (
|
|
8108
|
+
str(raw.get("paper_placement") or matched_todo.get("paper_placement") or paper_role or "").strip()
|
|
8109
|
+
or None
|
|
8110
|
+
)
|
|
8111
|
+
tier = str(raw.get("tier") or matched_todo.get("tier") or "").strip() or None
|
|
8112
|
+
exp_id = str(raw.get("exp_id") or matched_todo.get("exp_id") or "").strip() or None
|
|
5126
8113
|
why_now = str(raw.get("why_now") or matched_todo.get("why_now") or "").strip()
|
|
5127
8114
|
success_criteria = str(raw.get("success_criteria") or matched_todo.get("success_criteria") or "").strip()
|
|
5128
8115
|
abandonment_criteria = str(
|
|
@@ -5145,14 +8132,24 @@ class ArtifactService:
|
|
|
5145
8132
|
"",
|
|
5146
8133
|
str(raw.get("research_question") or matched_todo.get("research_question") or "").strip() or "TBD",
|
|
5147
8134
|
"",
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
8135
|
+
"## Experimental Design",
|
|
8136
|
+
"",
|
|
8137
|
+
str(raw.get("experimental_design") or matched_todo.get("experimental_design") or "").strip() or "TBD",
|
|
8138
|
+
"",
|
|
8139
|
+
"## Paper Contract Binding",
|
|
8140
|
+
"",
|
|
8141
|
+
f"- Section id: `{section_id or 'none'}`",
|
|
8142
|
+
f"- Item id: `{item_id}`",
|
|
8143
|
+
f"- Exp id: `{exp_id or 'none'}`",
|
|
8144
|
+
f"- Paper role: `{paper_role or 'none'}`",
|
|
8145
|
+
f"- Paper placement: `{paper_placement or 'none'}`",
|
|
8146
|
+
f"- Tier: `{tier or 'none'}`",
|
|
8147
|
+
f"- Claim links: {', '.join(claim_links) or 'none'}",
|
|
8148
|
+
"",
|
|
8149
|
+
"## Why Now",
|
|
8150
|
+
"",
|
|
8151
|
+
why_now or "TBD",
|
|
8152
|
+
"",
|
|
5156
8153
|
"## Hypothesis",
|
|
5157
8154
|
"",
|
|
5158
8155
|
str(raw.get("hypothesis") or "").strip() or "TBD",
|
|
@@ -5228,6 +8225,13 @@ class ArtifactService:
|
|
|
5228
8225
|
"experimental_design": str(
|
|
5229
8226
|
raw.get("experimental_design") or matched_todo.get("experimental_design") or ""
|
|
5230
8227
|
).strip(),
|
|
8228
|
+
"section_id": section_id,
|
|
8229
|
+
"item_id": item_id,
|
|
8230
|
+
"exp_id": exp_id,
|
|
8231
|
+
"paper_role": paper_role,
|
|
8232
|
+
"paper_placement": paper_placement,
|
|
8233
|
+
"tier": tier,
|
|
8234
|
+
"claim_links": claim_links,
|
|
5231
8235
|
"why_now": why_now,
|
|
5232
8236
|
"hypothesis": str(raw.get("hypothesis") or "").strip(),
|
|
5233
8237
|
"required_changes": str(raw.get("required_changes") or "").strip(),
|
|
@@ -5275,12 +8279,19 @@ class ArtifactService:
|
|
|
5275
8279
|
"experimental_designs": normalized_experimental_designs,
|
|
5276
8280
|
"todo_items": [
|
|
5277
8281
|
{
|
|
8282
|
+
"exp_id": str(item.get("exp_id") or context.get("exp_id") or "").strip() or None,
|
|
5278
8283
|
"todo_id": str(item.get("todo_id") or item.get("slice_id") or context["slice_id"]).strip() or context["slice_id"],
|
|
5279
8284
|
"slice_id": context["slice_id"],
|
|
5280
8285
|
"title": str(item.get("title") or context["title"]).strip() or context["title"],
|
|
5281
8286
|
"status": str(item.get("status") or "pending").strip() or "pending",
|
|
5282
8287
|
"research_question": item.get("research_question") or context.get("research_question"),
|
|
5283
8288
|
"experimental_design": item.get("experimental_design") or context.get("experimental_design"),
|
|
8289
|
+
"tier": item.get("tier") or context.get("tier"),
|
|
8290
|
+
"paper_placement": item.get("paper_placement") or context.get("paper_placement"),
|
|
8291
|
+
"paper_role": item.get("paper_role") or context.get("paper_role"),
|
|
8292
|
+
"section_id": item.get("section_id") or context.get("section_id"),
|
|
8293
|
+
"item_id": item.get("item_id") or context.get("item_id"),
|
|
8294
|
+
"claim_links": item.get("claim_links") or context.get("claim_links") or [],
|
|
5284
8295
|
"completion_condition": item.get("completion_condition") or context.get("completion_condition") or context.get("must_not_simplify"),
|
|
5285
8296
|
"why_now": item.get("why_now") or context.get("why_now"),
|
|
5286
8297
|
"success_criteria": item.get("success_criteria") or context.get("success_criteria"),
|
|
@@ -5339,6 +8350,11 @@ class ArtifactService:
|
|
|
5339
8350
|
f"- Goal: {item['goal'] or 'TBD'}",
|
|
5340
8351
|
f"- Research question: {item['research_question'] or 'TBD'}",
|
|
5341
8352
|
f"- Experimental design: {item['experimental_design'] or 'TBD'}",
|
|
8353
|
+
f"- Section id: `{item['section_id'] or 'none'}`",
|
|
8354
|
+
f"- Item id: `{item['item_id'] or 'none'}`",
|
|
8355
|
+
f"- Exp id: `{item['exp_id'] or 'none'}`",
|
|
8356
|
+
f"- Paper role: `{item['paper_role'] or 'none'}`",
|
|
8357
|
+
f"- Claim links: {', '.join(item['claim_links']) or 'none'}",
|
|
5342
8358
|
f"- Why now: {item['why_now'] or 'TBD'}",
|
|
5343
8359
|
f"- Required baselines: {', '.join(self._analysis_baseline_label(entry) for entry in item['required_baselines']) or 'none'}",
|
|
5344
8360
|
f"- Success criteria: {item['success_criteria'] or 'TBD'}",
|
|
@@ -5361,6 +8377,9 @@ class ArtifactService:
|
|
|
5361
8377
|
"active_idea_id": active_idea_id,
|
|
5362
8378
|
"parent_branch": parent_branch,
|
|
5363
8379
|
"parent_worktree_root": str(parent_worktree_root),
|
|
8380
|
+
"paper_line_id": paper_line_id,
|
|
8381
|
+
"paper_line_branch": paper_line_branch,
|
|
8382
|
+
"paper_line_root": paper_line_root,
|
|
5364
8383
|
"campaign_origin": normalized_campaign_origin,
|
|
5365
8384
|
"selected_outline_ref": resolved_outline_ref,
|
|
5366
8385
|
"research_questions": normalized_research_questions,
|
|
@@ -5398,6 +8417,13 @@ class ArtifactService:
|
|
|
5398
8417
|
"run_kind": item["run_kind"],
|
|
5399
8418
|
"research_question": item["research_question"],
|
|
5400
8419
|
"experimental_design": item["experimental_design"],
|
|
8420
|
+
"section_id": item["section_id"],
|
|
8421
|
+
"item_id": item["item_id"],
|
|
8422
|
+
"exp_id": item["exp_id"],
|
|
8423
|
+
"paper_role": item["paper_role"],
|
|
8424
|
+
"paper_placement": item["paper_placement"],
|
|
8425
|
+
"tier": item["tier"],
|
|
8426
|
+
"claim_links": item["claim_links"],
|
|
5401
8427
|
"why_now": item["why_now"],
|
|
5402
8428
|
"completion_condition": item["completion_condition"] or item["must_not_simplify"],
|
|
5403
8429
|
"must_not_simplify": item["must_not_simplify"],
|
|
@@ -5433,6 +8459,9 @@ class ArtifactService:
|
|
|
5433
8459
|
"campaign_title": campaign_title,
|
|
5434
8460
|
"campaign_goal": campaign_goal,
|
|
5435
8461
|
"parent_run_id": resolved_parent_run_id,
|
|
8462
|
+
"paper_line_id": paper_line_id,
|
|
8463
|
+
"paper_line_branch": paper_line_branch,
|
|
8464
|
+
"paper_line_root": paper_line_root,
|
|
5436
8465
|
"campaign_origin": normalized_campaign_origin,
|
|
5437
8466
|
"selected_outline_ref": resolved_outline_ref,
|
|
5438
8467
|
"todo_manifest_path": str(todo_manifest_path),
|
|
@@ -5447,6 +8476,13 @@ class ArtifactService:
|
|
|
5447
8476
|
"goal": item["goal"],
|
|
5448
8477
|
"research_question": item["research_question"],
|
|
5449
8478
|
"experimental_design": item["experimental_design"],
|
|
8479
|
+
"section_id": item["section_id"],
|
|
8480
|
+
"item_id": item["item_id"],
|
|
8481
|
+
"exp_id": item["exp_id"],
|
|
8482
|
+
"paper_role": item["paper_role"],
|
|
8483
|
+
"paper_placement": item["paper_placement"],
|
|
8484
|
+
"tier": item["tier"],
|
|
8485
|
+
"claim_links": item["claim_links"],
|
|
5450
8486
|
"why_now": item["why_now"],
|
|
5451
8487
|
"completion_condition": item["completion_condition"] or item["must_not_simplify"],
|
|
5452
8488
|
"must_not_simplify": item["must_not_simplify"],
|
|
@@ -5463,6 +8499,32 @@ class ArtifactService:
|
|
|
5463
8499
|
checkpoint=False,
|
|
5464
8500
|
workspace_root=parent_worktree_root,
|
|
5465
8501
|
)
|
|
8502
|
+
if writing_facing:
|
|
8503
|
+
self._sync_outline_sections(
|
|
8504
|
+
quest_root,
|
|
8505
|
+
items=[
|
|
8506
|
+
{
|
|
8507
|
+
"item_id": item.get("item_id"),
|
|
8508
|
+
"title": item.get("title"),
|
|
8509
|
+
"kind": "analysis_slice",
|
|
8510
|
+
"paper_role": item.get("paper_role"),
|
|
8511
|
+
"status": "pending",
|
|
8512
|
+
"claim_links": item.get("claim_links"),
|
|
8513
|
+
"section_id": item.get("section_id"),
|
|
8514
|
+
"source_paths": [self._workspace_relative(quest_root, Path(item["plan_path"]))] if item.get("plan_path") else [],
|
|
8515
|
+
}
|
|
8516
|
+
for item in slice_contexts
|
|
8517
|
+
],
|
|
8518
|
+
workspace_root=Path(paper_line_root) if paper_line_root else None,
|
|
8519
|
+
)
|
|
8520
|
+
if paper_line_root:
|
|
8521
|
+
self._write_paper_line_state(
|
|
8522
|
+
quest_root,
|
|
8523
|
+
workspace_root=Path(paper_line_root),
|
|
8524
|
+
source_branch=parent_branch,
|
|
8525
|
+
source_run_id=resolved_parent_run_id,
|
|
8526
|
+
source_idea_id=active_idea_id,
|
|
8527
|
+
)
|
|
5466
8528
|
research_state = self.quest_service.update_research_state(
|
|
5467
8529
|
quest_root,
|
|
5468
8530
|
active_idea_id=active_idea_id,
|
|
@@ -5568,9 +8630,7 @@ class ArtifactService:
|
|
|
5568
8630
|
selected_outline_path = paper_root / "selected_outline.json"
|
|
5569
8631
|
else:
|
|
5570
8632
|
selected_outline_path = self._paper_selected_outline_path(quest_root, workspace_root=workspace_root)
|
|
5571
|
-
existing_selected =
|
|
5572
|
-
if not isinstance(existing_selected, dict) or not existing_selected:
|
|
5573
|
-
existing_selected = read_json(quest_root / "paper" / "selected_outline.json", {})
|
|
8633
|
+
_, existing_selected = self._read_selected_outline_record(quest_root, workspace_root=workspace_root)
|
|
5574
8634
|
existing_selected = existing_selected if isinstance(existing_selected, dict) else {}
|
|
5575
8635
|
if normalized_mode == "candidate":
|
|
5576
8636
|
resolved_outline_id = str(outline_id or self._next_paper_outline_id(quest_root)).strip()
|
|
@@ -5650,10 +8710,8 @@ class ArtifactService:
|
|
|
5650
8710
|
created_at=str(source_record.get("created_at") or "") or None,
|
|
5651
8711
|
)
|
|
5652
8712
|
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
if canonical_selected_outline_path.resolve() != selected_outline_path.resolve():
|
|
5656
|
-
write_json(canonical_selected_outline_path, resolved_record)
|
|
8713
|
+
self._write_selected_outline_sync(quest_root, resolved_record, workspace_root=workspace_root)
|
|
8714
|
+
selected_outline_path = paper_root / "selected_outline.json"
|
|
5657
8715
|
if source_candidate_path.exists():
|
|
5658
8716
|
source_record["status"] = "selected" if normalized_mode == "select" else "revised"
|
|
5659
8717
|
source_record["updated_at"] = utc_now()
|
|
@@ -5682,6 +8740,13 @@ class ArtifactService:
|
|
|
5682
8740
|
"",
|
|
5683
8741
|
]
|
|
5684
8742
|
write_text(outline_selection_path, "\n".join(selection_lines).rstrip() + "\n")
|
|
8743
|
+
paper_line_state = self._write_paper_line_state(
|
|
8744
|
+
quest_root,
|
|
8745
|
+
workspace_root=workspace_root,
|
|
8746
|
+
source_branch=str(paper_context.get("source_branch") or "").strip() or None,
|
|
8747
|
+
source_run_id=str(paper_context.get("source_run_id") or "").strip() or None,
|
|
8748
|
+
source_idea_id=str(paper_context.get("source_idea_id") or "").strip() or None,
|
|
8749
|
+
)
|
|
5685
8750
|
self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="write")
|
|
5686
8751
|
artifact = self.record(
|
|
5687
8752
|
quest_root,
|
|
@@ -5695,13 +8760,17 @@ class ArtifactService:
|
|
|
5695
8760
|
"protocol_step": "select" if normalized_mode == "select" else "revise",
|
|
5696
8761
|
"paths": {
|
|
5697
8762
|
"selected_outline_json": str(selected_outline_path),
|
|
8763
|
+
"outline_manifest_json": str(self._paper_outline_manifest_path(quest_root, workspace_root=workspace_root)),
|
|
5698
8764
|
"outline_selection_md": str(outline_selection_path),
|
|
8765
|
+
"paper_line_state_json": str(self._paper_line_state_path(quest_root, workspace_root=workspace_root)),
|
|
5699
8766
|
**({"revised_outline_json": str(revised_outline_path)} if revised_outline_path else {}),
|
|
5700
8767
|
},
|
|
5701
8768
|
"details": {
|
|
5702
8769
|
"outline_id": source_outline_id,
|
|
5703
8770
|
"title": resolved_record.get("title"),
|
|
5704
8771
|
"selected_reason": selected_reason,
|
|
8772
|
+
"paper_line_id": paper_line_state.get("paper_line_id"),
|
|
8773
|
+
"paper_branch": paper_line_state.get("paper_branch"),
|
|
5705
8774
|
},
|
|
5706
8775
|
},
|
|
5707
8776
|
checkpoint=False,
|
|
@@ -5742,6 +8811,8 @@ class ArtifactService:
|
|
|
5742
8811
|
"title": resolved_record.get("title"),
|
|
5743
8812
|
"selected_reason": selected_reason,
|
|
5744
8813
|
"selected_outline_path": str(selected_outline_path),
|
|
8814
|
+
"outline_manifest_path": str(self._paper_outline_manifest_path(quest_root, workspace_root=workspace_root)),
|
|
8815
|
+
"paper_line_state_path": str(self._paper_line_state_path(quest_root, workspace_root=workspace_root)),
|
|
5745
8816
|
"outline_selection_path": str(outline_selection_path),
|
|
5746
8817
|
"revised_outline_path": str(revised_outline_path) if revised_outline_path else None,
|
|
5747
8818
|
}
|
|
@@ -5752,9 +8823,12 @@ class ArtifactService:
|
|
|
5752
8823
|
"mode": normalized_mode,
|
|
5753
8824
|
"outline_id": source_outline_id,
|
|
5754
8825
|
"selected_outline_path": str(selected_outline_path),
|
|
8826
|
+
"outline_manifest_path": str(self._paper_outline_manifest_path(quest_root, workspace_root=workspace_root)),
|
|
8827
|
+
"paper_line_state_path": str(self._paper_line_state_path(quest_root, workspace_root=workspace_root)),
|
|
5755
8828
|
"outline_selection_path": str(outline_selection_path),
|
|
5756
8829
|
"revised_outline_path": str(revised_outline_path) if revised_outline_path else None,
|
|
5757
8830
|
"record": resolved_record,
|
|
8831
|
+
"paper_line_state": paper_line_state,
|
|
5758
8832
|
"artifact": artifact,
|
|
5759
8833
|
"interaction": interaction,
|
|
5760
8834
|
}
|
|
@@ -5773,36 +8847,78 @@ class ArtifactService:
|
|
|
5773
8847
|
compile_report_path: str | None = None,
|
|
5774
8848
|
pdf_path: str | None = None,
|
|
5775
8849
|
latex_root_path: str | None = None,
|
|
8850
|
+
prepare_open_source: bool = False,
|
|
5776
8851
|
) -> dict[str, Any]:
|
|
5777
8852
|
paper_context = self._ensure_active_paper_workspace(quest_root)
|
|
5778
8853
|
workspace_root = Path(str(paper_context.get("worktree_root") or self._workspace_root_for(quest_root)))
|
|
5779
8854
|
paper_root = self._paper_root(quest_root, workspace_root=workspace_root, create=True)
|
|
5780
|
-
selected_outline_path = self.
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
selected_outline = read_json(fallback_selected_outline_path, {})
|
|
5785
|
-
if isinstance(selected_outline, dict) and selected_outline:
|
|
5786
|
-
selected_outline_path = fallback_selected_outline_path
|
|
8855
|
+
selected_outline_path, selected_outline = self._read_selected_outline_record(
|
|
8856
|
+
quest_root,
|
|
8857
|
+
workspace_root=workspace_root,
|
|
8858
|
+
)
|
|
5787
8859
|
selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
|
|
5788
8860
|
if not selected_outline and not str(outline_path or "").strip():
|
|
5789
8861
|
raise ValueError("submit_paper_bundle requires a selected outline or explicit `outline_path`.")
|
|
8862
|
+
gate_status = self._paper_bundle_gate_status(quest_root, workspace_root=workspace_root)
|
|
8863
|
+
if gate_status.get("unresolved_required_items") or gate_status.get("unmapped_completed_items"):
|
|
8864
|
+
problems: list[str] = []
|
|
8865
|
+
if gate_status.get("unresolved_required_items"):
|
|
8866
|
+
preview = [
|
|
8867
|
+
f"{item.get('section_id') or 'section'}:{item.get('item_id') or 'item'}"
|
|
8868
|
+
for item in (gate_status.get("unresolved_required_items") or [])[:5]
|
|
8869
|
+
if isinstance(item, dict)
|
|
8870
|
+
]
|
|
8871
|
+
problems.append(
|
|
8872
|
+
"unresolved required outline items"
|
|
8873
|
+
+ (f" ({', '.join(preview)})" if preview else "")
|
|
8874
|
+
)
|
|
8875
|
+
if gate_status.get("unmapped_completed_items"):
|
|
8876
|
+
preview = [
|
|
8877
|
+
f"{item.get('campaign_id') or 'analysis'}:{item.get('slice_id') or item.get('item_id') or 'slice'}"
|
|
8878
|
+
for item in (gate_status.get("unmapped_completed_items") or [])[:5]
|
|
8879
|
+
if isinstance(item, dict)
|
|
8880
|
+
]
|
|
8881
|
+
problems.append(
|
|
8882
|
+
"completed analysis results still unmapped into the paper contract"
|
|
8883
|
+
+ (f" ({', '.join(preview)})" if preview else "")
|
|
8884
|
+
)
|
|
8885
|
+
raise ValueError(
|
|
8886
|
+
"submit_paper_bundle blocked because the paper evidence contract is incomplete: "
|
|
8887
|
+
+ "; ".join(problems)
|
|
8888
|
+
+ "."
|
|
8889
|
+
)
|
|
5790
8890
|
|
|
5791
8891
|
manifest_path = self._paper_bundle_manifest_path(quest_root, workspace_root=workspace_root)
|
|
5792
8892
|
baseline_inventory = self._write_paper_baseline_inventory(quest_root, workspace_root=workspace_root)
|
|
5793
8893
|
baseline_inventory_path = self._paper_baseline_inventory_path(quest_root, workspace_root=workspace_root)
|
|
8894
|
+
evidence_ledger_path = self._paper_evidence_ledger_path(quest_root)
|
|
8895
|
+
if not evidence_ledger_path.exists():
|
|
8896
|
+
self._write_paper_evidence_ledger(
|
|
8897
|
+
quest_root,
|
|
8898
|
+
{
|
|
8899
|
+
"selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
|
|
8900
|
+
"items": [],
|
|
8901
|
+
},
|
|
8902
|
+
workspace_root=workspace_root,
|
|
8903
|
+
)
|
|
8904
|
+
experiment_matrix_path = paper_root / "paper_experiment_matrix.md"
|
|
8905
|
+
experiment_matrix_json_path = self._paper_experiment_matrix_json_path(quest_root, workspace_root=workspace_root)
|
|
5794
8906
|
source_branch = str(paper_context.get("source_branch") or "").strip() or None
|
|
5795
8907
|
paper_branch = str(paper_context.get("branch") or "").strip() or current_branch(workspace_root)
|
|
5796
8908
|
source_run_id = str(paper_context.get("source_run_id") or "").strip() or None
|
|
5797
8909
|
source_idea_id = str(paper_context.get("source_idea_id") or "").strip() or None
|
|
5798
8910
|
paper_manifest_rel = self._workspace_relative(quest_root, manifest_path) or "paper/paper_bundle_manifest.json"
|
|
5799
8911
|
paper_inventory_rel = self._workspace_relative(quest_root, baseline_inventory_path) or "paper/baseline_inventory.json"
|
|
5800
|
-
open_source_manifest =
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
8912
|
+
open_source_manifest = (
|
|
8913
|
+
self._ensure_open_source_prep(
|
|
8914
|
+
quest_root,
|
|
8915
|
+
workspace_root=workspace_root,
|
|
8916
|
+
source_branch=source_branch,
|
|
8917
|
+
source_bundle_manifest_path=paper_manifest_rel,
|
|
8918
|
+
baseline_inventory_path=paper_inventory_rel,
|
|
8919
|
+
)
|
|
8920
|
+
if prepare_open_source
|
|
8921
|
+
else {}
|
|
5806
8922
|
)
|
|
5807
8923
|
default_draft_path = self._workspace_relative(quest_root, paper_root / "draft.md") or "paper/draft.md"
|
|
5808
8924
|
default_writing_plan_path = self._workspace_relative(quest_root, paper_root / "writing_plan.md") or "paper/writing_plan.md"
|
|
@@ -5838,23 +8954,53 @@ class ArtifactService:
|
|
|
5838
8954
|
"writing_plan_path": str(writing_plan_path or default_writing_plan_path).strip() or None,
|
|
5839
8955
|
"references_path": str(references_path or default_references_path).strip() or None,
|
|
5840
8956
|
"claim_evidence_map_path": str(claim_evidence_map_path or default_claim_map_path).strip() or None,
|
|
8957
|
+
"evidence_ledger_path": self._workspace_relative(quest_root, evidence_ledger_path) or "paper/evidence_ledger.json",
|
|
8958
|
+
"experiment_matrix_path": (
|
|
8959
|
+
self._workspace_relative(quest_root, experiment_matrix_path) if experiment_matrix_path.exists() else None
|
|
8960
|
+
),
|
|
8961
|
+
"experiment_matrix_json_path": (
|
|
8962
|
+
self._workspace_relative(quest_root, experiment_matrix_json_path)
|
|
8963
|
+
if experiment_matrix_json_path.exists()
|
|
8964
|
+
else None
|
|
8965
|
+
),
|
|
5841
8966
|
"compile_report_path": str(compile_report_path or default_compile_report_path).strip() or None,
|
|
5842
8967
|
"pdf_path": str(pdf_path or "").strip() or None,
|
|
5843
8968
|
"latex_root_path": normalized_latex_root_path,
|
|
5844
8969
|
"baseline_inventory_path": paper_inventory_rel,
|
|
5845
|
-
"
|
|
5846
|
-
|
|
5847
|
-
self.
|
|
8970
|
+
"prepare_open_source": bool(prepare_open_source),
|
|
8971
|
+
"open_source_manifest_path": (
|
|
8972
|
+
self._workspace_relative(
|
|
8973
|
+
quest_root,
|
|
8974
|
+
self._open_source_manifest_path(quest_root, workspace_root=workspace_root),
|
|
8975
|
+
)
|
|
8976
|
+
if prepare_open_source
|
|
8977
|
+
else None
|
|
8978
|
+
),
|
|
8979
|
+
"open_source_cleanup_plan_path": (
|
|
8980
|
+
str(open_source_manifest.get("cleanup_plan_path") or "").strip() or None
|
|
5848
8981
|
)
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
or "release/open_source/cleanup_plan.md",
|
|
8982
|
+
if prepare_open_source
|
|
8983
|
+
else None,
|
|
5852
8984
|
"selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
|
|
8985
|
+
"evidence_gate": gate_status,
|
|
5853
8986
|
"created_at": utc_now(),
|
|
5854
8987
|
"updated_at": utc_now(),
|
|
5855
8988
|
}
|
|
5856
8989
|
write_json(manifest_path, manifest)
|
|
8990
|
+
paper_line_state = self._write_paper_line_state(
|
|
8991
|
+
quest_root,
|
|
8992
|
+
workspace_root=workspace_root,
|
|
8993
|
+
source_branch=source_branch,
|
|
8994
|
+
source_run_id=source_run_id,
|
|
8995
|
+
source_idea_id=source_idea_id,
|
|
8996
|
+
)
|
|
5857
8997
|
self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="finalize")
|
|
8998
|
+
self.quest_service.set_continuation_state(
|
|
8999
|
+
quest_root,
|
|
9000
|
+
policy="wait_for_user_or_resume",
|
|
9001
|
+
anchor="decision",
|
|
9002
|
+
reason="paper_bundle_submitted",
|
|
9003
|
+
)
|
|
5858
9004
|
artifact = self.record(
|
|
5859
9005
|
quest_root,
|
|
5860
9006
|
{
|
|
@@ -5870,17 +9016,27 @@ class ArtifactService:
|
|
|
5870
9016
|
"outline_path": manifest.get("outline_path"),
|
|
5871
9017
|
"draft_path": manifest.get("draft_path"),
|
|
5872
9018
|
"pdf_path": manifest.get("pdf_path"),
|
|
9019
|
+
"evidence_ledger_path": str(evidence_ledger_path) if evidence_ledger_path.exists() else None,
|
|
5873
9020
|
"baseline_inventory_path": str(baseline_inventory_path),
|
|
5874
|
-
"open_source_manifest_path":
|
|
9021
|
+
"open_source_manifest_path": (
|
|
9022
|
+
str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root))
|
|
9023
|
+
if prepare_open_source
|
|
9024
|
+
else None
|
|
9025
|
+
),
|
|
5875
9026
|
},
|
|
5876
9027
|
"details": {
|
|
5877
9028
|
"title": manifest.get("title"),
|
|
5878
9029
|
"selected_outline_ref": manifest.get("selected_outline_ref"),
|
|
9030
|
+
"ready_section_count": gate_status.get("ready_section_count"),
|
|
9031
|
+
"section_count": gate_status.get("section_count"),
|
|
9032
|
+
"ledger_item_count": gate_status.get("ledger_item_count"),
|
|
9033
|
+
"paper_line_id": paper_line_state.get("paper_line_id"),
|
|
5879
9034
|
"baseline_inventory_count": len(baseline_inventory.get("supplementary_baselines") or []),
|
|
5880
|
-
"open_source_status": open_source_manifest.get("status"),
|
|
9035
|
+
"open_source_status": open_source_manifest.get("status") if prepare_open_source else None,
|
|
5881
9036
|
"paper_branch": paper_branch,
|
|
5882
9037
|
"source_branch": source_branch,
|
|
5883
9038
|
"source_run_id": source_run_id,
|
|
9039
|
+
"prepare_open_source": bool(prepare_open_source),
|
|
5884
9040
|
},
|
|
5885
9041
|
},
|
|
5886
9042
|
checkpoint=False,
|
|
@@ -5901,6 +9057,7 @@ class ArtifactService:
|
|
|
5901
9057
|
writing_plan_rel_path=str(manifest.get("writing_plan_path") or "").strip() or None,
|
|
5902
9058
|
references_rel_path=str(manifest.get("references_path") or "").strip() or None,
|
|
5903
9059
|
claim_evidence_map_rel_path=str(manifest.get("claim_evidence_map_path") or "").strip() or None,
|
|
9060
|
+
evidence_ledger_rel_path=str(manifest.get("evidence_ledger_path") or "").strip() or None,
|
|
5904
9061
|
compile_report_rel_path=str(manifest.get("compile_report_path") or "").strip() or None,
|
|
5905
9062
|
pdf_rel_path=str(manifest.get("pdf_path") or "").strip() or None,
|
|
5906
9063
|
latex_root_rel_path=str(manifest.get("latex_root_path") or "").strip() or None,
|
|
@@ -5922,11 +9079,17 @@ class ArtifactService:
|
|
|
5922
9079
|
"writing_plan_path": manifest.get("writing_plan_path"),
|
|
5923
9080
|
"references_path": manifest.get("references_path"),
|
|
5924
9081
|
"claim_evidence_map_path": manifest.get("claim_evidence_map_path"),
|
|
9082
|
+
"evidence_ledger_path": manifest.get("evidence_ledger_path"),
|
|
5925
9083
|
"compile_report_path": manifest.get("compile_report_path"),
|
|
5926
9084
|
"pdf_path": manifest.get("pdf_path"),
|
|
5927
9085
|
"latex_root_path": manifest.get("latex_root_path"),
|
|
5928
9086
|
"baseline_inventory_path": str(baseline_inventory_path),
|
|
5929
|
-
"
|
|
9087
|
+
"prepare_open_source": bool(prepare_open_source),
|
|
9088
|
+
"open_source_manifest_path": (
|
|
9089
|
+
str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root))
|
|
9090
|
+
if prepare_open_source
|
|
9091
|
+
else None
|
|
9092
|
+
),
|
|
5930
9093
|
}
|
|
5931
9094
|
],
|
|
5932
9095
|
)
|
|
@@ -5935,7 +9098,14 @@ class ArtifactService:
|
|
|
5935
9098
|
"manifest_path": str(manifest_path),
|
|
5936
9099
|
"manifest": manifest,
|
|
5937
9100
|
"baseline_inventory_path": str(baseline_inventory_path),
|
|
5938
|
-
"
|
|
9101
|
+
"evidence_ledger_path": str(evidence_ledger_path),
|
|
9102
|
+
"paper_line_state_path": str(self._paper_line_state_path(quest_root, workspace_root=workspace_root)),
|
|
9103
|
+
"paper_line_state": paper_line_state,
|
|
9104
|
+
"open_source_manifest_path": (
|
|
9105
|
+
str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root))
|
|
9106
|
+
if prepare_open_source
|
|
9107
|
+
else None
|
|
9108
|
+
),
|
|
5939
9109
|
"artifact": artifact,
|
|
5940
9110
|
"interaction": interaction,
|
|
5941
9111
|
}
|
|
@@ -6079,10 +9249,18 @@ class ArtifactService:
|
|
|
6079
9249
|
"result_kind": "analysis_slice",
|
|
6080
9250
|
"campaign_id": campaign_id,
|
|
6081
9251
|
"slice_id": slice_id,
|
|
9252
|
+
"selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
|
|
6082
9253
|
"status": status,
|
|
6083
9254
|
"title": target.get("title"),
|
|
6084
9255
|
"goal": target.get("goal"),
|
|
6085
9256
|
"run_kind": target.get("run_kind"),
|
|
9257
|
+
"exp_id": target.get("exp_id"),
|
|
9258
|
+
"section_id": target.get("section_id"),
|
|
9259
|
+
"item_id": target.get("item_id"),
|
|
9260
|
+
"paper_role": target.get("paper_role"),
|
|
9261
|
+
"paper_placement": target.get("paper_placement"),
|
|
9262
|
+
"tier": target.get("tier"),
|
|
9263
|
+
"claim_links": target.get("claim_links") or [],
|
|
6086
9264
|
"required_baselines": target.get("required_baselines") or [],
|
|
6087
9265
|
"comparison_baselines": normalized_comparison_baselines,
|
|
6088
9266
|
"metrics_summary": normalized_metrics_summary,
|
|
@@ -6120,6 +9298,15 @@ class ArtifactService:
|
|
|
6120
9298
|
"",
|
|
6121
9299
|
str(target.get("goal") or "").strip() or "TBD",
|
|
6122
9300
|
"",
|
|
9301
|
+
"## Paper Contract Binding",
|
|
9302
|
+
"",
|
|
9303
|
+
f"- Selected outline: `{str(manifest.get('selected_outline_ref') or 'none').strip() or 'none'}`",
|
|
9304
|
+
f"- Section id: `{str(target.get('section_id') or 'none').strip() or 'none'}`",
|
|
9305
|
+
f"- Item id: `{str(target.get('item_id') or slice_id).strip() or slice_id}`",
|
|
9306
|
+
f"- Exp id: `{str(target.get('exp_id') or 'none').strip() or 'none'}`",
|
|
9307
|
+
f"- Paper role: `{str(target.get('paper_role') or 'none').strip() or 'none'}`",
|
|
9308
|
+
f"- Claim links: {', '.join(str(value).strip() for value in (target.get('claim_links') or []) if str(value).strip()) or 'none'}",
|
|
9309
|
+
"",
|
|
6123
9310
|
"## Core Requirement",
|
|
6124
9311
|
"",
|
|
6125
9312
|
str(target.get("must_not_simplify") or "").strip() or "Full protocol only.",
|
|
@@ -6191,6 +9378,14 @@ class ArtifactService:
|
|
|
6191
9378
|
"must_not_simplify": target.get("must_not_simplify"),
|
|
6192
9379
|
"dataset_scope": normalized_scope,
|
|
6193
9380
|
"subset_approval_ref": subset_approval_ref,
|
|
9381
|
+
"selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
|
|
9382
|
+
"section_id": target.get("section_id"),
|
|
9383
|
+
"item_id": target.get("item_id"),
|
|
9384
|
+
"exp_id": target.get("exp_id"),
|
|
9385
|
+
"paper_role": target.get("paper_role"),
|
|
9386
|
+
"paper_placement": target.get("paper_placement"),
|
|
9387
|
+
"tier": target.get("tier"),
|
|
9388
|
+
"claim_links": target.get("claim_links") or [],
|
|
6194
9389
|
"metric_rows": normalized_metric_rows,
|
|
6195
9390
|
"claim_impact": normalized_claim_impact,
|
|
6196
9391
|
"reviewer_resolution": normalized_reviewer_resolution,
|
|
@@ -6227,6 +9422,7 @@ class ArtifactService:
|
|
|
6227
9422
|
updated["result_path"] = str(result_path)
|
|
6228
9423
|
updated["result_json_path"] = str(result_json_path)
|
|
6229
9424
|
updated["mirror_path"] = str(mirror_path)
|
|
9425
|
+
updated["selected_outline_ref"] = str(manifest.get("selected_outline_ref") or "").strip() or None
|
|
6230
9426
|
updated["claim_impact"] = normalized_claim_impact
|
|
6231
9427
|
updated["reviewer_resolution"] = normalized_reviewer_resolution
|
|
6232
9428
|
updated["manuscript_update_hint"] = normalized_manuscript_update_hint
|
|
@@ -6245,6 +9441,49 @@ class ArtifactService:
|
|
|
6245
9441
|
"slices": updated_slices,
|
|
6246
9442
|
},
|
|
6247
9443
|
)
|
|
9444
|
+
paper_line_root = str(manifest.get("paper_line_root") or "").strip() or None
|
|
9445
|
+
self._upsert_paper_evidence_item(
|
|
9446
|
+
quest_root,
|
|
9447
|
+
{
|
|
9448
|
+
"item_id": str(target.get("item_id") or slice_id).strip() or slice_id,
|
|
9449
|
+
"title": str(target.get("title") or slice_id).strip() or slice_id,
|
|
9450
|
+
"kind": "analysis_slice",
|
|
9451
|
+
"status": status,
|
|
9452
|
+
"paper_role": str(target.get("paper_role") or target.get("paper_placement") or "").strip() or None,
|
|
9453
|
+
"section_id": str(target.get("section_id") or "").strip() or None,
|
|
9454
|
+
"claim_links": self._normalize_string_list(target.get("claim_links")),
|
|
9455
|
+
"setup": setup.strip() or None,
|
|
9456
|
+
"result_summary": results.strip() or normalized_claim_impact or normalized_manuscript_update_hint or None,
|
|
9457
|
+
"key_metrics": self._paper_evidence_key_metrics(
|
|
9458
|
+
metric_rows=normalized_metric_rows,
|
|
9459
|
+
metrics_summary=normalized_metrics_summary,
|
|
9460
|
+
),
|
|
9461
|
+
"source_paths": [
|
|
9462
|
+
value
|
|
9463
|
+
for value in [
|
|
9464
|
+
self._workspace_relative(quest_root, result_path),
|
|
9465
|
+
self._workspace_relative(quest_root, result_json_path),
|
|
9466
|
+
self._workspace_relative(quest_root, mirror_path),
|
|
9467
|
+
*evidence_paths,
|
|
9468
|
+
]
|
|
9469
|
+
if value
|
|
9470
|
+
],
|
|
9471
|
+
"campaign_id": campaign_id,
|
|
9472
|
+
"slice_id": slice_id,
|
|
9473
|
+
"selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
|
|
9474
|
+
"evaluation_summary": normalized_evaluation_summary,
|
|
9475
|
+
"claim_impact": normalized_claim_impact,
|
|
9476
|
+
},
|
|
9477
|
+
workspace_root=Path(paper_line_root) if paper_line_root else None,
|
|
9478
|
+
)
|
|
9479
|
+
if paper_line_root:
|
|
9480
|
+
self._write_paper_line_state(
|
|
9481
|
+
quest_root,
|
|
9482
|
+
workspace_root=Path(paper_line_root),
|
|
9483
|
+
source_branch=str(manifest.get("parent_branch") or "").strip() or None,
|
|
9484
|
+
source_run_id=str(manifest.get("parent_run_id") or "").strip() or None,
|
|
9485
|
+
source_idea_id=str(manifest.get("active_idea_id") or "").strip() or None,
|
|
9486
|
+
)
|
|
6248
9487
|
baseline_inventory = (
|
|
6249
9488
|
self._upsert_analysis_baseline_inventory(
|
|
6250
9489
|
quest_root,
|
|
@@ -6837,6 +10076,7 @@ class ArtifactService:
|
|
|
6837
10076
|
"source_mode": source_mode,
|
|
6838
10077
|
"comment": comment,
|
|
6839
10078
|
},
|
|
10079
|
+
"startup_contract": self._startup_contract(quest_root) or None,
|
|
6840
10080
|
"source": {"kind": "system", "role": "artifact"},
|
|
6841
10081
|
},
|
|
6842
10082
|
checkpoint=True,
|
|
@@ -6856,7 +10096,7 @@ class ArtifactService:
|
|
|
6856
10096
|
quest_root,
|
|
6857
10097
|
baseline_gate="confirmed",
|
|
6858
10098
|
confirmed_baseline_ref=confirmed_ref,
|
|
6859
|
-
active_anchor=
|
|
10099
|
+
active_anchor=self._post_baseline_anchor(quest_root) if auto_advance else "baseline",
|
|
6860
10100
|
)
|
|
6861
10101
|
registry_entry = self._sync_confirmed_baseline_registry_entry(
|
|
6862
10102
|
quest_root=quest_root,
|
|
@@ -6914,6 +10154,7 @@ class ArtifactService:
|
|
|
6914
10154
|
"baseline_gate": "waived",
|
|
6915
10155
|
"comment": comment,
|
|
6916
10156
|
},
|
|
10157
|
+
"startup_contract": self._startup_contract(quest_root) or None,
|
|
6917
10158
|
"source": {"kind": "system", "role": "artifact"},
|
|
6918
10159
|
},
|
|
6919
10160
|
checkpoint=True,
|
|
@@ -6922,7 +10163,7 @@ class ArtifactService:
|
|
|
6922
10163
|
quest_root,
|
|
6923
10164
|
baseline_gate="waived",
|
|
6924
10165
|
confirmed_baseline_ref=None,
|
|
6925
|
-
active_anchor=
|
|
10166
|
+
active_anchor=self._post_baseline_anchor(quest_root) if auto_advance else "baseline",
|
|
6926
10167
|
)
|
|
6927
10168
|
return {
|
|
6928
10169
|
"ok": True,
|
|
@@ -7021,6 +10262,7 @@ class ArtifactService:
|
|
|
7021
10262
|
*,
|
|
7022
10263
|
kind: str = "progress",
|
|
7023
10264
|
message: str = "",
|
|
10265
|
+
summary_preview: str | None = None,
|
|
7024
10266
|
response_phase: str = "ack",
|
|
7025
10267
|
importance: str = "info",
|
|
7026
10268
|
deliver_to_bound_conversations: bool = True,
|
|
@@ -7037,13 +10279,23 @@ class ArtifactService:
|
|
|
7037
10279
|
reply_schema: dict[str, Any] | None = None,
|
|
7038
10280
|
reply_to_interaction_id: str | None = None,
|
|
7039
10281
|
supersede_open_requests: bool = True,
|
|
10282
|
+
dedupe_key: str | None = None,
|
|
10283
|
+
suppress_if_unchanged: bool | None = None,
|
|
10284
|
+
min_interval_seconds: int | None = None,
|
|
7040
10285
|
) -> dict:
|
|
7041
10286
|
durable_kind = {
|
|
7042
10287
|
"progress": "progress",
|
|
10288
|
+
"answer": "answer",
|
|
7043
10289
|
"milestone": "milestone",
|
|
7044
10290
|
"decision_request": "decision",
|
|
7045
10291
|
"approval_result": "approval",
|
|
7046
10292
|
}.get(kind, "progress")
|
|
10293
|
+
full_message = str(message or "").strip()
|
|
10294
|
+
summary_preview_resolved = (
|
|
10295
|
+
self._summary_preview_text(summary_preview, limit=220)
|
|
10296
|
+
if summary_preview is not None
|
|
10297
|
+
else self._summary_preview_text(full_message, limit=220)
|
|
10298
|
+
)
|
|
7047
10299
|
options_resolved = options or []
|
|
7048
10300
|
surface_actions_resolved = [dict(item) for item in (surface_actions or []) if isinstance(item, dict)]
|
|
7049
10301
|
connector_hints_resolved = self._normalize_connector_hints(connector_hints)
|
|
@@ -7051,7 +10303,7 @@ class ArtifactService:
|
|
|
7051
10303
|
reply_schema_resolved = reply_schema if isinstance(reply_schema, dict) else {}
|
|
7052
10304
|
reply_mode_resolved = str(
|
|
7053
10305
|
reply_mode
|
|
7054
|
-
or ("blocking" if kind == "decision_request" else "threaded" if kind in {"progress", "milestone"} else "none")
|
|
10306
|
+
or ("blocking" if kind == "decision_request" else "threaded" if kind in {"progress", "milestone", "answer"} else "none")
|
|
7055
10307
|
).strip().lower()
|
|
7056
10308
|
if reply_mode_resolved not in {"none", "threaded", "blocking"}:
|
|
7057
10309
|
reply_mode_resolved = "blocking" if kind == "decision_request" else "threaded"
|
|
@@ -7128,6 +10380,59 @@ class ArtifactService:
|
|
|
7128
10380
|
"decision_type": decision_type or None,
|
|
7129
10381
|
"guidance": guidance,
|
|
7130
10382
|
}
|
|
10383
|
+
suppress_resolved = (kind == "progress") if suppress_if_unchanged is None else bool(suppress_if_unchanged)
|
|
10384
|
+
dedupe_key_resolved = str(dedupe_key or self._normalize_interaction_message(full_message)).strip() or None
|
|
10385
|
+
if (
|
|
10386
|
+
kind == "progress"
|
|
10387
|
+
and suppress_resolved
|
|
10388
|
+
and dedupe_key_resolved
|
|
10389
|
+
and int(self.quest_service.snapshot(self._quest_id(quest_root)).get("pending_user_message_count") or 0) == 0
|
|
10390
|
+
):
|
|
10391
|
+
prior_interaction = self._latest_duplicate_progress_interaction(
|
|
10392
|
+
quest_root,
|
|
10393
|
+
dedupe_key=dedupe_key_resolved,
|
|
10394
|
+
min_interval_seconds=min_interval_seconds,
|
|
10395
|
+
)
|
|
10396
|
+
if prior_interaction is not None:
|
|
10397
|
+
interaction_state = self._read_interaction_state(quest_root)
|
|
10398
|
+
waiting_requests = [
|
|
10399
|
+
dict(item)
|
|
10400
|
+
for item in (interaction_state.get("open_requests") or [])
|
|
10401
|
+
if str(item.get("status") or "") == "waiting"
|
|
10402
|
+
]
|
|
10403
|
+
return {
|
|
10404
|
+
"status": "suppressed_duplicate",
|
|
10405
|
+
"artifact_id": prior_interaction.get("artifact_id"),
|
|
10406
|
+
"interaction_id": prior_interaction.get("interaction_id"),
|
|
10407
|
+
"expects_reply": False,
|
|
10408
|
+
"reply_mode": "threaded",
|
|
10409
|
+
"surface_actions": [],
|
|
10410
|
+
"connector_hints": connector_hints_resolved,
|
|
10411
|
+
"normalized_attachments": attachments_resolved,
|
|
10412
|
+
"attachment_issues": attachment_issues,
|
|
10413
|
+
"delivered": False,
|
|
10414
|
+
"delivery_results": [],
|
|
10415
|
+
"response_phase": response_phase,
|
|
10416
|
+
"delivery_targets": [],
|
|
10417
|
+
"delivery_policy": self._delivery_policy(self._connectors_config()),
|
|
10418
|
+
"preferred_connector": self._preferred_connector(self._connectors_config()),
|
|
10419
|
+
"recent_inbound_messages": [],
|
|
10420
|
+
"delivery_batch": None,
|
|
10421
|
+
"recent_interaction_records": self.quest_service.latest_artifact_interaction_records(quest_root, limit=10),
|
|
10422
|
+
"agent_instruction": self.quest_service.localized_copy(
|
|
10423
|
+
quest_root=quest_root,
|
|
10424
|
+
zh="当前用户可见状态没有变化,不需要再发送一条重复 progress。继续工作,等出现真实变化再汇报。",
|
|
10425
|
+
en="The user-visible state has not changed. Do not send another duplicate progress update; continue working until there is a real change.",
|
|
10426
|
+
),
|
|
10427
|
+
"queued_message_count_before_delivery": 0,
|
|
10428
|
+
"queued_message_count_after_delivery": 0,
|
|
10429
|
+
"open_request_count": len(waiting_requests),
|
|
10430
|
+
"active_request": waiting_requests[-1] if waiting_requests else None,
|
|
10431
|
+
"default_reply_interaction_id": interaction_state.get("default_reply_interaction_id"),
|
|
10432
|
+
"guidance": "Duplicate progress was suppressed because the latest user-visible state is unchanged.",
|
|
10433
|
+
"suppressed_reason": "unchanged_progress",
|
|
10434
|
+
"dedupe_key": dedupe_key_resolved,
|
|
10435
|
+
}
|
|
7131
10436
|
resolved_artifact_id = generate_id(durable_kind)
|
|
7132
10437
|
resolved_interaction_id = interaction_id or (
|
|
7133
10438
|
resolved_artifact_id if reply_mode_resolved != "none" or reply_to_interaction_id else None
|
|
@@ -7135,9 +10440,10 @@ class ArtifactService:
|
|
|
7135
10440
|
payload: dict[str, Any] = {
|
|
7136
10441
|
"kind": durable_kind,
|
|
7137
10442
|
"artifact_id": resolved_artifact_id,
|
|
7138
|
-
"status": "active" if durable_kind == "progress" else "completed",
|
|
7139
|
-
"message":
|
|
7140
|
-
"summary":
|
|
10443
|
+
"status": "completed" if kind == "answer" else "active" if durable_kind == "progress" else "completed",
|
|
10444
|
+
"message": full_message,
|
|
10445
|
+
"summary": summary_preview_resolved or full_message,
|
|
10446
|
+
"summary_preview": summary_preview_resolved,
|
|
7141
10447
|
"interaction_phase": "request" if kind == "decision_request" else response_phase,
|
|
7142
10448
|
"importance": importance,
|
|
7143
10449
|
"attachments": attachments_resolved,
|
|
@@ -7157,11 +10463,11 @@ class ArtifactService:
|
|
|
7157
10463
|
{
|
|
7158
10464
|
"verdict": "pending_user",
|
|
7159
10465
|
"action": "request_user_decision",
|
|
7160
|
-
"reason":
|
|
10466
|
+
"reason": full_message or "Decision request emitted for user review.",
|
|
7161
10467
|
}
|
|
7162
10468
|
)
|
|
7163
10469
|
if durable_kind == "approval":
|
|
7164
|
-
payload.setdefault("reason",
|
|
10470
|
+
payload.setdefault("reason", full_message or "Approval result emitted.")
|
|
7165
10471
|
artifact = self.record(
|
|
7166
10472
|
quest_root,
|
|
7167
10473
|
payload,
|
|
@@ -7173,7 +10479,7 @@ class ArtifactService:
|
|
|
7173
10479
|
kind=kind,
|
|
7174
10480
|
expects_reply=expects_reply_resolved,
|
|
7175
10481
|
reply_mode=reply_mode_resolved,
|
|
7176
|
-
message=
|
|
10482
|
+
message=full_message,
|
|
7177
10483
|
options=options_resolved,
|
|
7178
10484
|
allow_free_text=allow_free_text,
|
|
7179
10485
|
reply_schema=reply_schema_resolved,
|
|
@@ -7196,7 +10502,7 @@ class ArtifactService:
|
|
|
7196
10502
|
"quest_id": self._quest_id(quest_root),
|
|
7197
10503
|
"conversation_id": target,
|
|
7198
10504
|
"kind": kind,
|
|
7199
|
-
"message":
|
|
10505
|
+
"message": full_message,
|
|
7200
10506
|
"response_phase": response_phase,
|
|
7201
10507
|
"importance": importance,
|
|
7202
10508
|
"artifact_id": artifact.get("artifact_id"),
|
|
@@ -7217,6 +10523,9 @@ class ArtifactService:
|
|
|
7217
10523
|
if delivery_result.get("ok", False) or delivery_result.get("queued", False):
|
|
7218
10524
|
delivery_targets.append(target)
|
|
7219
10525
|
delivered = True
|
|
10526
|
+
counts_as_visible = (not deliver_to_bound_conversations) or (not delivery_results) or any(
|
|
10527
|
+
bool(item.get("ok", False)) for item in delivery_results
|
|
10528
|
+
)
|
|
7220
10529
|
|
|
7221
10530
|
mailbox_payload = {
|
|
7222
10531
|
"delivery_batch": None,
|
|
@@ -7241,12 +10550,15 @@ class ArtifactService:
|
|
|
7241
10550
|
interaction_id=request_state.get("interaction_id"),
|
|
7242
10551
|
artifact_id=artifact.get("artifact_id"),
|
|
7243
10552
|
kind=kind,
|
|
7244
|
-
message=
|
|
10553
|
+
message=full_message,
|
|
10554
|
+
summary_preview=summary_preview_resolved,
|
|
10555
|
+
dedupe_key=dedupe_key_resolved,
|
|
7245
10556
|
response_phase=response_phase,
|
|
7246
10557
|
reply_mode=reply_mode_resolved,
|
|
7247
10558
|
surface_actions=surface_actions_resolved,
|
|
7248
10559
|
connector_hints=connector_hints_resolved,
|
|
7249
10560
|
created_at=(artifact.get("record") or {}).get("updated_at"),
|
|
10561
|
+
counts_as_visible=counts_as_visible,
|
|
7250
10562
|
)
|
|
7251
10563
|
|
|
7252
10564
|
return {
|
|
@@ -7277,6 +10589,36 @@ class ArtifactService:
|
|
|
7277
10589
|
"guidance": "如果收到新的用户要求,请先吸收这些要求;如果没有新消息,请继续当前任务并在真实检查点再次汇报。",
|
|
7278
10590
|
}
|
|
7279
10591
|
|
|
10592
|
+
@staticmethod
|
|
10593
|
+
def _normalize_interaction_message(message: str) -> str:
|
|
10594
|
+
return " ".join(str(message or "").split())
|
|
10595
|
+
|
|
10596
|
+
def _latest_duplicate_progress_interaction(
|
|
10597
|
+
self,
|
|
10598
|
+
quest_root: Path,
|
|
10599
|
+
*,
|
|
10600
|
+
dedupe_key: str,
|
|
10601
|
+
min_interval_seconds: int | None,
|
|
10602
|
+
) -> dict[str, Any] | None:
|
|
10603
|
+
recent = self.quest_service.latest_artifact_interaction_records(quest_root, limit=40)
|
|
10604
|
+
for item in reversed(recent):
|
|
10605
|
+
record_type = str(item.get("type") or "").strip()
|
|
10606
|
+
if record_type == "user_inbound":
|
|
10607
|
+
return None
|
|
10608
|
+
if record_type != "artifact_outbound":
|
|
10609
|
+
continue
|
|
10610
|
+
if str(item.get("kind") or "").strip() != "progress":
|
|
10611
|
+
continue
|
|
10612
|
+
previous_key = str(item.get("dedupe_key") or self._normalize_interaction_message(item.get("message") or "")).strip()
|
|
10613
|
+
if previous_key != dedupe_key:
|
|
10614
|
+
continue
|
|
10615
|
+
if min_interval_seconds:
|
|
10616
|
+
seconds_since = self.quest_service._seconds_since_iso_timestamp(item.get("created_at"))
|
|
10617
|
+
if seconds_since is not None and seconds_since > int(min_interval_seconds):
|
|
10618
|
+
return None
|
|
10619
|
+
return dict(item)
|
|
10620
|
+
return None
|
|
10621
|
+
|
|
7280
10622
|
def complete_quest(
|
|
7281
10623
|
self,
|
|
7282
10624
|
quest_root: Path,
|
|
@@ -7465,6 +10807,7 @@ class ArtifactService:
|
|
|
7465
10807
|
def _default_status(kind: str) -> str:
|
|
7466
10808
|
return {
|
|
7467
10809
|
"progress": "active",
|
|
10810
|
+
"answer": "completed",
|
|
7468
10811
|
"decision": "pending",
|
|
7469
10812
|
"approval": "accepted",
|
|
7470
10813
|
"graph": "generated",
|