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
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
"""Thin provider-run references and evidence import; providers own execution.
|
|
2
|
+
|
|
3
|
+
No polling loop, worker pool or host-agent scheduler lives here. Each call either
|
|
4
|
+
prepares a concrete request, starts it once, observes its existing provider ID,
|
|
5
|
+
or imports a saved report. Lost create responses never trigger resubmission.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from research import ResearchSessions
|
|
18
|
+
from service_http import ResearchHttp, ServiceError
|
|
19
|
+
from settings import Settings
|
|
20
|
+
from credentials import Credentials
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResearchServices:
|
|
24
|
+
DOCS = {
|
|
25
|
+
"openai": "https://developers.openai.com/api/docs/guides/deep-research",
|
|
26
|
+
"parallel": "https://docs.parallel.ai/task-api/guides/execute-task-run",
|
|
27
|
+
}
|
|
28
|
+
ACTIVE = ("pending", "queued", "in_progress", "unknown_outcome")
|
|
29
|
+
|
|
30
|
+
def __init__(self, directory=None, settings=None, research_sessions=None, transport=None):
|
|
31
|
+
self.settings = settings if settings is not None else Settings()
|
|
32
|
+
self.credentials = Credentials(self.settings)
|
|
33
|
+
self.directory = Settings.external_path(
|
|
34
|
+
str(directory or (Settings.data_directory() / "service-runs")), "Research service directory")
|
|
35
|
+
self.sessions = research_sessions if research_sessions is not None else ResearchSessions(settings=self.settings)
|
|
36
|
+
self.transport = transport
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _now():
|
|
40
|
+
return ResearchSessions._now()
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _text(value, label, maximum=12000):
|
|
44
|
+
return ResearchSessions._text(value, label, maximum)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _identifier(value, label):
|
|
48
|
+
return ResearchSessions._identifier(value, label)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _json(value):
|
|
52
|
+
return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":"))
|
|
53
|
+
|
|
54
|
+
def _provider(self, value):
|
|
55
|
+
provider = self.settings.load()["professional_research"]["provider"] if value is None else value
|
|
56
|
+
if not isinstance(provider, str) or provider not in self.DOCS:
|
|
57
|
+
raise ValueError("Select openai or parallel for professional research")
|
|
58
|
+
return provider
|
|
59
|
+
|
|
60
|
+
def _path(self, external_id):
|
|
61
|
+
if not isinstance(external_id, str) or not re.fullmatch(r"er-[a-f0-9]{24}", external_id):
|
|
62
|
+
raise ValueError("Invalid external_id")
|
|
63
|
+
path = self.directory / external_id
|
|
64
|
+
if path.is_symlink() or path.resolve().parent != self.directory:
|
|
65
|
+
raise ValueError("Research service record must stay in its data directory")
|
|
66
|
+
return Settings.external_path(str(path), "Research service record")
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _remote_id(provider, run_id):
|
|
70
|
+
pattern = r"resp_[A-Za-z0-9_-]{1,192}" if provider == "openai" else r"trun_[A-Za-z0-9_-]{1,192}"
|
|
71
|
+
if not isinstance(run_id, str) or not re.fullmatch(pattern, run_id):
|
|
72
|
+
raise ValueError("Invalid provider run ID")
|
|
73
|
+
return run_id
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _external_id(research_id, operation_id):
|
|
77
|
+
return "er-" + hashlib.sha256((research_id + "\0" + operation_id).encode()).hexdigest()[:24]
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _save(path, state):
|
|
81
|
+
state["updated_at"] = ResearchServices._now()
|
|
82
|
+
ResearchSessions._write(path / "state.json", state)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _write_bytes(path, value):
|
|
86
|
+
"""Keep report line endings and UTF-8 bytes unchanged on every OS."""
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
88
|
+
temporary = None
|
|
89
|
+
try:
|
|
90
|
+
with tempfile.NamedTemporaryFile(mode="wb", dir=path.parent, prefix=".service-", delete=False) as stream:
|
|
91
|
+
temporary = Path(stream.name)
|
|
92
|
+
stream.write(value)
|
|
93
|
+
stream.flush()
|
|
94
|
+
os.fsync(stream.fileno())
|
|
95
|
+
temporary.replace(path)
|
|
96
|
+
finally:
|
|
97
|
+
if temporary is not None:
|
|
98
|
+
temporary.unlink(missing_ok=True)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _local_error(kind, message):
|
|
102
|
+
return {"error_kind": kind, "error": message}
|
|
103
|
+
|
|
104
|
+
def _run_data(self, provider, response):
|
|
105
|
+
if not isinstance(response, dict):
|
|
106
|
+
raise ValueError("Invalid research API response")
|
|
107
|
+
data = response.get("run", response) if provider == "parallel" else response
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
raise ValueError("Invalid research API run response")
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
def _capture_create_id(self, state, response):
|
|
113
|
+
"""A valid create ID remains useful even if its report cannot be parsed."""
|
|
114
|
+
if state.get("provider_run_id"):
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
data = self._run_data(state["provider"], response)
|
|
118
|
+
state["provider_run_id"] = self._remote_id(state["provider"], data.get("id" if state["provider"] == "openai" else "run_id"))
|
|
119
|
+
except ValueError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def _receipt(self, path, relative):
|
|
123
|
+
target = ResearchSessions._artifact(path, relative)
|
|
124
|
+
if target.stat().st_size > 2 * ResearchHttp.MAX_RESPONSE_BYTES:
|
|
125
|
+
raise ValueError("Saved research response exceeds the local size limit")
|
|
126
|
+
return json.loads(target.read_bytes(), object_pairs_hook=ResearchHttp._unique_object,
|
|
127
|
+
parse_constant=ResearchHttp._invalid_constant, parse_float=ResearchHttp._finite_float)
|
|
128
|
+
|
|
129
|
+
def _load(self, external_id):
|
|
130
|
+
path = self._path(external_id)
|
|
131
|
+
state_path = path / "state.json"
|
|
132
|
+
if state_path.is_symlink():
|
|
133
|
+
raise ValueError("Research service state must not be a symlink")
|
|
134
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
135
|
+
if state.get("schema_version") != 1 or state.get("external_id") != external_id:
|
|
136
|
+
raise ValueError("Inconsistent research service record")
|
|
137
|
+
receipt = path / "create-receipt.json"
|
|
138
|
+
if (state["status"] in ("pending", "unknown_outcome") and not state.get("attached_at")
|
|
139
|
+
and not state.get("create_response_applied", bool(state.get("provider_run_id")))):
|
|
140
|
+
if receipt.is_file():
|
|
141
|
+
matched = False
|
|
142
|
+
try:
|
|
143
|
+
value = self._receipt(path, "create-receipt.json")
|
|
144
|
+
if not isinstance(value, dict) or value.get("external_id") != external_id or value.get("digest") != state["digest"]:
|
|
145
|
+
raise ValueError("Create receipt does not match research request")
|
|
146
|
+
response = value["response"]
|
|
147
|
+
matched = True
|
|
148
|
+
self._apply_response(path, state, response, "create-receipt.json", value.get("received_at"))
|
|
149
|
+
except (ValueError, OSError, KeyError, TypeError):
|
|
150
|
+
if matched:
|
|
151
|
+
self._capture_create_id(state, response)
|
|
152
|
+
state["status"] = "unknown_outcome"
|
|
153
|
+
state["observation_error"] = self._local_error("create_receipt_error", "Saved create receipt could not be verified or interpreted; no request was repeated")
|
|
154
|
+
elif state["status"] == "pending":
|
|
155
|
+
state["status"] = "unknown_outcome"
|
|
156
|
+
state["observation_error"] = self._local_error("create_interrupted", "Create outcome is unknown; no provider request was repeated")
|
|
157
|
+
pending = state.get("pending_observation")
|
|
158
|
+
if pending:
|
|
159
|
+
try:
|
|
160
|
+
value = self._receipt(path, pending["response_file"])
|
|
161
|
+
if (not isinstance(value, dict) or value.get("external_id") != external_id
|
|
162
|
+
or value.get("provider_run_id") != state.get("provider_run_id") or value.get("action") != pending["action"]):
|
|
163
|
+
raise ValueError("Observation receipt does not match the existing run")
|
|
164
|
+
self._apply_response(path, state, value["response"], pending["response_file"], value.get("received_at"))
|
|
165
|
+
state.pop("pending_observation", None)
|
|
166
|
+
except (ValueError, OSError, KeyError, TypeError):
|
|
167
|
+
state["observation_error"] = self._local_error("observation_receipt_error", "An interrupted observation could not be recovered; the last provider state is retained")
|
|
168
|
+
return path, state
|
|
169
|
+
|
|
170
|
+
def _request(self, provider, method, endpoint, payload=None):
|
|
171
|
+
client = self.transport or ResearchHttp(self.settings.load()["timeout_seconds"], credentials=self.credentials)
|
|
172
|
+
return client.request(provider, method, endpoint, payload)
|
|
173
|
+
|
|
174
|
+
def _availability(self, provider, config):
|
|
175
|
+
key = ResearchHttp.PROVIDERS[provider]["key_env"]
|
|
176
|
+
missing = ([] if config["enabled"] else ["professional_research.enabled"])
|
|
177
|
+
missing += [] if self.credentials.get(key) else [key]
|
|
178
|
+
return {"ready_to_start": not missing, "missing": missing, "authentication_verified": False}
|
|
179
|
+
|
|
180
|
+
def describe(self, provider=None):
|
|
181
|
+
config = self.settings.load()["professional_research"]
|
|
182
|
+
providers = [self._provider(provider)] if provider is not None else list(self.DOCS)
|
|
183
|
+
return {"enabled": config["enabled"], "default_provider": config["provider"], "network_checked": False,
|
|
184
|
+
"providers": [{"provider": name, "docs": self.DOCS[name],
|
|
185
|
+
**self._availability(name, config),
|
|
186
|
+
"credential_env": ResearchHttp.PROVIDERS[name]["key_env"],
|
|
187
|
+
"credential_configured": bool(self.credentials.get(ResearchHttp.PROVIDERS[name]["key_env"])),
|
|
188
|
+
"authentication_verified": False, "defaults": config[name],
|
|
189
|
+
"cancel_supported": name == "openai",
|
|
190
|
+
"budget_controls": ["max_tool_calls"] if name == "openai" else ["processor"]}
|
|
191
|
+
for name in providers],
|
|
192
|
+
"execution": "Provider-managed runs; observe explicitly by run ID. No local worker or automatic retry.",
|
|
193
|
+
"workflow": ["research_start", "research_service_prepare", "research_service_start",
|
|
194
|
+
"research_service_status", "research_service_result", "research_service_import"],
|
|
195
|
+
"native_research": {"available": True, "entrypoint": "research_start",
|
|
196
|
+
"execution_owner": "host", "requires_service_credentials": False},
|
|
197
|
+
"cost_note": "Tool-count/processor controls are not a monetary cap. Host-native research tools remain usable independently."}
|
|
198
|
+
|
|
199
|
+
def prepare(self, research_id, input, provider=None, question_id=None, inventory_ids=None, options=None):
|
|
200
|
+
provider = self._provider(provider)
|
|
201
|
+
input = self._text(input, "Research service input", 50000)
|
|
202
|
+
_, state = self.sessions._load(research_id)
|
|
203
|
+
ResearchSessions._active(state)
|
|
204
|
+
if question_id is not None:
|
|
205
|
+
ResearchSessions._find(state["questions"], question_id, "question")
|
|
206
|
+
options = {} if options is None else copy.deepcopy(options)
|
|
207
|
+
if not isinstance(options, dict):
|
|
208
|
+
raise ValueError("Research service options must be an object")
|
|
209
|
+
allowed = {"model", "max_tool_calls", "store", "vector_store_ids"} if provider == "openai" else {"processor", "output_schema"}
|
|
210
|
+
if set(options) - allowed:
|
|
211
|
+
raise ValueError("Unsupported " + provider + " research options")
|
|
212
|
+
source_scope = copy.deepcopy(state.get("source_scope", {}))
|
|
213
|
+
selected = source_scope.get("selected_inventory_ids", [])
|
|
214
|
+
if inventory_ids is not None:
|
|
215
|
+
if (not isinstance(inventory_ids, list) or any(not isinstance(x, str) for x in inventory_ids)
|
|
216
|
+
or len(set(inventory_ids)) != len(inventory_ids) or set(inventory_ids) - set(selected)):
|
|
217
|
+
raise ValueError("inventory_ids must select distinct sources within the frozen research scope")
|
|
218
|
+
selected = inventory_ids[:]
|
|
219
|
+
sources, local_only = [], []
|
|
220
|
+
selected_set = set(selected)
|
|
221
|
+
# _load verified the frozen inventory. Read its complete entries here:
|
|
222
|
+
# the public inventory API intentionally limits ID filters and may
|
|
223
|
+
# replace oversized context with an artifact pointer in a single page.
|
|
224
|
+
for item in state.get("inventory", []):
|
|
225
|
+
if item["id"] not in selected_set:
|
|
226
|
+
continue
|
|
227
|
+
url = item.get("retrieval_url")
|
|
228
|
+
if isinstance(url, str) and url.startswith(("https://", "http://")):
|
|
229
|
+
sources.append({"source_id": item["id"], "url": url})
|
|
230
|
+
else:
|
|
231
|
+
local_only.append(item["id"])
|
|
232
|
+
shared = {"research_question": input, "sources": sources,
|
|
233
|
+
"local_only_source_ids": local_only,
|
|
234
|
+
"requirements": "Investigate the question using the supplied source scope. Identify unread or inaccessible sources and contradictions. Return a cited report; do not claim coverage for sources not actually reviewed."}
|
|
235
|
+
input_text = self._json(shared)
|
|
236
|
+
if len(input_text) > 1_000_000:
|
|
237
|
+
raise ValueError("Research input exceeds the API adapter size limit; explicitly split the investigation without shrinking its recorded scope")
|
|
238
|
+
config = self.settings.load()["professional_research"]
|
|
239
|
+
defaults = config[provider]
|
|
240
|
+
if provider == "openai":
|
|
241
|
+
model = options.get("model", defaults["model"])
|
|
242
|
+
maximum = options.get("max_tool_calls", defaults["max_tool_calls"])
|
|
243
|
+
if model not in ("o3-deep-research", "o4-mini-deep-research"):
|
|
244
|
+
raise ValueError("Use a documented OpenAI deep research model")
|
|
245
|
+
if type(maximum) is not int or not 1 <= maximum <= 1000:
|
|
246
|
+
raise ValueError("max_tool_calls must be between 1 and 1000")
|
|
247
|
+
store = options.get("store", False)
|
|
248
|
+
if type(store) is not bool:
|
|
249
|
+
raise ValueError("store must be boolean")
|
|
250
|
+
tools = [{"type": "web_search_preview"}]
|
|
251
|
+
if "vector_store_ids" in options:
|
|
252
|
+
stores = options["vector_store_ids"]
|
|
253
|
+
if (not isinstance(stores, list) or not 1 <= len(stores) <= 2
|
|
254
|
+
or any(not isinstance(x, str) or not re.fullmatch(r"vs_[A-Za-z0-9_-]+", x) for x in stores)):
|
|
255
|
+
raise ValueError("vector_store_ids must contain one or two remote OpenAI vector store IDs")
|
|
256
|
+
tools.append({"type": "file_search", "vector_store_ids": stores})
|
|
257
|
+
payload = {"model": model, "input": input_text, "background": True, "store": store,
|
|
258
|
+
"tools": tools, "max_tool_calls": maximum}
|
|
259
|
+
endpoint = "/v1/responses"
|
|
260
|
+
else:
|
|
261
|
+
processor = options.get("processor", defaults["processor"])
|
|
262
|
+
if not isinstance(processor, str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", processor):
|
|
263
|
+
raise ValueError("Invalid Parallel processor")
|
|
264
|
+
schema = options.get("output_schema", "A Markdown research report with citations, contradictions, and an explicit source coverage/gap section.")
|
|
265
|
+
schema = self._text(schema, "Parallel output schema", 12000)
|
|
266
|
+
payload = {"processor": processor, "input": input_text, "task_spec": {"output_schema": schema}}
|
|
267
|
+
endpoint = "/v1/tasks/runs"
|
|
268
|
+
return {"research_id": research_id, "provider": provider, "question_id": question_id,
|
|
269
|
+
**self._availability(provider, config),
|
|
270
|
+
"next_action": "research_service_start" if self._availability(provider, config)["ready_to_start"]
|
|
271
|
+
else "Configure the listed missing settings/credential in the host environment before starting this service.",
|
|
272
|
+
"endpoint": endpoint, "payload": payload,
|
|
273
|
+
"source_scope": {"input_version": source_scope.get("input_version"),
|
|
274
|
+
"inventory_ids": selected, "shared_url_count": len(sources),
|
|
275
|
+
"local_only_inventory_ids": local_only,
|
|
276
|
+
"remaining_inventory_ids": [x for x in source_scope.get("selected_inventory_ids", []) if x not in selected_set]},
|
|
277
|
+
"network_performed": False,
|
|
278
|
+
"data_note": "Only the explicit input and source URLs are sent. Local paths, bookmark labels, notes and files are not uploaded. Imported reports do not count as reading their cited pages."}
|
|
279
|
+
|
|
280
|
+
def _publish(self, path, state, strict=False):
|
|
281
|
+
entry = {"kind": "external_run", "id": state["external_id"], "provider": state["provider"],
|
|
282
|
+
"run_id": state.get("provider_run_id") or state["external_id"],
|
|
283
|
+
"status": state["status"],
|
|
284
|
+
"text": "Provider-run observation; " + ("provider ID verified from a response or explicitly attached." if state.get("provider_run_id") else "create outcome has no known provider ID; no local worker is implied."),
|
|
285
|
+
"result": {"provider_status": state.get("provider_status"), "observed_at": state.get("observed_at"),
|
|
286
|
+
"source_scope": state.get("source_scope"), "report_path": state.get("report_path"),
|
|
287
|
+
"usage": state.get("usage"), "observation_error": state.get("observation_error")}}
|
|
288
|
+
if state.get("response_file"):
|
|
289
|
+
entry["artifact_path"] = str(path / state["response_file"])
|
|
290
|
+
try:
|
|
291
|
+
self.sessions.record(state["research_id"], entry)
|
|
292
|
+
state.pop("research_record_error", None)
|
|
293
|
+
except (ValueError, OSError, RuntimeError) as error:
|
|
294
|
+
if strict:
|
|
295
|
+
raise
|
|
296
|
+
state["research_record_error"] = "Provider observation saved, but research record could not be updated: " + type(error).__name__
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _next_action(state):
|
|
300
|
+
external_id = state["external_id"]
|
|
301
|
+
if state["status"] in ResearchServices.ACTIVE:
|
|
302
|
+
if state.get("provider_run_id"):
|
|
303
|
+
return {"tool": "research_service_status", "arguments": {"external_id": external_id, "refresh": True},
|
|
304
|
+
"poll_after_seconds": 15}
|
|
305
|
+
return {"tool": "research_service_attach", "requires": ["known provider run_id"],
|
|
306
|
+
"reason": "The create outcome is unknown. Recover the existing provider ID; do not submit another run."}
|
|
307
|
+
if state["status"] == "completed":
|
|
308
|
+
return {"tool": "research_service_result",
|
|
309
|
+
"arguments": {"external_id": external_id, "refresh": not bool(state.get("report_path"))},
|
|
310
|
+
"then": "Read all report pages, import with research_service_import, and verify cited source pages."}
|
|
311
|
+
if state["status"] == "error":
|
|
312
|
+
return {"tool": "research_route", "arguments": {"depth": "deep",
|
|
313
|
+
"failed_routes": ["professional_service:" + state["provider"]]},
|
|
314
|
+
"research_id": state["research_id"],
|
|
315
|
+
"reason": "Confirmed failure: add current host observations and earlier failed_routes, then continue the same research. Preserve an explicit user provider choice instead of silently switching."}
|
|
316
|
+
return {"tool": "research_status", "arguments": {"research_id": state["research_id"]},
|
|
317
|
+
"reason": "Inspect the cancellation and remaining evidence gaps; cancellation does not trigger a replacement run."}
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _summary(path, state):
|
|
321
|
+
fields = ("external_id", "research_id", "operation_id", "provider", "provider_run_id", "status",
|
|
322
|
+
"provider_status", "created_at", "updated_at", "observed_at", "source_scope", "report_path",
|
|
323
|
+
"report_sha256", "response_sha256", "request_sha256", "citations_file", "citations_sha256", "citation_count",
|
|
324
|
+
"usage", "observation_error", "research_record_error", "imported_evidence")
|
|
325
|
+
return {**{key: copy.deepcopy(state.get(key)) for key in fields}, "directory": str(path),
|
|
326
|
+
"next_action": ResearchServices._next_action(state),
|
|
327
|
+
"request_path": str(path / "request.json") if (path / "request.json").is_file() else None,
|
|
328
|
+
"observations": len(state.get("observations", [])),
|
|
329
|
+
"local_worker_running": False,
|
|
330
|
+
"note": "Status is the last observation. Refresh observes the same provider run; a timeout never recreates it."}
|
|
331
|
+
|
|
332
|
+
def _intent(self, input, provider, question_id, inventory_ids, options):
|
|
333
|
+
value = {"input": self._text(input, "Research service input", 50000), "provider": provider,
|
|
334
|
+
"question_id": question_id, "inventory_ids": inventory_ids, "options": {} if options is None else options}
|
|
335
|
+
if provider is not None and (not isinstance(provider, str) or provider not in self.DOCS):
|
|
336
|
+
raise ValueError("Select openai or parallel for professional research")
|
|
337
|
+
if not isinstance(value["options"], dict):
|
|
338
|
+
raise ValueError("Research service options must be an object")
|
|
339
|
+
if question_id is not None:
|
|
340
|
+
self._identifier(question_id, "question_id")
|
|
341
|
+
if inventory_ids is not None and (not isinstance(inventory_ids, list)
|
|
342
|
+
or any(not isinstance(item, str) for item in inventory_ids) or len(set(inventory_ids)) != len(inventory_ids)):
|
|
343
|
+
raise ValueError("inventory_ids must contain distinct source IDs")
|
|
344
|
+
try:
|
|
345
|
+
return json.loads(self._json(value))
|
|
346
|
+
except (ValueError, TypeError):
|
|
347
|
+
raise ValueError("Research service arguments must contain valid JSON") from None
|
|
348
|
+
|
|
349
|
+
def _replay_matches(self, path, state, intent, intent_digest):
|
|
350
|
+
if state.get("intent_digest") is not None:
|
|
351
|
+
return state["intent_digest"] == intent_digest
|
|
352
|
+
# Read old records against their frozen request, never today's defaults.
|
|
353
|
+
if not state.get("digest") or not (path / "request.json").is_file():
|
|
354
|
+
return False
|
|
355
|
+
request = self._receipt(path, "request.json")
|
|
356
|
+
if hashlib.sha256(self._json(request).encode()).hexdigest() != state["digest"]:
|
|
357
|
+
raise ValueError("Saved research request hash does not match")
|
|
358
|
+
if intent["provider"] not in (None, state["provider"]) or intent["question_id"] != request.get("question_id"):
|
|
359
|
+
return False
|
|
360
|
+
if intent["input"] != json.loads(request["payload"]["input"])["research_question"]:
|
|
361
|
+
return False
|
|
362
|
+
if intent["inventory_ids"] is not None and intent["inventory_ids"] != request["source_scope"]["inventory_ids"]:
|
|
363
|
+
return False
|
|
364
|
+
for key, value in intent["options"].items():
|
|
365
|
+
payload = request["payload"]
|
|
366
|
+
old = payload.get("task_spec", {}).get("output_schema") if key == "output_schema" else payload.get(key)
|
|
367
|
+
if key == "vector_store_ids":
|
|
368
|
+
old = next((tool.get(key) for tool in payload.get("tools", []) if tool.get("type") == "file_search"), None)
|
|
369
|
+
if old != value:
|
|
370
|
+
return False
|
|
371
|
+
return True
|
|
372
|
+
|
|
373
|
+
def start(self, research_id, operation_id, input, provider=None, question_id=None, inventory_ids=None, options=None):
|
|
374
|
+
operation_id = self._identifier(operation_id, "operation_id")
|
|
375
|
+
self.sessions._path(research_id)
|
|
376
|
+
intent = self._intent(input, provider, question_id, inventory_ids, options)
|
|
377
|
+
intent_digest = hashlib.sha256(self._json(intent).encode()).hexdigest()
|
|
378
|
+
external_id = self._external_id(research_id, operation_id)
|
|
379
|
+
path = self._path(external_id)
|
|
380
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
381
|
+
with Settings._update_lock(path / "state.json"):
|
|
382
|
+
if (path / "state.json").exists():
|
|
383
|
+
_, state = self._load(external_id)
|
|
384
|
+
if not self._replay_matches(path, state, intent, intent_digest):
|
|
385
|
+
raise ValueError("operation_id already refers to a different research service request")
|
|
386
|
+
self._publish(path, state)
|
|
387
|
+
self._save(path, state)
|
|
388
|
+
return {**self._summary(path, state), "replayed": True}
|
|
389
|
+
request = self.prepare(research_id, input, provider, question_id, inventory_ids, options)
|
|
390
|
+
provider = request["provider"]
|
|
391
|
+
digest = hashlib.sha256(self._json(request).encode()).hexdigest()
|
|
392
|
+
if not self.settings.load()["professional_research"]["enabled"]:
|
|
393
|
+
raise ValueError("Professional research is disabled; enable professional_research.enabled to start API runs")
|
|
394
|
+
if not self.credentials.get(ResearchHttp.PROVIDERS[provider]["key_env"]):
|
|
395
|
+
raise ServiceError("authentication_required", "Configure " + ResearchHttp.PROVIDERS[provider]["key_env"] + " with setup or in the host environment")
|
|
396
|
+
state = {"schema_version": 1, "external_id": external_id, "research_id": research_id,
|
|
397
|
+
"operation_id": operation_id, "provider": provider, "provider_run_id": None,
|
|
398
|
+
"question_id": question_id, "digest": digest, "intent_digest": intent_digest, "status": "pending",
|
|
399
|
+
"create_response_applied": False,
|
|
400
|
+
"source_scope": request["source_scope"], "created_at": self._now(), "observations": []}
|
|
401
|
+
ResearchSessions._write(path / "request.json", request)
|
|
402
|
+
state["request_sha256"] = hashlib.sha256((path / "request.json").read_bytes()).hexdigest()
|
|
403
|
+
self._save(path, state)
|
|
404
|
+
try:
|
|
405
|
+
self._publish(path, state, strict=True)
|
|
406
|
+
except (ValueError, OSError, RuntimeError):
|
|
407
|
+
state["status"] = "error"
|
|
408
|
+
state["observation_error"] = {"error_kind": "local_record_failed", "error": "Research record rejected before the API call"}
|
|
409
|
+
self._save(path, state)
|
|
410
|
+
raise
|
|
411
|
+
result = None
|
|
412
|
+
try:
|
|
413
|
+
result = self._request(provider, "POST", request["endpoint"], request["payload"])
|
|
414
|
+
ResearchSessions._write(path / "create-receipt.json",
|
|
415
|
+
{"external_id": external_id, "digest": digest, "received_at": self._now(), "response": result})
|
|
416
|
+
self._apply_response(path, state, result, "create-receipt.json")
|
|
417
|
+
except ServiceError as error:
|
|
418
|
+
state["status"] = "unknown_outcome" if error.uncertain else "error"
|
|
419
|
+
state["observation_error"] = error.details()
|
|
420
|
+
except (OSError, ValueError, TypeError):
|
|
421
|
+
# The provider may already have accepted this request.
|
|
422
|
+
state["status"] = "unknown_outcome"
|
|
423
|
+
if result is not None:
|
|
424
|
+
self._capture_create_id(state, result)
|
|
425
|
+
state["observation_error"] = {"error_kind": "local_result_error",
|
|
426
|
+
"error": "Create result could not be saved or interpreted"}
|
|
427
|
+
self._publish(path, state)
|
|
428
|
+
self._save(path, state)
|
|
429
|
+
return {**self._summary(path, state), "replayed": False}
|
|
430
|
+
|
|
431
|
+
def _apply_response(self, path, state, response, response_file, observed_at=None):
|
|
432
|
+
data = self._run_data(state["provider"], response)
|
|
433
|
+
run_id = data.get("run_id" if state["provider"] == "parallel" else "id")
|
|
434
|
+
run_id = self._remote_id(state["provider"], run_id)
|
|
435
|
+
if state.get("provider_run_id") not in (None, run_id):
|
|
436
|
+
raise ValueError("Response belongs to a different provider run")
|
|
437
|
+
remote_status = data.get("status")
|
|
438
|
+
if not isinstance(remote_status, str) or not remote_status or len(remote_status) > 100:
|
|
439
|
+
raise ValueError("Invalid research API run status")
|
|
440
|
+
states = {"queued": "queued", "running": "in_progress", "in_progress": "in_progress",
|
|
441
|
+
"action_required": "in_progress", "cancelling": "in_progress", "completed": "completed",
|
|
442
|
+
"failed": "error", "incomplete": "error", "cancelled": "cancelled"}
|
|
443
|
+
candidate = copy.deepcopy(state)
|
|
444
|
+
candidate["provider_run_id"] = run_id
|
|
445
|
+
candidate["create_response_applied"] = True
|
|
446
|
+
candidate["status"] = states.get(remote_status, "unknown_outcome")
|
|
447
|
+
candidate["provider_status"] = remote_status
|
|
448
|
+
candidate["observed_at"] = observed_at or self._now()
|
|
449
|
+
candidate["response_file"] = response_file
|
|
450
|
+
candidate["response_sha256"] = hashlib.sha256(ResearchSessions._artifact(path, response_file).read_bytes()).hexdigest()
|
|
451
|
+
candidate.pop("observation_error", None)
|
|
452
|
+
candidate["usage"] = copy.deepcopy(response.get("usage", data.get("usage")))
|
|
453
|
+
if candidate["usage"] is not None and not isinstance(candidate["usage"], dict):
|
|
454
|
+
raise ValueError("Invalid provider usage metadata")
|
|
455
|
+
self._json(candidate["usage"])
|
|
456
|
+
report, citations = self._report(state["provider"], response)
|
|
457
|
+
if report:
|
|
458
|
+
raw = report.encode("utf-8")
|
|
459
|
+
report_file = "reports/" + hashlib.sha256(raw).hexdigest() + ".md"
|
|
460
|
+
self._write_bytes(ResearchSessions._artifact(path, report_file), raw)
|
|
461
|
+
citation_bytes = (self._json(citations) + "\n").encode("utf-8")
|
|
462
|
+
citation_file = "reports/" + hashlib.sha256(citation_bytes).hexdigest() + "-citations.json"
|
|
463
|
+
self._write_bytes(ResearchSessions._artifact(path, citation_file), citation_bytes)
|
|
464
|
+
candidate["report_path"] = str(path / report_file)
|
|
465
|
+
candidate["report_sha256"] = hashlib.sha256(raw).hexdigest()
|
|
466
|
+
candidate["citations_file"] = str(path / citation_file)
|
|
467
|
+
candidate["citations_sha256"] = hashlib.sha256(citation_bytes).hexdigest()
|
|
468
|
+
candidate["citation_count"] = len(citations)
|
|
469
|
+
candidate.pop("citations", None)
|
|
470
|
+
candidate.setdefault("observations", []).append(
|
|
471
|
+
{"observed_at": candidate["observed_at"], "provider_status": remote_status, "response_file": response_file,
|
|
472
|
+
"response_sha256": candidate["response_sha256"]})
|
|
473
|
+
# A parser/storage failure must never leave a half-applied terminal
|
|
474
|
+
# state, a replacement run ID or mismatched report metadata behind.
|
|
475
|
+
state.clear()
|
|
476
|
+
state.update(candidate)
|
|
477
|
+
|
|
478
|
+
@staticmethod
|
|
479
|
+
def _report(provider, response):
|
|
480
|
+
texts, citations = [], []
|
|
481
|
+
def rows(value, label):
|
|
482
|
+
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
|
|
483
|
+
raise ValueError("Invalid " + label + " in research API response")
|
|
484
|
+
return value
|
|
485
|
+
if provider == "openai":
|
|
486
|
+
for item in rows(response.get("output", []), "output"):
|
|
487
|
+
if item.get("type") != "message":
|
|
488
|
+
continue
|
|
489
|
+
for content in rows(item.get("content", []), "message content"):
|
|
490
|
+
if content.get("type") != "output_text":
|
|
491
|
+
continue
|
|
492
|
+
if not isinstance(content.get("text"), str):
|
|
493
|
+
raise ValueError("Invalid output text in research API response")
|
|
494
|
+
texts.append(content["text"])
|
|
495
|
+
for annotation in rows(content.get("annotations", []), "annotations"):
|
|
496
|
+
if annotation.get("type") == "url_citation":
|
|
497
|
+
citations.append(copy.deepcopy(annotation))
|
|
498
|
+
else:
|
|
499
|
+
output = response.get("output")
|
|
500
|
+
if "run" in response and not isinstance(output, dict):
|
|
501
|
+
raise ValueError("Parallel result must contain its run and output objects")
|
|
502
|
+
if output is not None:
|
|
503
|
+
if not isinstance(output, dict):
|
|
504
|
+
raise ValueError("Invalid Parallel result output")
|
|
505
|
+
content = output.get("content")
|
|
506
|
+
if isinstance(content, str):
|
|
507
|
+
texts.append(content)
|
|
508
|
+
elif isinstance(content, dict):
|
|
509
|
+
texts.append(json.dumps(content, ensure_ascii=False, allow_nan=False, indent=2))
|
|
510
|
+
else:
|
|
511
|
+
raise ValueError("Invalid Parallel result content")
|
|
512
|
+
for basis in rows(output.get("basis", []), "research basis"):
|
|
513
|
+
for citation in rows(basis.get("citations", []), "basis citations"):
|
|
514
|
+
citations.append(copy.deepcopy(citation))
|
|
515
|
+
return "\n\n".join(texts), citations
|
|
516
|
+
|
|
517
|
+
@staticmethod
|
|
518
|
+
def _read_saved(path, file, expected_hash):
|
|
519
|
+
target = Path(file)
|
|
520
|
+
if target.is_symlink() or path not in target.resolve().parents:
|
|
521
|
+
raise ValueError("Research artifact must stay within its run directory")
|
|
522
|
+
raw = target.read_bytes()
|
|
523
|
+
if hashlib.sha256(raw).hexdigest() != expected_hash:
|
|
524
|
+
raise ValueError("Saved research artifact hash does not match")
|
|
525
|
+
return raw
|
|
526
|
+
|
|
527
|
+
def _read_report(self, path, state):
|
|
528
|
+
if not state.get("report_path"):
|
|
529
|
+
return ""
|
|
530
|
+
return self._read_saved(path, state["report_path"], state["report_sha256"]).decode("utf-8")
|
|
531
|
+
|
|
532
|
+
def _citations(self, path, state):
|
|
533
|
+
if state.get("citations_file"):
|
|
534
|
+
citations = json.loads(self._read_saved(path, state["citations_file"], state["citations_sha256"]))
|
|
535
|
+
else:
|
|
536
|
+
citations = state.get("citations", [])
|
|
537
|
+
if not isinstance(citations, list):
|
|
538
|
+
raise ValueError("Invalid saved provider citations")
|
|
539
|
+
return citations
|
|
540
|
+
|
|
541
|
+
def _citation_preview(self, citations, byte_limit=100000):
|
|
542
|
+
selected, consumed = [], 0
|
|
543
|
+
for citation in citations[:100]:
|
|
544
|
+
size = len(self._json(citation).encode("utf-8"))
|
|
545
|
+
if consumed + size > byte_limit:
|
|
546
|
+
break
|
|
547
|
+
selected.append(citation)
|
|
548
|
+
consumed += size
|
|
549
|
+
return selected
|
|
550
|
+
|
|
551
|
+
def status(self, external_id, refresh=False):
|
|
552
|
+
path = self._path(external_id)
|
|
553
|
+
with Settings._update_lock(path / "state.json"):
|
|
554
|
+
_, state = self._load(external_id)
|
|
555
|
+
if refresh:
|
|
556
|
+
self._observe(path, state, "status")
|
|
557
|
+
self._publish(path, state)
|
|
558
|
+
self._save(path, state)
|
|
559
|
+
return self._summary(path, state)
|
|
560
|
+
|
|
561
|
+
def _observe(self, path, state, action):
|
|
562
|
+
run_id = state.get("provider_run_id")
|
|
563
|
+
if not run_id:
|
|
564
|
+
raise ValueError("No provider run ID is known; attach an independently verified ID instead of restarting")
|
|
565
|
+
run_id = self._remote_id(state["provider"], run_id)
|
|
566
|
+
prefix = "/v1/responses/" if state["provider"] == "openai" else "/v1/tasks/runs/"
|
|
567
|
+
endpoint, method, payload = prefix + run_id, "GET", None
|
|
568
|
+
if action == "cancel":
|
|
569
|
+
if state["provider"] != "openai":
|
|
570
|
+
raise ValueError("Parallel Task cancellation is not established by this adapter; remote state is unchanged")
|
|
571
|
+
endpoint, method, payload = endpoint + "/cancel", "POST", {}
|
|
572
|
+
elif action == "result" and state["provider"] == "parallel":
|
|
573
|
+
endpoint += "/result?timeout=1"
|
|
574
|
+
response_file = "observations/" + uuid.uuid4().hex + ".json"
|
|
575
|
+
state["pending_observation"] = {"action": action, "response_file": response_file, "started_at": self._now()}
|
|
576
|
+
self._save(path, state)
|
|
577
|
+
try:
|
|
578
|
+
result = self._request(state["provider"], method, endpoint, payload)
|
|
579
|
+
ResearchSessions._write(ResearchSessions._artifact(path, response_file), {
|
|
580
|
+
"external_id": state["external_id"], "provider_run_id": run_id, "action": action,
|
|
581
|
+
"received_at": self._now(), "response": result})
|
|
582
|
+
self._apply_response(path, state, result, response_file)
|
|
583
|
+
state.pop("pending_observation", None)
|
|
584
|
+
except ServiceError as error:
|
|
585
|
+
# This is an observation failure, not a provider terminal status.
|
|
586
|
+
state["observation_error"] = error.details()
|
|
587
|
+
state.setdefault("observations", []).append({"observed_at": self._now(), "action": action,
|
|
588
|
+
"observation_error": error.details()})
|
|
589
|
+
state.pop("pending_observation", None)
|
|
590
|
+
except (ValueError, OSError, TypeError):
|
|
591
|
+
state["observation_error"] = {"error_kind": "local_result_error",
|
|
592
|
+
"error": "Provider observation could not be saved or interpreted"}
|
|
593
|
+
|
|
594
|
+
def result(self, external_id, refresh=False, offset=0, limit=12000):
|
|
595
|
+
ResearchSessions._integer(offset, "offset", 0, 10000000)
|
|
596
|
+
ResearchSessions._integer(limit, "limit", 1, 50000)
|
|
597
|
+
path = self._path(external_id)
|
|
598
|
+
with Settings._update_lock(path / "state.json"):
|
|
599
|
+
_, state = self._load(external_id)
|
|
600
|
+
if refresh:
|
|
601
|
+
self._observe(path, state, "result")
|
|
602
|
+
self._publish(path, state)
|
|
603
|
+
self._save(path, state)
|
|
604
|
+
text = self._read_report(path, state)
|
|
605
|
+
citations = self._citations(path, state)
|
|
606
|
+
preview = self._citation_preview(citations)
|
|
607
|
+
return {**self._summary(path, state), "text": text[offset:offset + limit], "offset": offset,
|
|
608
|
+
"total_characters": len(text), "next_offset": offset + limit if offset + limit < len(text) else None,
|
|
609
|
+
"citations": preview, "citation_count": len(citations), "citations_truncated": len(preview) < len(citations),
|
|
610
|
+
"citations_verified": False}
|
|
611
|
+
|
|
612
|
+
def cancel(self, external_id):
|
|
613
|
+
path = self._path(external_id)
|
|
614
|
+
with Settings._update_lock(path / "state.json"):
|
|
615
|
+
_, state = self._load(external_id)
|
|
616
|
+
if state["status"] in self.ACTIVE:
|
|
617
|
+
self._observe(path, state, "cancel")
|
|
618
|
+
self._publish(path, state)
|
|
619
|
+
self._save(path, state)
|
|
620
|
+
return self._summary(path, state)
|
|
621
|
+
|
|
622
|
+
def attach(self, research_id, operation_id, provider, run_id, question_id=None):
|
|
623
|
+
provider = self._provider(provider)
|
|
624
|
+
operation_id = self._identifier(operation_id, "operation_id")
|
|
625
|
+
run_id = self._remote_id(provider, run_id)
|
|
626
|
+
self.sessions._path(research_id)
|
|
627
|
+
external_id = self._external_id(research_id, operation_id)
|
|
628
|
+
path = self._path(external_id)
|
|
629
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
630
|
+
with Settings._update_lock(path / "state.json"):
|
|
631
|
+
existed = (path / "state.json").exists()
|
|
632
|
+
if existed:
|
|
633
|
+
_, state = self._load(external_id)
|
|
634
|
+
if state["provider"] != provider or state.get("provider_run_id") not in (None, run_id):
|
|
635
|
+
raise ValueError("Existing operation refers to a different provider run")
|
|
636
|
+
if question_id is not None and state.get("question_id") not in (None, question_id):
|
|
637
|
+
raise ValueError("Existing operation belongs to another research question")
|
|
638
|
+
if question_id is not None and state.get("question_id") is None:
|
|
639
|
+
_, research = self.sessions._load(research_id)
|
|
640
|
+
ResearchSessions._find(research["questions"], question_id, "question")
|
|
641
|
+
state["question_id"] = question_id
|
|
642
|
+
state["provider_run_id"] = run_id
|
|
643
|
+
else:
|
|
644
|
+
_, research = self.sessions._load(research_id)
|
|
645
|
+
ResearchSessions._active(research)
|
|
646
|
+
if question_id is not None:
|
|
647
|
+
ResearchSessions._find(research["questions"], question_id, "question")
|
|
648
|
+
state = {"schema_version": 1, "external_id": external_id, "research_id": research_id,
|
|
649
|
+
"operation_id": operation_id, "provider": provider, "provider_run_id": run_id,
|
|
650
|
+
"question_id": question_id, "digest": None, "status": "unknown_outcome",
|
|
651
|
+
"created_at": self._now(), "source_scope": None, "observations": []}
|
|
652
|
+
state["attached_at"] = self._now()
|
|
653
|
+
self._publish(path, state, strict=not existed)
|
|
654
|
+
self._save(path, state)
|
|
655
|
+
return self._summary(path, state)
|
|
656
|
+
|
|
657
|
+
def import_result(self, external_id, operation_id, question_id=None):
|
|
658
|
+
operation_id = self._identifier(operation_id, "operation_id")
|
|
659
|
+
path = self._path(external_id)
|
|
660
|
+
with Settings._update_lock(path / "state.json"):
|
|
661
|
+
_, state = self._load(external_id)
|
|
662
|
+
if state["status"] != "completed" or not state.get("report_path"):
|
|
663
|
+
raise ValueError("A completed, saved provider report is required for import")
|
|
664
|
+
question_id = question_id if question_id is not None else state.get("question_id")
|
|
665
|
+
if question_id is None:
|
|
666
|
+
raise ValueError("question_id is required to import a report")
|
|
667
|
+
text = self._read_report(path, state)
|
|
668
|
+
citations = self._citations(path, state)
|
|
669
|
+
endpoint = "/v1/responses/" if state["provider"] == "openai" else "/v1/tasks/runs/"
|
|
670
|
+
url = ResearchHttp.PROVIDERS[state["provider"]]["origin"] + endpoint + self._remote_id(state["provider"], state["provider_run_id"])
|
|
671
|
+
provenance = {"kind": "external_report", "provider": state["provider"], "run_id": state["provider_run_id"],
|
|
672
|
+
"external_id": external_id, "artifact_hash": state["report_sha256"], "text_sha256": state["report_sha256"],
|
|
673
|
+
"citations_verified": False, "provider_citations": self._citation_preview(citations, byte_limit=16000),
|
|
674
|
+
"provider_citation_count": len(citations)}
|
|
675
|
+
if state.get("citations_file"):
|
|
676
|
+
provenance.update(provider_citations_file=state["citations_file"], provider_citations_sha256=state["citations_sha256"])
|
|
677
|
+
self._publish(path, state)
|
|
678
|
+
evidence = self.sessions.import_evidence(state["research_id"], operation_id, question_id, url, text, provenance,
|
|
679
|
+
inventory_ids=(state.get("source_scope") or {}).get("inventory_ids", []),
|
|
680
|
+
title=state["provider"] + " research report")
|
|
681
|
+
state["imported_evidence"] = evidence
|
|
682
|
+
self._save(path, state)
|
|
683
|
+
return {"external_id": external_id, "research_id": state["research_id"], "evidence": evidence,
|
|
684
|
+
"coverage_note": "Imported as an external report. Referenced original pages remain unreviewed until read and assessed independently."}
|