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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- package/src/wiki.py +383 -0
package/src/research.py
ADDED
|
@@ -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"
|