bookmark-research 0.5.0-beta.1

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.
Files changed (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. package/src/wiki.py +383 -0
@@ -0,0 +1,1334 @@
1
+ """Durable, host-led research: bounded retrieval, grounded claims and reports.
2
+
3
+ The calling model plans and interprets evidence. This module does not run a model
4
+ or an autonomous background worker. Every network operation has a durable intent
5
+ and an idempotency key; a lost response is never silently submitted again.
6
+ """
7
+
8
+ import copy
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import sqlite3
14
+ import tempfile
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from urllib.parse import urlsplit
19
+
20
+ from archive import SourceArchive
21
+ from research_coverage import ResearchCoverage
22
+ from settings import Settings
23
+
24
+
25
+ class ResearchSessions:
26
+ """Keep research artifacts outside the plugin and original canvas packages."""
27
+
28
+ DEFAULT_BUDGET = {"max_search_calls": 16, "max_fetch_calls": 12, "max_rounds": 8}
29
+ LIMITS = {"max_search_calls": 120, "max_fetch_calls": 80, "max_rounds": 40}
30
+ FETCH_BATCH_SIZE = 8
31
+ RECORD_BATCH_SIZE = 50
32
+ PROVIDERS = Settings.PROVIDERS
33
+
34
+ def __init__(self, directory=None, settings=None, engine=None, db_path=None):
35
+ self.settings = settings if settings is not None else Settings()
36
+ self.directory = Settings.external_path(
37
+ str(directory or (Settings.data_directory() / "research")), "Research directory")
38
+ self.engine = engine
39
+ self.db_path = Path(db_path) if db_path is not None else Settings.data_directory() / "index.sqlite3"
40
+
41
+ @staticmethod
42
+ def _now():
43
+ return datetime.now(timezone.utc).isoformat()
44
+
45
+ @staticmethod
46
+ def _text(value, label, maximum=4000):
47
+ if not isinstance(value, str) or not value.strip() or len(value) > maximum or "\x00" in value:
48
+ raise ValueError(label + " must be a nonempty string of at most %s characters" % maximum)
49
+ return value.strip()
50
+
51
+ @classmethod
52
+ def _identifier(cls, value, label):
53
+ value = cls._text(value, label, 80)
54
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", value):
55
+ raise ValueError(label + " must use letters, digits, dots, hyphens or underscores")
56
+ return value
57
+
58
+ @classmethod
59
+ def _providers(cls, value):
60
+ if (not isinstance(value, list) or not 1 <= len(value) <= len(cls.PROVIDERS)
61
+ or any(not isinstance(p, str) or p not in cls.PROVIDERS for p in value)
62
+ or len(set(value)) != len(value)):
63
+ raise ValueError("providers must select supported providers without duplicates")
64
+ return value[:]
65
+
66
+ @staticmethod
67
+ def _integer(value, label, minimum, maximum):
68
+ if type(value) is not int or not minimum <= value <= maximum:
69
+ raise ValueError("%s must be an integer between %s and %s" % (label, minimum, maximum))
70
+ return value
71
+
72
+ @classmethod
73
+ def _question(cls, value):
74
+ if not isinstance(value, dict) or set(value) != {"id", "question"}:
75
+ raise ValueError("Each question must contain id and question only")
76
+ return {"id": cls._identifier(value["id"], "Question id"),
77
+ "question": cls._text(value["question"], "Question"),
78
+ "status": "open", "answer": None, "claim_ids": [], "gap": None}
79
+
80
+ def _path(self, research_id):
81
+ if not isinstance(research_id, str) or not re.fullmatch(r"r-[a-f0-9]{16}", research_id):
82
+ raise ValueError("Invalid research_id")
83
+ path = self.directory / research_id
84
+ if path.is_symlink() or path.resolve().parent != self.directory:
85
+ raise ValueError("Research session must stay inside its data directory")
86
+ return Settings.external_path(str(path), "Research session")
87
+
88
+ def _load(self, research_id):
89
+ path = self._path(research_id)
90
+ state_path = path / "state.json"
91
+ if state_path.is_symlink():
92
+ raise ValueError("Research state must not be a symlink")
93
+ state = json.loads(state_path.read_text(encoding="utf-8"))
94
+ if state.get("schema_version") not in (1, 2) or state.get("research_id") != research_id:
95
+ raise ValueError("Unsupported or inconsistent research state")
96
+ if state["schema_version"] == 2:
97
+ manifest_bytes = self._artifact(path, "inventory.json").read_bytes()
98
+ if hashlib.sha256(manifest_bytes).hexdigest() != state.get("inventory_manifest_sha256"):
99
+ raise ValueError("Frozen input inventory hash changed")
100
+ manifest = json.loads(manifest_bytes)
101
+ scope = state.get("source_scope", {})
102
+ selected = scope.get("selected_inventory_ids", [])
103
+ entries = {entry["id"]: entry for entry in manifest["entries"]}
104
+ if (len(set(selected)) != len(selected) or set(selected) - entries.keys()
105
+ or scope.get("input_version") != manifest["input_version"]
106
+ or (scope.get("mode") == "whole" and set(selected) != entries.keys())
107
+ or state.get("inventory") != [entries[identifier] for identifier in selected]):
108
+ raise ValueError("Research scope is inconsistent with its frozen complete inventory")
109
+ # A complete receipt is an atomic commit record. Materialize it if the
110
+ # process exited between receipt publication and the state checkpoint.
111
+ # Every later mutation loads this view before assigning new source IDs.
112
+ for operation in state["operations"]:
113
+ if operation["status"] != "pending":
114
+ continue
115
+ receipt = path / self._receipt_file(operation["id"])
116
+ if not receipt.is_file():
117
+ continue
118
+ result = json.loads(self._artifact(path, str(receipt.relative_to(path))).read_text(encoding="utf-8"))
119
+ commit = result.get("_commit")
120
+ if not isinstance(commit, dict):
121
+ continue
122
+ completed = commit.get("operation", {})
123
+ if (result.get("research_id") != research_id or result.get("operation_id") != operation["id"]
124
+ or completed.get("id") != operation["id"] or completed.get("digest") != operation["digest"]
125
+ or completed.get("status") not in ("ok", "partial", "error")):
126
+ raise ValueError("Research receipt does not match its pending operation")
127
+ for source in commit.get("sources", []):
128
+ prior = next((row for row in state["sources"] if row["id"] == source["id"]), None)
129
+ if prior is not None and prior != source:
130
+ raise ValueError("Research receipt conflicts with an existing source ID")
131
+ if prior is None:
132
+ state["sources"].append(source)
133
+ operation.update(completed)
134
+ state["updated_at"] = completed["finished_at"]
135
+ return path, state
136
+
137
+ @staticmethod
138
+ def _receipt_file(operation_id):
139
+ return "operations/" + hashlib.sha256(operation_id.encode()).hexdigest()[:24] + ".json"
140
+
141
+ @staticmethod
142
+ def _write(path, value):
143
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
144
+ temporary = None
145
+ try:
146
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="", dir=path.parent,
147
+ prefix=".research-", delete=False) as stream:
148
+ temporary = Path(stream.name)
149
+ stream.write(value if isinstance(value, str) else
150
+ json.dumps(value, ensure_ascii=False, allow_nan=False, indent=2) + "\n")
151
+ stream.flush()
152
+ os.fsync(stream.fileno())
153
+ temporary.replace(path)
154
+ finally:
155
+ if temporary is not None:
156
+ temporary.unlink(missing_ok=True)
157
+
158
+ def _save(self, path, state):
159
+ state["updated_at"] = self._now()
160
+ self._write(path / "state.json", state)
161
+
162
+ def _web(self):
163
+ if self.engine is None:
164
+ from web_search import SearchProviders
165
+ self.engine = SearchProviders(settings=self.settings)
166
+ return self.engine
167
+
168
+ @staticmethod
169
+ def _active(state):
170
+ if state["status"] == "incomplete":
171
+ raise ValueError("Research has an incomplete report; use research_record kind=resume with a reason to continue")
172
+ if state["status"] != "active":
173
+ raise ValueError("Research is closed; inspect its report or start a new session")
174
+
175
+ @staticmethod
176
+ def _find(rows, identifier, label):
177
+ for row in rows:
178
+ if row["id"] == identifier:
179
+ return row
180
+ raise ValueError("Unknown %s: %s" % (label, identifier))
181
+
182
+ @staticmethod
183
+ def _preview(section, row):
184
+ value = copy.deepcopy(row)
185
+ if section in ("claims", "inventory_reviews"):
186
+ for citation in value["citations"]:
187
+ citation["quote_truncated"] = len(citation["quote"]) > 300
188
+ citation["quote"] = citation["quote"][:300]
189
+ if section == "sources":
190
+ value["bookmark_ref_count"] = len(value.pop("bookmark_refs", []))
191
+ if isinstance(value.get("title"), str):
192
+ value["title"] = value["title"][:500]
193
+ if section == "operations":
194
+ value.pop("parameters", None)
195
+ if section == "bookmark_context":
196
+ value = {key: row[key] for key in ("source_id", "section_id", "item_id", "title", "url",
197
+ "instance_id", "input_index") if key in row}
198
+ if section == "inventory":
199
+ value["instance_count"] = len(value.pop("instances"))
200
+ for field in ("answer", "question", "statement", "text", "title"):
201
+ if isinstance(value.get(field), str) and len(value[field]) > 2000:
202
+ value[field] = value[field][:2000]
203
+ value[field + "_truncated"] = True
204
+ return value
205
+
206
+ @staticmethod
207
+ def _scope_preview(path, scope):
208
+ """Bound response metadata without changing frozen scope or saved artifacts."""
209
+ if scope is None:
210
+ return None
211
+ preview = {}
212
+ for field, value in scope.items():
213
+ if isinstance(value, list):
214
+ preview[field] = copy.deepcopy(value[:20])
215
+ preview[field + "_total"] = len(value)
216
+ preview[field + "_truncated"] = len(value) > 20
217
+ else:
218
+ preview[field] = copy.deepcopy(value)
219
+ preview.update(artifact_path=str(path / "context.json"), artifact_json_pointer="/source_scope",
220
+ detail_note="Scope lists preview at most 20 entries; read the full source_scope from the artifact. "
221
+ "Paginate research_inventory to read every selected item.")
222
+ return preview
223
+
224
+ def _summary(self, path, state):
225
+ remaining = {key.removeprefix("max_"): state["budget"][key] - state["usage"][key.removeprefix("max_")]
226
+ for key in self.DEFAULT_BUDGET}
227
+ collections = ("questions", "claims", "conflicts", "sources", "operations", "bookmark_context",
228
+ "inventory", "inventory_reviews", "external_runs")
229
+ coverage = self._coverage(path, state)
230
+ coverage["source_scope"] = self._scope_preview(path, state.get("source_scope"))
231
+ return {"research_id": state["research_id"], "directory": str(path),
232
+ "status": state["status"], "brief": state["brief"], "scope": state["scope"],
233
+ "source_ids": state["source_ids"], "providers": state["providers"],
234
+ "search_fallback_providers": state.get("search_fallback_providers", []),
235
+ "fetch_providers": state.get("fetch_providers", state["providers"]),
236
+ "context_manifest": str(path / "context.json"),
237
+ "inventory_manifest": str(path / "inventory.json") if state.get("source_scope") else None,
238
+ "source_scope": coverage["source_scope"],
239
+ "coverage": {key: value for key, value in coverage.items() if key != "rows"},
240
+ "created_at": state["created_at"], "updated_at": state["updated_at"],
241
+ "budget": state["budget"], "usage": state["usage"], "remaining": remaining,
242
+ "initial_fetch_plan": state.get("initial_fetch_plan"),
243
+ **{section: [self._preview(section, row) for row in state.get(section, [])[:20]] for section in collections},
244
+ "counts": {section: len(state.get(section, [])) for section in (*collections, "events")},
245
+ "pagination": {section: {"total": len(state.get(section, [])), "next_offset": 20 if len(state.get(section, [])) > 20 else None}
246
+ for section in collections},
247
+ "detail_note": "Overview includes at most 20 previews per collection. Use research_status section with offset/limit for full entries, research_source for page text.",
248
+ "artifacts": state.get("artifacts"),
249
+ "execution": "Host model must choose each next action; no background worker is running.",
250
+ "budget_unit": "Reserved provider attempts: one per MCP batch or Jina HTTP request (one Reader request per URL). Search batches also consume a round. Failures and unknown outcomes keep their reservation.",
251
+ "evidence_note": "Quotes are checked against saved extracts. Relevance, factual support and independence require model review; provider agreement is not confirmation."}
252
+
253
+ def _bookmark_scope(self, source_ids, references, scope_mode, inventory_ids, urls=None):
254
+ references = [] if references is None else references
255
+ if not isinstance(references, list) or len(references) > 100:
256
+ raise ValueError("bookmark_refs must be a list of at most 100 bookmark references")
257
+ for reference in references:
258
+ if not isinstance(reference, dict) or set(reference) != {"source_id", "section_id", "item_id"}:
259
+ raise ValueError("Each bookmark_ref requires source_id, section_id and item_id only")
260
+ for name, value in reference.items():
261
+ self._text(value, name, 512)
262
+ if scope_mode not in ("whole", "subset"):
263
+ raise ValueError("scope_mode must be whole or subset")
264
+ if inventory_ids is not None:
265
+ if not isinstance(inventory_ids, list) or not inventory_ids:
266
+ raise ValueError("inventory_ids must be a nonempty list")
267
+ inventory_ids = [self._identifier(value, "Inventory id") for value in inventory_ids]
268
+ if len(set(inventory_ids)) != len(inventory_ids):
269
+ raise ValueError("inventory_ids must be unique")
270
+ if scope_mode != "subset":
271
+ raise ValueError("inventory_ids selection requires explicit scope_mode=subset")
272
+ source_ids = sorted(set(source_ids + [reference["source_id"] for reference in references]))
273
+ if urls is not None:
274
+ if source_ids:
275
+ raise ValueError("Choose urls or indexed source_ids/bookmark_refs as the original input")
276
+ if not isinstance(urls, list) or not 1 <= len(urls) <= 10000:
277
+ raise ValueError("urls must contain 1 to 10000 original bookmark URLs")
278
+ for url in urls:
279
+ self._text(url, "Original URL", 8192)
280
+ if source_ids:
281
+ if not self.db_path.is_file():
282
+ raise ValueError("Sync the bookmark package before selecting source_ids or bookmark_refs")
283
+ from bookmark_index import BookmarkIndex
284
+ with BookmarkIndex(self.db_path) as index:
285
+ from source_manager import SourceManager
286
+ manager = SourceManager(index)
287
+ for source_id in source_ids:
288
+ update = manager.refresh(source_id)
289
+ if update["state"] not in ("current", "snapshot"):
290
+ raise ValueError("Source %s is %s; wait for a settled input before starting research" % (source_id, update["state"]))
291
+ manifest = index.inventory(source_ids)
292
+ else:
293
+ manifest = {"schema_version": 1, "index_state": "none", "source_ids": [],
294
+ "input_version": hashlib.sha256(b"[]").hexdigest(),
295
+ "entries": [], "counts": {"unique_urls": 0, "bookmark_instances": 0},
296
+ **{key: [] for key in ("sources", "files", "sections", "folders", "nodes", "edges", "memberships")}}
297
+ if urls is not None:
298
+ by_url = {}
299
+ for position, original in enumerate(urls):
300
+ if original not in by_url:
301
+ try:
302
+ retrieval = SourceArchive._key(original)
303
+ except (ValueError, UnicodeError):
304
+ retrieval = None
305
+ try:
306
+ scheme = urlsplit(original).scheme
307
+ except ValueError:
308
+ scheme = ""
309
+ by_url[original] = {"id": "u-" + hashlib.sha256(original.encode("utf-8")).hexdigest()[:32],
310
+ "original_url": original, "retrieval_url": retrieval,
311
+ "url_kind": "web" if retrieval else "local" if scheme == "file" else "unsupported",
312
+ "instances": []}
313
+ identity = json.dumps([position, original], ensure_ascii=False).encode("utf-8")
314
+ by_url[original]["instances"].append({
315
+ "instance_id": "b-" + hashlib.sha256(identity).hexdigest()[:32],
316
+ "input_index": position, "url": original})
317
+ manifest.update(index_state="provided", input_kind="urls",
318
+ input_version=hashlib.sha256(json.dumps(urls, ensure_ascii=False).encode("utf-8")).hexdigest(),
319
+ entries=sorted(by_url.values(), key=lambda row: row["id"]),
320
+ counts={"unique_urls": len(by_url), "bookmark_instances": len(urls)})
321
+ reference_ids = set()
322
+ for reference in references:
323
+ matches = []
324
+ for entry in manifest["entries"]:
325
+ for item in entry["instances"]:
326
+ selectors = {item["section_id"]}
327
+ selectors.update(appearance[key] for appearance in item["appearances"]
328
+ for key in ("section_id", "label", "file_path"))
329
+ if (item["source_id"] == reference["source_id"] and item["item_id"] == reference["item_id"]
330
+ and reference["section_id"] in selectors):
331
+ matches.append(entry["id"])
332
+ if len(matches) != 1:
333
+ raise ValueError("bookmark_ref must identify one bookmark in its source and section")
334
+ reference_ids.update(matches)
335
+ all_ids = {entry["id"] for entry in manifest["entries"]}
336
+ if scope_mode == "subset":
337
+ if inventory_ids is not None and references:
338
+ raise ValueError("Choose inventory_ids or bookmark_refs for an explicit subset, not both")
339
+ selected = set(inventory_ids) if inventory_ids is not None else reference_ids
340
+ if not selected or selected - all_ids:
341
+ raise ValueError("An explicit subset needs existing inventory_ids or bookmark_refs")
342
+ else:
343
+ selected = all_ids
344
+ entries = [entry for entry in manifest["entries"] if entry["id"] in selected]
345
+ context = []
346
+ for entry in entries:
347
+ for item in entry["instances"]:
348
+ if urls is not None:
349
+ context.append({**item, "inventory_id": entry["id"], "index_state": "provided"})
350
+ continue
351
+ record = {key: item[key] for key in ("source_id", "section_id", "item_id", "title", "url", "path", "section_label")}
352
+ record.update(canonical_section_id=item["canonical_section_id"], inventory_id=entry["id"],
353
+ index_state="last_synchronized", memberships=[], edges=[])
354
+ for appearance in item["appearances"]:
355
+ for key in ("memberships", "edges"):
356
+ for row in appearance[key]:
357
+ if row not in record[key]:
358
+ record[key].append(row)
359
+ context.append(record)
360
+ selection = {"mode": scope_mode if source_ids or urls is not None else "questions", "input_version": manifest["input_version"],
361
+ "input_url_count": len(manifest["entries"]), "selected_url_count": len(entries),
362
+ "input_instance_count": manifest["counts"]["bookmark_instances"],
363
+ "selected_instance_count": len(context),
364
+ "selected_inventory_ids": [entry["id"] for entry in entries],
365
+ "omitted_inventory_ids": sorted(all_ids - selected), "focus_bookmark_refs": references}
366
+ return manifest, entries, context, selection
367
+
368
+ def start(self, brief, questions, budget=None, providers=None, scope="", source_ids=None,
369
+ bookmark_refs=None, scope_mode="whole", inventory_ids=None, urls=None):
370
+ brief = self._text(brief, "Research brief", 12000)
371
+ if not isinstance(questions, list) or not 1 <= len(questions) <= 24:
372
+ raise ValueError("questions must contain 1 to 24 questions")
373
+ rows = [self._question(question) for question in questions]
374
+ if len({row["id"] for row in rows}) != len(rows):
375
+ raise ValueError("Question ids must be unique")
376
+ if budget is not None and (not isinstance(budget, dict) or set(budget) - set(self.DEFAULT_BUDGET)):
377
+ raise ValueError("Unknown research budget fields")
378
+ selected_budget = {**self.DEFAULT_BUDGET, **(budget or {})}
379
+ for key, limit in selected_budget.items():
380
+ self._integer(limit, key, 0, self.LIMITS[key])
381
+ preferences = self.settings.load()
382
+ names = self._providers(providers if providers is not None else preferences["search"]["providers"])
383
+ fetch_names = names[:] if providers is not None else Settings.fetch_providers(preferences)
384
+ search_fallbacks = [] if providers is not None else [name for name in preferences["search"]["fallback_providers"]
385
+ if name not in names]
386
+ if not isinstance(scope, str) or len(scope) > 12000 or "\x00" in scope:
387
+ raise ValueError("scope must be a string of at most 12000 characters")
388
+ source_ids = [] if source_ids is None else source_ids
389
+ if not isinstance(source_ids, list) or len(source_ids) > 100:
390
+ raise ValueError("source_ids must be a list of at most 100 indexed package IDs")
391
+ source_ids = list(dict.fromkeys(self._text(value, "Source id", 512) for value in source_ids))
392
+ manifest, inventory, bookmark_context, selection = self._bookmark_scope(source_ids, bookmark_refs, scope_mode, inventory_ids, urls)
393
+ source_ids = manifest["source_ids"]
394
+ urls_per_call = 1 if fetch_names[0] == "jina" else self.FETCH_BATCH_SIZE
395
+ minimum_fetch_calls = (len(inventory) + urls_per_call - 1) // urls_per_call
396
+ explicit_fetch_budget = budget is not None and "max_fetch_calls" in budget
397
+ if not explicit_fetch_budget and inventory:
398
+ selected_budget["max_fetch_calls"] = min(self.LIMITS["max_fetch_calls"],
399
+ max(self.DEFAULT_BUDGET["max_fetch_calls"], minimum_fetch_calls + self.DEFAULT_BUDGET["max_fetch_calls"]))
400
+ fetch_limit = selected_budget["max_fetch_calls"]
401
+ fetch_plan = {"selected_urls": len(inventory), "max_urls_per_call": urls_per_call,
402
+ "minimum_required": minimum_fetch_calls, "configured_limit": fetch_limit,
403
+ "hard_limit": self.LIMITS["max_fetch_calls"],
404
+ "budget_source": "explicit" if explicit_fetch_budget else "scope_default",
405
+ "additional_calls": max(0, fetch_limit - minimum_fetch_calls),
406
+ "call_shortfall": max(0, minimum_fetch_calls - fetch_limit),
407
+ "urls_beyond_capacity": max(0, len(inventory) - fetch_limit * urls_per_call),
408
+ "scope_preserved": True,
409
+ "basis": "Initial lower bound assuming full batches. Grouping, failures and supplementary reads can need more calls; imported evidence may need fewer."}
410
+ research_id = "r-" + uuid.uuid4().hex[:16]
411
+ path = self._path(research_id)
412
+ state = {"schema_version": 2, "research_id": research_id, "status": "active",
413
+ "brief": brief, "scope": scope, "source_ids": source_ids, "providers": names,
414
+ "search_fallback_providers": search_fallbacks,
415
+ "fetch_providers": fetch_names,
416
+ "bookmark_context": bookmark_context,
417
+ "source_scope": selection, "inventory": inventory, "inventory_reviews": [], "external_runs": [],
418
+ "created_at": self._now(), "budget": selected_budget,
419
+ "initial_fetch_plan": fetch_plan,
420
+ "usage": {"search_calls": 0, "fetch_calls": 0, "rounds": 0},
421
+ "questions": rows, "claims": [], "conflicts": [], "sources": [],
422
+ "operations": [], "events": []}
423
+ path.mkdir(parents=True, mode=0o700)
424
+ self._write(path / "inventory.json", manifest)
425
+ state["inventory_manifest_sha256"] = hashlib.sha256((path / "inventory.json").read_bytes()).hexdigest()
426
+ self._write(path / "context.json", {"schema_version": 1, "captured_at": self._now(),
427
+ "kind": "local_bookmark_metadata", "bookmarks": bookmark_context, "source_scope": selection,
428
+ "boundary": "Frozen research input; never automatically sent to web providers. Full input structure is in inventory.json."})
429
+ self._save(path, state)
430
+ return self._summary(path, state)
431
+
432
+ def status(self, research_id=None, section=None, offset=0, limit=20):
433
+ self._integer(offset, "offset", 0, 10000000)
434
+ self._integer(limit, "limit", 1, 100)
435
+ if research_id is None:
436
+ if section is not None:
437
+ raise ValueError("section requires research_id")
438
+ sessions = []
439
+ for path in sorted(self.directory.glob("r-*/state.json")) if self.directory.exists() else []:
440
+ _, state = self._load(path.parent.name)
441
+ sessions.append({key: state[key] for key in
442
+ ("research_id", "brief", "status", "updated_at", "usage")})
443
+ return {"directory": str(self.directory), "sessions": sessions[offset:offset + limit], "total": len(sessions),
444
+ "next_offset": offset + limit if offset + limit < len(sessions) else None}
445
+ path, state = self._load(research_id)
446
+ if section is not None:
447
+ if section not in ("questions", "claims", "sources", "operations", "conflicts", "bookmark_context", "events",
448
+ "inventory", "inventory_reviews", "external_runs"):
449
+ raise ValueError("Unknown research status section")
450
+ rows, consumed = [], 0
451
+ collection = state.get(section, [])
452
+ for row in collection[offset:offset + limit]:
453
+ size = len(json.dumps(row, ensure_ascii=False))
454
+ if consumed + size > 900000:
455
+ if not rows:
456
+ rows.append({"oversized_entry": True, "state_file": str(path / "state.json"),
457
+ "entry_index": offset, "note": "Read this unusually large entry from the state artifact."})
458
+ break
459
+ rows.append(row)
460
+ consumed += size
461
+ next_offset = offset + len(rows)
462
+ return {"research_id": research_id, "section": section, "items": rows, "total": len(collection),
463
+ "offset": offset, "next_offset": next_offset if next_offset < len(collection) else None}
464
+ result = self._summary(path, state)
465
+ result["source_freshness"] = self.source_freshness(state=state)
466
+ return result
467
+
468
+ def source_freshness(self, research_id=None, state=None):
469
+ """Compare current indexed input to frozen scope; never rewrite evidence."""
470
+ if state is None:
471
+ _, state = self._load(research_id)
472
+ ids = state.get("source_ids", [])
473
+ frozen = state.get("source_scope", {}).get("input_version")
474
+ result = {"frozen_input_version": frozen, "current_input_version": None,
475
+ "state": "not_applicable" if not ids else "unknown", "requires_review": False if not ids else None,
476
+ "basis": "Last synchronized local index; no webpage refetch or inventory mutation"}
477
+ if not ids and state.get("inventory"):
478
+ result["basis"] = "Explicit URL list frozen at research_start; no live source is registered"
479
+ if not ids or not frozen or not self.db_path.is_file():
480
+ return result
481
+ try:
482
+ from bookmark_index import BookmarkIndex
483
+ from source_manager import SourceManager
484
+ with BookmarkIndex(self.db_path) as index:
485
+ manager = SourceManager(index)
486
+ with index.read_snapshot():
487
+ current = index.input_version(ids)
488
+ states = {source_id: manager.status(source_id)["source"]["state"] for source_id in ids}
489
+ changed = current != frozen
490
+ result.update(current_input_version=current, state="changed" if changed else "unchanged",
491
+ requires_review=changed, source_states=states)
492
+ except (OSError, ValueError, sqlite3.Error) as exc:
493
+ result["error"] = str(exc)[:1000]
494
+ return result
495
+
496
+ @staticmethod
497
+ def _page(rows, offset, limit, artifact):
498
+ page, size = [], 0
499
+ for row in rows[offset:offset + limit]:
500
+ length = len(json.dumps(row, ensure_ascii=False))
501
+ if size + length > 900000:
502
+ if not page:
503
+ page.append({"id": row["id"], "oversized_entry": True, "artifact_path": str(artifact),
504
+ "note": "Read this full entry from the saved artifact by its stable ID."})
505
+ break
506
+ page.append(row)
507
+ size += length
508
+ following = offset + len(page)
509
+ return {"items": page, "inventory_ids": [row["id"] for row in page], "offset": offset,
510
+ "total": len(rows), "next_offset": following if following < len(rows) else None}
511
+
512
+ def inventory(self, research_id, inventory_ids=None, offset=0, limit=100):
513
+ self._integer(offset, "offset", 0, 10000000)
514
+ self._integer(limit, "limit", 1, 100)
515
+ path, state = self._load(research_id)
516
+ rows = state.get("inventory", [])
517
+ if inventory_ids is not None:
518
+ if not isinstance(inventory_ids, list) or not 1 <= len(inventory_ids) <= 100:
519
+ raise ValueError("inventory_ids must contain 1 to 100 IDs per read")
520
+ identifiers = [self._identifier(value, "Inventory id") for value in inventory_ids]
521
+ if len(set(identifiers)) != len(identifiers):
522
+ raise ValueError("inventory_ids must be unique")
523
+ for identifier in identifiers:
524
+ self._find(rows, identifier, "inventory item")
525
+ rows = [row for row in rows if row["id"] in identifiers]
526
+ return {"research_id": research_id, "available": "source_scope" in state,
527
+ "source_scope": self._scope_preview(path, state.get("source_scope")),
528
+ "scope_total": len(state.get("inventory", [])),
529
+ "manifest_path": str(path / "inventory.json") if "source_scope" in state else None,
530
+ **self._page(rows, offset, limit, path / "inventory.json")}
531
+
532
+ def _coverage(self, path, state):
533
+ return ResearchCoverage(state, lambda source: self._body(path, source)).evaluate()
534
+
535
+ def coverage(self, research_id, filter=None, offset=0, limit=100):
536
+ self._integer(offset, "offset", 0, 10000000)
537
+ self._integer(limit, "limit", 1, 100)
538
+ selected = "all" if filter is None else filter
539
+ if selected not in ResearchCoverage.FILTERS:
540
+ raise ValueError("Unknown coverage filter")
541
+ path, state = self._load(research_id)
542
+ result = self._coverage(path, state)
543
+ result["source_scope"] = self._scope_preview(path, result["source_scope"])
544
+ rows = result.pop("rows")
545
+ scope_total = len(rows)
546
+ if selected != "all":
547
+ rows = [row for row in rows if row[selected]]
548
+ return {"research_id": research_id, "filter": selected, "scope_total": scope_total,
549
+ **result, **self._page(rows, offset, limit, path / "state.json")}
550
+
551
+ @staticmethod
552
+ def _source_links(state, url):
553
+ try:
554
+ canonical = SourceArchive._key(url)
555
+ except (ValueError, UnicodeError):
556
+ canonical = None
557
+ entries = [entry for entry in state.get("inventory", [])
558
+ if entry["original_url"] == url or (canonical is not None and entry["retrieval_url"] == canonical)]
559
+ references = []
560
+ if entries:
561
+ for entry in entries:
562
+ references.extend({key: item[key] for key in ("source_id", "section_id", "item_id")}
563
+ for item in entry["instances"] if item.get("source_id"))
564
+ else:
565
+ # Legacy explicit-context sessions remain readable and useful.
566
+ for bookmark in state.get("bookmark_context", []):
567
+ try:
568
+ same_url = SourceArchive._key(bookmark["url"]) == canonical
569
+ except (ValueError, UnicodeError):
570
+ same_url = bookmark["url"] == url
571
+ if same_url:
572
+ references.append({key: bookmark[key] for key in ("source_id", "section_id", "item_id")})
573
+ return [entry["id"] for entry in entries], references
574
+
575
+ def _operation(self, research_id, operation_id, kind, parameters, cost, execute):
576
+ operation_id = self._identifier(operation_id, "operation_id")
577
+ path = self._path(research_id)
578
+ if not (path / "state.json").is_file():
579
+ raise ValueError("Unknown research session")
580
+ digest = hashlib.sha256(json.dumps({"kind": kind, "parameters": parameters}, sort_keys=True,
581
+ ensure_ascii=False).encode("utf-8")).hexdigest()
582
+ # Hold the OS lock through the bounded request. Status reads remain available.
583
+ # Once this lock is released after a crash, an explicit interruption record
584
+ # can acknowledge a durable pending operation without resubmitting it.
585
+ with Settings._update_lock(path / "state.json"):
586
+ _, state = self._load(research_id)
587
+ for previous in state["operations"]:
588
+ if previous["id"] != operation_id:
589
+ continue
590
+ if previous["digest"] != digest:
591
+ raise ValueError("operation_id already exists with different arguments")
592
+ if previous.get("result_file"):
593
+ result = json.loads(self._artifact(path, previous["result_file"]).read_text(encoding="utf-8"))
594
+ return {**{key: value for key, value in result.items() if key != "_commit"}, "replayed": True}
595
+ return {"research_id": research_id, "operation": previous, "replayed": True,
596
+ "next_action": "Inspect the saved operation; acknowledge interruption with research_record before using a new operation_id. No automatic retry or budget refund."}
597
+ self._active(state)
598
+ for key, amount in cost.items():
599
+ if state["usage"][key] + amount > state["budget"]["max_" + key]:
600
+ raise ValueError("Research budget exhausted: " + key + "; synthesize available evidence or finish incomplete")
601
+ operation = {"id": operation_id, "kind": kind, "digest": digest, "parameters": parameters,
602
+ "status": "pending", "reserved": cost, "started_at": self._now(), "result_file": None}
603
+ state["operations"].append(operation)
604
+ for key, amount in cost.items():
605
+ state["usage"][key] += amount
606
+ self._save(path, state) # The reservation survives lost responses and process exits.
607
+ source_count = len(state["sources"])
608
+ try:
609
+ payload = execute(path, state, operation)
610
+ operation["status"] = payload.get("status", "ok")
611
+ except (RuntimeError, OSError, ValueError) as error:
612
+ payload = {"status": "error", "error": str(error)[:2000], "error_type": type(error).__name__}
613
+ operation["status"] = "error"
614
+ operation["finished_at"] = self._now()
615
+ operation["result_file"] = self._receipt_file(operation_id)
616
+ if "error" in payload:
617
+ operation["error"] = payload["error"]
618
+ if "usage" in payload:
619
+ operation["actual_usage"] = payload["usage"]
620
+ result = {"research_id": research_id, "operation_id": operation_id,
621
+ "replayed": False, **payload}
622
+ self._write(path / operation["result_file"], {**result, "_commit": {
623
+ "operation": copy.deepcopy(operation), "sources": state["sources"][source_count:]}})
624
+ self._save(path, state)
625
+ return result
626
+
627
+ def search(self, research_id, operation_id, queries, providers=None, limit_per_target=5):
628
+ _, state = self._load(research_id)
629
+ if not isinstance(queries, list) or not 1 <= len(queries) <= 12:
630
+ raise ValueError("queries must contain 1 to 12 question_id/query objects")
631
+ targets = []
632
+ for query in queries:
633
+ if not isinstance(query, dict) or set(query) != {"question_id", "query"}:
634
+ raise ValueError("Each query needs question_id and query only")
635
+ self._find(state["questions"], query["question_id"], "question")
636
+ row = {"target": query["question_id"], "query": self._text(query["query"], "Query", 2000)}
637
+ if row not in targets:
638
+ targets.append(row)
639
+ names = self._providers(providers if providers is not None else state["providers"])
640
+ self._integer(limit_per_target, "limit_per_target", 1, 20)
641
+ parameters = {"targets": targets, "providers": names, "limit_per_target": limit_per_target}
642
+ fallbacks = state.get("search_fallback_providers", []) if providers is None else []
643
+ previous = next((row for row in state["operations"] if row["id"] == operation_id), None)
644
+ if fallbacks and not (previous and previous.get("parameters") == parameters):
645
+ parameters["fallback_providers"] = fallbacks
646
+
647
+ def execute(path, current, operation):
648
+ def reserve(jobs):
649
+ remaining = current["budget"]["max_search_calls"] - current["usage"]["search_calls"]
650
+ selected = jobs[:remaining]
651
+ if selected:
652
+ current["usage"]["search_calls"] += len(selected)
653
+ operation["reserved"]["search_calls"] += len(selected)
654
+ operation["fallback_attempts"] = [{"provider": name, "target": target, "query": query}
655
+ for name, target, query in selected]
656
+ self._save(path, current)
657
+ return selected
658
+
659
+ result = self._web().search(**parameters, **({"reserve_fallback": reserve} if "fallback_providers" in parameters else {}))
660
+ success = result.get("successful_provider_count", 0)
661
+ failed = any(row.get("status") == "error" for row in result.get("batches", []))
662
+ return {"status": "partial" if success and failed else "ok" if success else "error",
663
+ "result": result, "usage": result.get("usage", {})}
664
+
665
+ return self._operation(research_id, operation_id, "search", parameters,
666
+ {"search_calls": len(targets) * len(names), "rounds": 1}, execute)
667
+
668
+ def fetch(self, research_id, operation_id, question_id, urls, provider=None, max_characters=12000):
669
+ _, state = self._load(research_id)
670
+ self._find(state["questions"], question_id, "question")
671
+ names = self._providers([provider] if provider is not None else state.get("fetch_providers", state["providers"]))
672
+ self._integer(max_characters, "max_characters", 100, 100000)
673
+ if not isinstance(urls, list) or not 1 <= len(urls) <= self.FETCH_BATCH_SIZE:
674
+ raise ValueError("urls must contain 1 to %s HTTP(S) URLs" % self.FETCH_BATCH_SIZE)
675
+ for url in urls:
676
+ self._text(url, "URL", 8192)
677
+ SourceArchive._key(url)
678
+ urls = list(dict.fromkeys(urls))
679
+ parameters = {"question_id": question_id, "urls": urls, "provider": names[0],
680
+ "max_characters": max_characters}
681
+ previous = next((row for row in state["operations"] if row["id"] == operation_id), None)
682
+ # Legacy receipts predate fallback selection. Replaying one must never
683
+ # submit additional requests merely because the plugin was upgraded.
684
+ if len(names) > 1 and not (previous and previous.get("parameters") == parameters):
685
+ parameters["fallback_providers"] = names[1:]
686
+
687
+ def capture(path, current, fetched):
688
+ if "result" not in fetched:
689
+ return {"status": "error", "error": fetched.get("error", "Provider returned no response"),
690
+ "error_kind": fetched.get("error_kind", "no_response"),
691
+ "retryable": fetched.get("retryable", False), "usage": fetched.get("usage", {}),
692
+ "provider_outcome": fetched}
693
+ try:
694
+ archive = SourceArchive(path / "evidence").save(fetched)
695
+ except (OSError, ValueError) as error:
696
+ # Preserve the actual response in the operation receipt when page
697
+ # archival fails. Never repeat a completed retrieval to fix storage.
698
+ return {"status": "error", "error": str(error), "error_kind": "archive_error",
699
+ "usage": fetched.get("usage", {}), "provider_outcome": fetched}
700
+ sources = []
701
+ for page in archive["pages"]:
702
+ source = {"id": "s%s" % (len(current["sources"]) + 1),
703
+ "question_id": question_id, "operation_id": operation_id,
704
+ "url": page["requested_url"], "canonical_url": SourceArchive._key(page["requested_url"]),
705
+ "provider": fetched["provider"], "retrieved_at": fetched["retrieved_at"],
706
+ "title": page["title"], "extraction_status": page["extraction_status"],
707
+ "evidence_kind": "page",
708
+ "content_kind": page["content_kind"], "completeness": page["completeness"],
709
+ "possibly_truncated": page.get("possibly_truncated", False),
710
+ "published_at": page.get("provider_published_at"), "crawled_at": page.get("provider_crawled_at"),
711
+ "sha256": page["sha256"],
712
+ "body_file": str(Path(page["body_path"]).relative_to(path)) if page["body_path"] else None,
713
+ "manifest_file": str(Path(archive["manifest_path"]).relative_to(path)),
714
+ "response_file": str(Path(archive["response_path"]).relative_to(path))}
715
+ source["review"] = {"verdict": "unreviewed", "text": None}
716
+ source["inventory_ids"], source["bookmark_refs"] = self._source_links(current, source["url"])
717
+ current["sources"].append(source)
718
+ sources.append(source)
719
+ extracted = sum(source["body_file"] is not None for source in sources)
720
+ return {"status": "ok" if extracted == len(sources) else "partial" if extracted else "error",
721
+ "sources": sources, "usage": fetched.get("usage", {}),
722
+ "next_action": "Read saved text with research_source, then record exact quotes and findings. Unread search snippets cannot support a claim."}
723
+
724
+ def execute(path, current, operation):
725
+ from web_search import SearchProviders
726
+ engine = self._web()
727
+
728
+ def reserve(candidates, pending):
729
+ remaining = current["budget"]["max_fetch_calls"] - current["usage"]["fetch_calls"]
730
+ selected, reserved = [], 0
731
+ for name in candidates:
732
+ cost = Settings.fetch_call_count(name, pending)
733
+ if cost <= remaining:
734
+ selected.append(name)
735
+ reserved += cost
736
+ remaining -= cost
737
+ if selected:
738
+ current["usage"]["fetch_calls"] += reserved
739
+ operation["reserved"]["fetch_calls"] += reserved
740
+ operation["fallback_attempts"] = [{"provider": name, "urls": pending} for name in selected]
741
+ self._save(path, current) # Durable before any parallel requests.
742
+ return selected
743
+
744
+ fetched = SearchProviders._fetch_waterfall(urls, names, lambda name, pending:
745
+ engine.fetch(pending, provider=name, archive=False, max_characters=max_characters), reserve)
746
+ captured = [capture(path, current, attempt) for attempt in fetched["attempts"]]
747
+ sources = [source for result in captured for source in result.get("sources", [])]
748
+ payload = captured[0] if len(captured) == 1 else {
749
+ "status": fetched["status"], "sources": sources,
750
+ "next_action": "Read saved text with research_source and review its identity and meaning before citing it."}
751
+ archive_errors = [result for result in captured if result.get("error_kind") == "archive_error"]
752
+ if archive_errors:
753
+ payload = {**payload, "status": "partial" if any(s["body_file"] for s in sources) else "error",
754
+ "error_kind": "archive_error", "archive_errors": archive_errors}
755
+ else:
756
+ payload["status"] = fetched["status"]
757
+ return {**payload, "usage": fetched["usage"], "per_url": fetched["per_url"],
758
+ "unresolved_urls": fetched["unresolved_urls"], "remaining_providers": fetched["remaining_providers"],
759
+ "attempts": [{key: attempt[key] for key in
760
+ ("provider", "urls", "status", "error_kind", "retryable", "skipped_due_to_cooldown")
761
+ if key in attempt} for attempt in fetched["attempts"]]}
762
+
763
+ return self._operation(research_id, operation_id, "fetch", parameters,
764
+ {"fetch_calls": Settings.fetch_call_count(names[0], urls)}, execute)
765
+
766
+ def import_evidence(self, research_id, operation_id, question_id, url, text, provenance,
767
+ inventory_ids=None, title=""):
768
+ """Save host-read page text or a secondary report without fetching it.
769
+
770
+ A report can cite original input IDs, but those references never count
771
+ as reading the cited pages. All imported evidence starts unreviewed.
772
+ """
773
+ _, state = self._load(research_id)
774
+ self._find(state["questions"], question_id, "question")
775
+ url = self._text(url, "URL", 8192)
776
+ self._text(text, "Imported text", 2000000)
777
+ if not isinstance(title, str) or len(title) > 12000 or "\x00" in title:
778
+ raise ValueError("title must be a string of at most 12000 characters")
779
+ if not isinstance(provenance, dict) or provenance.get("kind") not in (
780
+ "page", "archived_page", "local_document", "external_report"):
781
+ raise ValueError("provenance.kind must be page, archived_page, local_document or external_report")
782
+ try:
783
+ serialized = json.dumps(provenance, ensure_ascii=False, allow_nan=False)
784
+ except (ValueError, TypeError) as error:
785
+ raise ValueError("provenance must contain JSON metadata") from error
786
+ if len(serialized) > 64000:
787
+ raise ValueError("provenance metadata is too large")
788
+ provenance = json.loads(serialized)
789
+ kind = provenance["kind"]
790
+ if kind == "external_report":
791
+ canonical = url
792
+ elif kind == "local_document" and url.startswith("file://"):
793
+ canonical = url
794
+ else:
795
+ canonical = SourceArchive._key(url)
796
+ if "provider" in provenance:
797
+ self._text(provenance["provider"], "Provenance provider", 512)
798
+ if "run_id" in provenance:
799
+ self._text(provenance["run_id"], "External run id", 2000)
800
+ supplied_ids = None
801
+ if inventory_ids is not None:
802
+ if not isinstance(inventory_ids, list):
803
+ raise ValueError("inventory_ids must be a list")
804
+ supplied_ids = list(dict.fromkeys(self._identifier(value, "Inventory id") for value in inventory_ids))
805
+ for identifier in supplied_ids:
806
+ self._find(state.get("inventory", []), identifier, "inventory item")
807
+ linked_ids, _ = self._source_links(state, url)
808
+ if kind != "external_report" and supplied_ids is not None and set(supplied_ids) - set(linked_ids):
809
+ raise ValueError("Imported page URL does not match the selected original inventory URL")
810
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
811
+ if "text_sha256" in provenance and provenance["text_sha256"] != digest:
812
+ raise ValueError("Imported text does not match provenance.text_sha256")
813
+ parameters = {"question_id": question_id, "url": url, "text_sha256": digest, "title": title,
814
+ "provenance": provenance, "inventory_ids": supplied_ids}
815
+
816
+ def execute(path, current, operation):
817
+ capture = path / "evidence" / ("import-" + uuid.uuid4().hex[:16])
818
+ imported_at = self._now()
819
+ body_file, response_file, manifest_file = (capture / name for name in ("body.txt", "response.json", "manifest.json"))
820
+ self._write(body_file, text)
821
+ self._write(response_file, {"kind": "host_evidence_import", "url": url, "text": text,
822
+ "title": title, "provenance": provenance, "imported_at": imported_at})
823
+ identifiers, refs = self._source_links(current, url)
824
+ if supplied_ids is not None:
825
+ identifiers = supplied_ids
826
+ source = {"id": "s%s" % (len(current["sources"]) + 1), "question_id": question_id,
827
+ "operation_id": operation_id, "url": url, "canonical_url": canonical,
828
+ "provider": provenance.get("provider", "host_import"),
829
+ "retrieved_at": provenance.get("retrieved_at"), "imported_at": imported_at,
830
+ "title": title, "extraction_status": "extracted", "evidence_kind":
831
+ "external_report" if kind == "external_report" else "local_document" if kind == "local_document" else "page",
832
+ "content_kind": kind if kind in ("external_report", "local_document") else "imported_page_text",
833
+ "completeness": "unknown", "possibly_truncated": True,
834
+ "published_at": provenance.get("published_at"), "crawled_at": provenance.get("crawled_at"),
835
+ "sha256": digest, "body_file": str(body_file.relative_to(path)),
836
+ "manifest_file": str(manifest_file.relative_to(path)), "response_file": str(response_file.relative_to(path)),
837
+ "provenance": provenance, "review": {"verdict": "unreviewed", "text": None},
838
+ "inventory_ids": [] if kind == "external_report" else identifiers,
839
+ "referenced_inventory_ids": (supplied_ids or []) if kind == "external_report" else [],
840
+ "bookmark_refs": [] if kind == "external_report" else refs}
841
+ self._write(manifest_file, {"schema_version": 1, "kind": "host_evidence_import", "source": source})
842
+ current["sources"].append(source)
843
+ return {"status": "ok", "sources": [source], "next_action":
844
+ "Read and review the saved text. An imported report is one secondary source and does not mark its referenced original pages as read."}
845
+
846
+ return self._operation(research_id, operation_id, "import_evidence", parameters, {}, execute)
847
+
848
+ @staticmethod
849
+ def _artifact(path, relative):
850
+ target = (path / relative).resolve()
851
+ if path not in target.parents:
852
+ raise ValueError("Research artifact is outside the session")
853
+ return target
854
+
855
+ def _body(self, path, source):
856
+ if not source["body_file"] or source["extraction_status"] != "extracted":
857
+ raise ValueError("Source has no extracted page text; it cannot support a quoted claim")
858
+ body = self._artifact(path, source["body_file"]).read_bytes()
859
+ if hashlib.sha256(body).hexdigest() != source["sha256"]:
860
+ raise ValueError("Saved source hash changed; fetch a new snapshot before citing it")
861
+ return body.decode("utf-8")
862
+
863
+ def source(self, research_id, source_id, offset=0, limit=12000):
864
+ self._integer(offset, "offset", 0, 10000000)
865
+ self._integer(limit, "limit", 1, 50000)
866
+ path, state = self._load(research_id)
867
+ source = self._find(state["sources"], source_id, "source")
868
+ body = self._body(path, source)
869
+ return {"research_id": research_id, "source": source, "offset": offset,
870
+ "text": body[offset:offset + limit], "total_characters": len(body),
871
+ "next_offset": offset + limit if offset + limit < len(body) else None}
872
+
873
+ def _citations(self, path, state, citations, allow_rejected=False):
874
+ if not isinstance(citations, list) or not 1 <= len(citations) <= 12:
875
+ raise ValueError("A claim needs 1 to 12 citations from fetched sources")
876
+ checked = []
877
+ for citation in citations:
878
+ if not isinstance(citation, dict) or set(citation) != {"source_id", "quote"}:
879
+ raise ValueError("Each citation needs source_id and quote only")
880
+ source = self._find(state["sources"], citation["source_id"], "source")
881
+ if source.get("review", {}).get("verdict") == "rejected" and not allow_rejected:
882
+ raise ValueError("Source was rejected during content review: " + source["id"])
883
+ quote = self._text(citation["quote"], "Quote")
884
+ if quote not in self._body(path, source):
885
+ raise ValueError("Quote is absent from saved source " + source["id"])
886
+ checked.append({"source_id": source["id"], "quote": quote,
887
+ "source_sha256": source["sha256"], "quote_verified": True})
888
+ return checked
889
+
890
+ def _claims(self, state, identifiers, minimum=1, allow_retracted=False):
891
+ if not isinstance(identifiers, list) or not minimum <= len(identifiers) <= 100:
892
+ raise ValueError("claim_ids must contain %s to 100 existing claim IDs" % minimum)
893
+ for identifier in identifiers:
894
+ claim = self._find(state["claims"], identifier, "claim")
895
+ if claim.get("status") == "retracted" and not allow_retracted:
896
+ raise ValueError("Retracted claims cannot support an answer or resolution")
897
+ if not allow_retracted:
898
+ for citation in claim["citations"]:
899
+ source = self._find(state["sources"], citation["source_id"], "source")
900
+ if source.get("review", {}).get("verdict") == "rejected":
901
+ raise ValueError("Claim cites a rejected source; retract and replace the claim")
902
+ if len(set(identifiers)) != len(identifiers):
903
+ raise ValueError("claim_ids must be unique")
904
+ return identifiers[:]
905
+
906
+ def _inventory_review(self, path, state, entry):
907
+ identifier = self._identifier(entry.get("inventory_id"), "Inventory id")
908
+ self._find(state.get("inventory", []), identifier, "inventory item")
909
+ disposition = entry.get("disposition")
910
+ if disposition not in ("reviewed", "excluded", "blocked"):
911
+ raise ValueError("disposition must be reviewed, excluded or blocked")
912
+ reason = entry.get("reason_code")
913
+ if disposition == "excluded" and reason not in ("out_of_scope", "non_content"):
914
+ raise ValueError("Excluded inputs need reason_code=out_of_scope or non_content and a specific explanation")
915
+ if reason is not None:
916
+ reason = self._identifier(reason, "Reason code")
917
+ recorded = {"id": identifier, "inventory_id": identifier, "disposition": disposition,
918
+ "text": self._text(entry.get("text"), "Inventory review"), "reason_code": reason}
919
+ for key, collection, maximum in (("question_ids", "questions", 24), ("source_ids", "sources", 12)):
920
+ values = entry.get(key, [])
921
+ if not isinstance(values, list) or len(values) > maximum:
922
+ raise ValueError("%s must contain at most %s IDs" % (key, maximum))
923
+ values = [self._identifier(value, key) for value in values]
924
+ if len(set(values)) != len(values):
925
+ raise ValueError(key + " must be unique")
926
+ for value in values:
927
+ self._find(state[collection], value, collection)
928
+ recorded[key] = values
929
+ recorded["claim_ids"] = self._claims(state, entry.get("claim_ids", []), minimum=0,
930
+ allow_retracted=disposition != "reviewed")
931
+ citations = entry.get("citations", [])
932
+ if not isinstance(citations, list):
933
+ raise ValueError("citations must be a list")
934
+ recorded["citations"] = self._citations(path, state, citations, allow_rejected=disposition != "reviewed") if citations else []
935
+ issues = ResearchCoverage(state, lambda source: self._body(path, source)).review_issues(recorded)
936
+ if issues:
937
+ raise ValueError("Inventory review is not substantiated: " + "; ".join(issues))
938
+ previous = next((row for row in state.get("inventory_reviews", []) if row["inventory_id"] == identifier), None)
939
+ if previous and all(previous.get(key) == value for key, value in recorded.items()):
940
+ return previous, True
941
+ recorded["updated_at"] = self._now()
942
+ if previous is None:
943
+ state.setdefault("inventory_reviews", []).append(recorded)
944
+ else:
945
+ previous.clear()
946
+ previous.update(recorded)
947
+ return recorded, False
948
+
949
+ def _external_run(self, path, state, entry):
950
+ identifier = self._identifier(entry.get("id"), "External reference id")
951
+ provider = self._identifier(entry.get("provider"), "External provider")
952
+ run_id = self._text(entry.get("run_id"), "External run id", 2000)
953
+ status = entry.get("status")
954
+ if status not in ("prepared", "pending", "queued", "in_progress", "completed", "cancelled", "error", "unknown_outcome"):
955
+ raise ValueError("Unknown external run status")
956
+ previous = next((row for row in state.get("external_runs", []) if row["id"] == identifier), None)
957
+ if previous:
958
+ remote_pattern = {"openai": r"resp_[A-Za-z0-9_-]{1,192}",
959
+ "parallel": r"trun_[A-Za-z0-9_-]{1,192}"}.get(provider)
960
+ placeholder_transition = (remote_pattern and previous["run_id"] == previous["id"]
961
+ and re.fullmatch(r"er-[a-f0-9]{24}", previous["id"])
962
+ and previous["status"] in ("prepared", "pending", "unknown_outcome")
963
+ and re.fullmatch(remote_pattern, run_id))
964
+ local_prepared_transition = (not remote_pattern and previous["status"] == "prepared"
965
+ and (previous["run_id"] == previous["id"] or previous["run_id"].startswith("local:")))
966
+ if (previous["provider"] != provider or
967
+ previous["run_id"] != run_id and not (placeholder_transition or local_prepared_transition)):
968
+ raise ValueError("External reference id already belongs to another provider or run_id")
969
+ if status == "prepared" and previous["status"] != "prepared":
970
+ raise ValueError("An observed external run cannot return to prepared; preserve its actual outcome")
971
+ recorded = {**(previous or {}), "id": identifier, "provider": provider, "run_id": run_id,
972
+ "status": status, "text": self._text(entry.get("text"), "External observation")}
973
+ result_text = None
974
+ if "result" in entry:
975
+ if not isinstance(entry["result"], dict):
976
+ raise ValueError("External result must be a JSON object")
977
+ try:
978
+ result_text = json.dumps(entry["result"], ensure_ascii=False, allow_nan=False, sort_keys=True, indent=2) + "\n"
979
+ except (ValueError, TypeError) as error:
980
+ raise ValueError("External result must contain JSON data") from error
981
+ if len(result_text) > 10000000:
982
+ raise ValueError("External result is too large; use artifact_path for a saved file")
983
+ digest = hashlib.sha256(result_text.encode("utf-8")).hexdigest()
984
+ result_file = "external-runs/" + hashlib.sha256(identifier.encode()).hexdigest()[:24] + "-" + digest[:24] + ".json"
985
+ recorded.update(result_file=result_file, result_path=str(path / result_file), result_sha256=digest)
986
+ if "artifact_path" in entry:
987
+ artifact = Settings.external_path(self._text(entry["artifact_path"], "External artifact path", 8192), "External result artifact")
988
+ if not artifact.is_file():
989
+ raise ValueError("External artifact_path must name an existing file outside source packages")
990
+ recorded.update(artifact_path=str(artifact), artifact_sha256=hashlib.sha256(artifact.read_bytes()).hexdigest())
991
+ if previous and all(previous.get(key) == value for key, value in recorded.items()):
992
+ return previous, True
993
+ if result_text is not None:
994
+ artifact = path / recorded["result_file"]
995
+ if artifact.exists() and hashlib.sha256(artifact.read_bytes()).hexdigest() != recorded["result_sha256"]:
996
+ raise ValueError("Saved external result artifact hash changed")
997
+ if not artifact.exists():
998
+ self._write(artifact, result_text)
999
+ recorded["observed_at"] = self._now()
1000
+ if previous is None:
1001
+ state.setdefault("external_runs", []).append(recorded)
1002
+ else:
1003
+ previous.clear()
1004
+ previous.update(recorded)
1005
+ return recorded, False
1006
+
1007
+ def record(self, research_id, entry=None, entries=None, batch_id=None):
1008
+ if (entry is None) == (entries is None):
1009
+ raise ValueError("Provide exactly one of entry or entries")
1010
+ if entries is not None:
1011
+ if not isinstance(entries, list) or not 1 <= len(entries) <= self.RECORD_BATCH_SIZE:
1012
+ raise ValueError("entries must contain 1 to %s objects" % self.RECORD_BATCH_SIZE)
1013
+ elif batch_id is not None:
1014
+ raise ValueError("batch_id is only supported with entries")
1015
+ digest = None
1016
+ if batch_id is not None:
1017
+ batch_id = self._identifier(batch_id, "Batch id")
1018
+ try:
1019
+ payload = json.dumps(entries, sort_keys=True, ensure_ascii=False, allow_nan=False)
1020
+ except (TypeError, ValueError) as error:
1021
+ raise ValueError("entries must contain JSON data") from error
1022
+ digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
1023
+ path = self._path(research_id)
1024
+ if not (path / "state.json").is_file():
1025
+ raise ValueError("Unknown research session")
1026
+ with Settings._update_lock(path / "state.json"):
1027
+ _, state = self._load(research_id)
1028
+ if entries is None:
1029
+ result = self._record_entry(path, state, entry)
1030
+ if not result.get("replayed"):
1031
+ self._save(path, state)
1032
+ return result
1033
+ batches = state.get("record_batches", {})
1034
+ if batch_id is not None and batch_id in batches:
1035
+ previous = batches[batch_id]
1036
+ if previous["digest"] != digest:
1037
+ raise ValueError("batch_id already used with different entries")
1038
+ return {**copy.deepcopy(previous["result"]), "replayed": True}
1039
+ if batch_id is not None and len(batches) >= max(500, 10 * len(state.get("inventory", []))):
1040
+ raise ValueError("Research record batch limit reached; finish this session")
1041
+ results = []
1042
+ for index, item in enumerate(entries):
1043
+ try:
1044
+ # These two kinds write artifacts outside state.json and
1045
+ # therefore cannot participate in this atomic state update.
1046
+ if isinstance(item, dict) and item.get("kind") in ("resume", "external_run"):
1047
+ raise ValueError("resume and external_run require a single entry call")
1048
+ result = self._record_entry(path, state, item)
1049
+ except ValueError as error:
1050
+ raise ValueError("entries[%s]: %s; no entries were saved" % (index, error)) from error
1051
+ results.append({"index": index, "kind": result["kind"], "id": result["recorded"]["id"],
1052
+ "replayed": result.get("replayed", False)})
1053
+ response = {"research_id": research_id, "count": len(results), "results": results, "replayed": False}
1054
+ if batch_id is not None:
1055
+ response["batch_id"] = batch_id
1056
+ state.setdefault("record_batches", {})[batch_id] = {"digest": digest, "result": response}
1057
+ # All validation and in-batch dependencies run against the same
1058
+ # in-memory state. The entries and retry receipt commit together.
1059
+ self._save(path, state)
1060
+ return response
1061
+
1062
+ def _record_entry(self, path, state, entry):
1063
+ research_id = state["research_id"]
1064
+ if not isinstance(entry, dict):
1065
+ raise ValueError("entry must be an object")
1066
+ shapes = {"claim": {"kind", "question_id", "statement", "citations", "confidence", "inference"},
1067
+ "answer": {"kind", "question_id", "answer", "claim_ids"},
1068
+ "gap": {"kind", "question_id", "text"},
1069
+ "conflict": {"kind", "claim_ids", "text"},
1070
+ "resolution": {"kind", "conflict_id", "claim_ids", "text"},
1071
+ "source_review": {"kind", "source_id", "verdict", "text"},
1072
+ "inventory_review": {"kind", "inventory_id", "disposition", "text", "question_ids", "source_ids", "claim_ids", "citations", "reason_code"},
1073
+ "external_run": {"kind", "id", "provider", "run_id", "status", "text", "result", "artifact_path"},
1074
+ "retraction": {"kind", "claim_id", "text"},
1075
+ "question": {"kind", "id", "question"},
1076
+ "interruption": {"kind", "operation_id", "text"},
1077
+ "resume": {"kind", "text"}}
1078
+ kind = entry.get("kind")
1079
+ if not isinstance(kind, str) or kind not in shapes or set(entry) - shapes[kind]:
1080
+ raise ValueError("Unknown research entry kind or fields")
1081
+ if kind != "resume":
1082
+ self._active(state)
1083
+ if len(state["events"]) >= max(500, 10 * len(state.get("inventory", []))):
1084
+ raise ValueError("Research event limit reached; finish this session")
1085
+ question = self._find(state["questions"], entry.get("question_id"), "question") if kind in ("claim", "answer", "gap") else None
1086
+ if kind in ("inventory_review", "external_run"):
1087
+ recorded, replayed = (self._inventory_review(path, state, entry) if kind == "inventory_review"
1088
+ else self._external_run(path, state, entry))
1089
+ if replayed:
1090
+ return {"research_id": research_id, "kind": kind, "recorded": recorded, "replayed": True}
1091
+ elif kind == "resume":
1092
+ if state["status"] != "incomplete":
1093
+ raise ValueError("Only an incomplete report can be resumed; active sessions already accept steps")
1094
+ reason = self._text(entry.get("text"), "Resume reason")
1095
+ snapshot = uuid.uuid4().hex[:12]
1096
+ artifacts = {}
1097
+ # Keep earlier deliverables in the session root so their
1098
+ # relative evidence links still resolve after a later finish.
1099
+ for name, suffix in (("report", ".md"), ("sources", ".json"), ("state", ".json")):
1100
+ saved = path / (name + "-" + snapshot + suffix)
1101
+ self._write(saved, self._artifact(path, name + suffix).read_text(encoding="utf-8"))
1102
+ artifacts[name] = str(saved)
1103
+ recorded = {"status": "active", "text": reason, "previous_status": "incomplete",
1104
+ "previous_artifacts": artifacts}
1105
+ state["status"] = "active"
1106
+ for field in ("summary", "limitations", "finished_at", "artifacts"):
1107
+ state.pop(field, None)
1108
+ elif kind == "claim":
1109
+ confidence = entry.get("confidence", "medium")
1110
+ if confidence not in ("low", "medium", "high") or type(entry.get("inference", False)) is not bool:
1111
+ raise ValueError("Invalid confidence or inference field")
1112
+ if len(state["claims"]) >= max(100, 2 * len(state.get("inventory", []))):
1113
+ raise ValueError("Research claim limit reached")
1114
+ recorded = {"id": "c%s" % (len(state["claims"]) + 1), "question_id": question["id"],
1115
+ "statement": self._text(entry.get("statement"), "Statement"),
1116
+ "citations": self._citations(path, state, entry.get("citations")),
1117
+ "confidence": confidence, "inference": entry.get("inference", False), "status": "active"}
1118
+ state["claims"].append(recorded)
1119
+ elif kind == "answer":
1120
+ claims = self._claims(state, entry.get("claim_ids"))
1121
+ if any(self._find(state["claims"], identifier, "claim")["question_id"] != question["id"] for identifier in claims):
1122
+ raise ValueError("Answer claims must belong to this question")
1123
+ question.update(status="answered", answer=self._text(entry.get("answer"), "Answer", 12000),
1124
+ claim_ids=claims, gap=None)
1125
+ recorded = question
1126
+ elif kind == "gap":
1127
+ question.update(status="unresolved", gap=self._text(entry.get("text"), "Evidence gap"))
1128
+ recorded = question
1129
+ elif kind == "question":
1130
+ if len(state["questions"]) >= 24:
1131
+ raise ValueError("Research question limit reached")
1132
+ recorded = self._question({"id": entry.get("id"), "question": entry.get("question")})
1133
+ if any(row["id"] == recorded["id"] for row in state["questions"]):
1134
+ raise ValueError("Question id already exists")
1135
+ state["questions"].append(recorded)
1136
+ elif kind == "conflict":
1137
+ recorded = {"id": "x%s" % (len(state["conflicts"]) + 1), "status": "open",
1138
+ "claim_ids": self._claims(state, entry.get("claim_ids"), minimum=2, allow_retracted=True),
1139
+ "text": self._text(entry.get("text"), "Conflict"), "resolution": None}
1140
+ state["conflicts"].append(recorded)
1141
+ elif kind == "resolution":
1142
+ recorded = self._find(state["conflicts"], entry.get("conflict_id"), "conflict")
1143
+ recorded.update(status="resolved", resolution={"text": self._text(entry.get("text"), "Resolution"),
1144
+ "claim_ids": self._claims(state, entry.get("claim_ids"))})
1145
+ elif kind == "source_review":
1146
+ verdict = entry.get("verdict")
1147
+ if verdict not in ("accepted", "rejected", "uncertain"):
1148
+ raise ValueError("verdict must be accepted, rejected or uncertain")
1149
+ recorded = self._find(state["sources"], entry.get("source_id"), "source")
1150
+ if verdict == "accepted":
1151
+ self._body(path, recorded)
1152
+ recorded["review"] = {"verdict": verdict, "text": self._text(entry.get("text"), "Source review")}
1153
+ if verdict == "rejected":
1154
+ affected = {claim["id"] for claim in state["claims"]
1155
+ if any(citation["source_id"] == recorded["id"] for citation in claim["citations"])}
1156
+ for question in state["questions"]:
1157
+ if affected.intersection(question["claim_ids"]):
1158
+ question.update(status="unresolved", gap="A supporting source was rejected; review and replace affected claims.")
1159
+ for conflict in state["conflicts"]:
1160
+ if conflict["resolution"] and affected.intersection(conflict["resolution"]["claim_ids"]):
1161
+ conflict["status"] = "open"
1162
+ elif kind == "retraction":
1163
+ recorded = self._find(state["claims"], entry.get("claim_id"), "claim")
1164
+ recorded.update(status="retracted", retraction=self._text(entry.get("text"), "Retraction reason"))
1165
+ for question in state["questions"]:
1166
+ if recorded["id"] in question["claim_ids"]:
1167
+ question.update(status="unresolved", gap="A supporting claim was retracted; a revised answer is needed.")
1168
+ for conflict in state["conflicts"]:
1169
+ if conflict["resolution"] and recorded["id"] in conflict["resolution"]["claim_ids"]:
1170
+ conflict["status"] = "open"
1171
+ else:
1172
+ recorded = self._find(state["operations"], entry.get("operation_id"), "operation")
1173
+ if recorded["status"] != "pending":
1174
+ raise ValueError("Only an interrupted pending operation can be acknowledged")
1175
+ recorded.update(status="unknown_outcome", explanation=self._text(entry.get("text"), "Interruption explanation"))
1176
+ state["events"].append({"at": self._now(), "kind": kind, "value": copy.deepcopy(recorded)})
1177
+ return {"research_id": research_id, "kind": kind, "recorded": recorded}
1178
+
1179
+ def finish(self, research_id, summary, status="completed", limitations=None):
1180
+ summary = self._text(summary, "Summary", 16000)
1181
+ if status not in ("completed", "incomplete", "cancelled"):
1182
+ raise ValueError("status must be completed, incomplete or cancelled")
1183
+ limitations = [] if limitations is None else limitations
1184
+ if not isinstance(limitations, list) or len(limitations) > 100:
1185
+ raise ValueError("limitations must be a list of at most 100 strings")
1186
+ limitations = [self._text(item, "Limitation") for item in limitations]
1187
+ path = self._path(research_id)
1188
+ if not (path / "state.json").is_file():
1189
+ raise ValueError("Unknown research session")
1190
+ with Settings._update_lock(path / "state.json"):
1191
+ _, state = self._load(research_id)
1192
+ self._active(state)
1193
+ if status == "completed":
1194
+ if any(question["status"] != "answered" for question in state["questions"]):
1195
+ raise ValueError("Unanswered research questions remain; record answers or finish incomplete")
1196
+ if any(conflict["status"] == "open" for conflict in state["conflicts"]):
1197
+ raise ValueError("Unresolved evidence conflicts remain; resolve them or finish incomplete")
1198
+ if any(operation["status"] == "pending" for operation in state["operations"]):
1199
+ raise ValueError("An interrupted operation needs acknowledgement before completion")
1200
+ for question in state["questions"]:
1201
+ self._claims(state, question["claim_ids"])
1202
+ coverage = self._coverage(path, state)
1203
+ if not coverage["completion_ready"]:
1204
+ raise ValueError("Research coverage incomplete: " + "; ".join(coverage["completion_blockers"]))
1205
+ # A report must never cite a missing or altered extract, even when partial.
1206
+ for claim in state["claims"]:
1207
+ self._citations(path, state, [{"source_id": c["source_id"], "quote": c["quote"]}
1208
+ for c in claim["citations"]], allow_rejected=claim.get("status") == "retracted")
1209
+ for run in state.get("external_runs", []):
1210
+ for field, hash_field in (("result_file", "result_sha256"), ("artifact_path", "artifact_sha256")):
1211
+ if run.get(field):
1212
+ artifact = self._artifact(path, run[field]) if field == "result_file" else Settings.external_path(run[field], "External result artifact")
1213
+ if hashlib.sha256(artifact.read_bytes()).hexdigest() != run[hash_field]:
1214
+ raise ValueError("Saved external result artifact hash changed")
1215
+ coverage = self._coverage(path, state)
1216
+ state.update(status=status, summary=summary, limitations=limitations, finished_at=self._now())
1217
+ coverage_text = json.dumps(coverage, ensure_ascii=False, allow_nan=False, indent=2) + "\n"
1218
+ coverage_name = "coverage-" + hashlib.sha256(coverage_text.encode("utf-8")).hexdigest()[:24] + ".json"
1219
+ state["artifacts"] = {"report": str(path / "report.md"), "sources": str(path / "sources.json"),
1220
+ "state": str(path / "state.json"), "context": str(path / "context.json"),
1221
+ "coverage": str(path / coverage_name)}
1222
+ if state.get("source_scope"):
1223
+ state["artifacts"]["inventory"] = str(path / "inventory.json")
1224
+ self._write(path / coverage_name, coverage_text)
1225
+ self._write(path / "report.md", self._report(state, coverage))
1226
+ self._write(path / "sources.json", {"schema_version": state["schema_version"], "research_id": research_id,
1227
+ "status": status, "sources": state["sources"], "claims": state["claims"],
1228
+ "conflicts": state["conflicts"], "operations": state["operations"],
1229
+ "questions": state["questions"], "limitations": limitations,
1230
+ "source_scope": state.get("source_scope"), "inventory_manifest_sha256": state.get("inventory_manifest_sha256"),
1231
+ "inventory_reviews": state.get("inventory_reviews", []), "external_runs": state.get("external_runs", []),
1232
+ "coverage": {key: value for key, value in coverage.items() if key != "rows"}})
1233
+ self._save(path, state)
1234
+ return self._summary(path, state)
1235
+
1236
+ @staticmethod
1237
+ def _report(state, coverage=None):
1238
+ lines = ["# " + state["brief"], "", "Status: **%s** · %s" % (state["status"], state["finished_at"]),
1239
+ "", state["summary"], "", "## Scope", "", state["scope"] or "See the research questions below.",
1240
+ "", "Indexed input sources: " + (", ".join(state["source_ids"]) or "None"),
1241
+ "", "[Bookmark context in the research scope](context.json)", ""]
1242
+ scope = state.get("source_scope")
1243
+ if scope:
1244
+ lines.extend(["Input scope: **%s** · %s / %s exact original URLs · %s / %s bookmark instances." %
1245
+ (scope["mode"], scope["selected_url_count"], scope["input_url_count"],
1246
+ scope["selected_instance_count"], scope["input_instance_count"]), "",
1247
+ "Input version: `%s` · [Complete frozen inventory](inventory.json)" % scope["input_version"], ""])
1248
+ else:
1249
+ lines.extend(["Legacy session: no complete input inventory was captured; original-package coverage cannot be inferred.", ""])
1250
+ fetch_plan = state.get("initial_fetch_plan")
1251
+ if fetch_plan and fetch_plan["selected_urls"]:
1252
+ lines.extend(["Initial fetch budget: minimum_required=%s calls at up to %s URLs/call; configured=%s; "
1253
+ "call_shortfall=%s; URLs beyond that capacity=%s. The complete scope is retained." %
1254
+ (fetch_plan["minimum_required"], fetch_plan["max_urls_per_call"], fetch_plan["configured_limit"],
1255
+ fetch_plan["call_shortfall"], fetch_plan["urls_beyond_capacity"]),
1256
+ fetch_plan["basis"], ""])
1257
+ if coverage:
1258
+ lines.extend(["| Coverage measure | Count / scope | Rate |", "| --- | ---: | ---: |"])
1259
+ for label, key in (("Accounted for", "accounted_for"), ("Usable accepted text", "usable_text"),
1260
+ ("Substantive review", "substantive_review"), ("Questions completed", "question_completion")):
1261
+ metric = coverage["metrics"][key]
1262
+ rate = "n/a" if metric["rate"] is None else "%.1f%%" % (100 * metric["rate"])
1263
+ lines.append("| %s | %s / %s | %s |" % (label, metric["count"], metric["total"], rate))
1264
+ lines.extend(["", "Failure notes and explicit exclusions count only toward accounting. A substantive review requires accepted original-page text and a quoted judgment; these checks do not independently judge its reasoning quality.", "",
1265
+ "[Per-input reviews and gaps](%s)" % Path(state["artifacts"]["coverage"]).name, ""])
1266
+ lines.extend("- " + blocker for blocker in coverage["completion_blockers"])
1267
+ lines.append("")
1268
+ lines.extend(["## Research questions", ""])
1269
+ for question in state["questions"]:
1270
+ references = " ".join("[%s](#claim-%s)" % (identifier, identifier) for identifier in question["claim_ids"])
1271
+ answer = question["answer"] or "No supported answer recorded."
1272
+ if question["answer"] and question["status"] != "answered":
1273
+ answer = "Previous answer (requires revision): " + answer
1274
+ lines.extend(["### " + question["id"] + ": " + question["question"], "",
1275
+ "Status: " + question["status"], "",
1276
+ answer + " " + references, ""])
1277
+ if question["gap"]:
1278
+ lines.extend(["Unresolved: " + question["gap"], ""])
1279
+ lines.extend(["## Claims and evidence", ""])
1280
+ for claim in state["claims"]:
1281
+ lines.extend(["### Claim " + claim["id"], "", "%s (confidence: %s; %s; status: %s)" % (claim["statement"], claim["confidence"],
1282
+ "model inference" if claim["inference"] else "source-backed finding", claim.get("status", "active")), ""])
1283
+ if claim.get("retraction"):
1284
+ lines.extend(["Retracted: " + claim["retraction"], ""])
1285
+ for citation in claim["citations"]:
1286
+ lines.extend(["Source [%s](#source-%s); exact saved quote:" % (citation["source_id"], citation["source_id"]), ""])
1287
+ lines.extend("> " + line for line in citation["quote"].splitlines())
1288
+ lines.append("")
1289
+ lines.extend(["## Conflicts", ""])
1290
+ for conflict in state["conflicts"]:
1291
+ lines.extend(["- %s (%s): %s — claims %s" % (conflict["id"], conflict["status"], conflict["text"],
1292
+ ", ".join("[%s](#claim-%s)" % (identifier, identifier) for identifier in conflict["claim_ids"]))])
1293
+ if conflict["resolution"]:
1294
+ references = ", ".join("[%s](#claim-%s)" % (identifier, identifier) for identifier in conflict["resolution"]["claim_ids"])
1295
+ lines.append(" Resolution: " + conflict["resolution"]["text"] + " (" + references + ")")
1296
+ if not state["conflicts"]:
1297
+ lines.append("No conflicts recorded; this does not prove that none exist.")
1298
+ lines.extend(["", "## Limitations and retrieval coverage", "",
1299
+ "Saved evidence may contain page extracts, local documents or secondary external reports; each source records its evidence kind and provenance. Sources have unknown completeness and origin freshness unless established separately. Retrieval dates are not publication dates. Multiple providers returning the same URL are one page, not independent confirmation.", ""])
1300
+ lines.extend("- " + limitation for limitation in state["limitations"])
1301
+ for operation in state["operations"]:
1302
+ if operation["status"] != "ok":
1303
+ lines.append("- %s (%s): %s; inspect %s" % (operation["id"], operation["kind"], operation["status"],
1304
+ operation["result_file"] or "state.json"))
1305
+ if state.get("external_runs"):
1306
+ lines.extend(["", "## External run observations", "",
1307
+ "These references record host or provider observations. Run completion does not establish input coverage.", ""])
1308
+ for run in state["external_runs"]:
1309
+ lines.append("- %s · %s / %s · %s: %s" % (run["id"], run["provider"], run["run_id"], run["status"], run["text"]))
1310
+ if run.get("result_file"):
1311
+ lines.append(" [Saved complete result](%s) · SHA-256: `%s`" % (run["result_file"], run["result_sha256"]))
1312
+ lines.extend(["", "Budget reservations: " + json.dumps(state["usage"], ensure_ascii=False), "",
1313
+ "## Sources", ""])
1314
+ for source in state["sources"]:
1315
+ lines.extend(["### Source " + source["id"], "", source["url"], "",
1316
+ "Provider: %s · Retrieved: %s · Status: %s · Evidence: %s · Content: %s" %
1317
+ (source["provider"], source["retrieved_at"] or "unknown", source["extraction_status"],
1318
+ source.get("evidence_kind", "page"), source["content_kind"]), "",
1319
+ "Content review: " + source.get("review", {}).get("verdict", "unreviewed") +
1320
+ (" — " + source["review"]["text"] if source.get("review", {}).get("text") else ""), "",
1321
+ "[Manifest](%s) · [Actual response](%s)" % (source["manifest_file"], source["response_file"])])
1322
+ if source["body_file"]:
1323
+ lines.append(" · [Saved text](%s) · SHA-256: `%s`" % (source["body_file"], source["sha256"]))
1324
+ if source.get("evidence_kind") == "external_report":
1325
+ lines.extend(["", "Secondary external report; its referenced original pages are not marked as read."])
1326
+ lines.append("")
1327
+ resumes = [event["value"] for event in state["events"] if event["kind"] == "resume"]
1328
+ if resumes:
1329
+ lines.extend(["## Earlier reports", ""])
1330
+ for resumed in resumes:
1331
+ lines.append("- [Previous incomplete report](%s): %s" %
1332
+ (Path(resumed["previous_artifacts"]["report"]).name, resumed["text"]))
1333
+ lines.append("")
1334
+ return "\n".join(lines) + "\n"