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/routing.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Choose research entrypoints from observed host capabilities; never launch work."""
|
|
2
|
+
|
|
3
|
+
from service_http import ResearchHttp
|
|
4
|
+
from settings import Settings
|
|
5
|
+
from web_search import SearchProviders
|
|
6
|
+
from credentials import Credentials
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResearchRouting:
|
|
10
|
+
HOSTS = ("codex", "claude_code", "pi", "dsh", "unknown")
|
|
11
|
+
METHODS = {
|
|
12
|
+
"comparative_analysis": "skills/bookmark-research/references/research-methods.md",
|
|
13
|
+
"fact_check": "skills/bookmark-research/references/research-methods.md",
|
|
14
|
+
"benchmark_review": "skills/bookmark-research/references/research-methods.md",
|
|
15
|
+
"wiki_synthesis": "skills/bookmark-research/references/wiki-and-evaluation.md",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def __init__(self, settings=None):
|
|
19
|
+
self.settings = settings if settings is not None else Settings()
|
|
20
|
+
self.credentials = Credentials(self.settings)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _names(values, label):
|
|
24
|
+
if values is None:
|
|
25
|
+
return []
|
|
26
|
+
if (not isinstance(values, list) or len(values) > 1000
|
|
27
|
+
or any(not isinstance(x, str) or not x.strip() or len(x) > 300 for x in values)):
|
|
28
|
+
raise ValueError(label + " must be a list of names observed in the current host")
|
|
29
|
+
return list(dict.fromkeys(values))
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _has(tools, *names):
|
|
33
|
+
return any(tool == name or tool.endswith(("__" + name, "." + name, ":" + name))
|
|
34
|
+
for tool in tools for name in names)
|
|
35
|
+
|
|
36
|
+
def route(self, depth=None, host="unknown", task_shape="investigation", observed_tools=None,
|
|
37
|
+
available_commands=None, installed_extensions=None, provider=None, failed_routes=None):
|
|
38
|
+
settings = self.settings.load()
|
|
39
|
+
depth = settings["research"]["depth"] if depth is None else depth
|
|
40
|
+
if depth not in ("auto", "quick", "agentic", "deep") or host not in self.HOSTS:
|
|
41
|
+
raise ValueError("Unsupported research depth or host")
|
|
42
|
+
if task_shape not in ("lookup", "investigation", "batch_research"):
|
|
43
|
+
raise ValueError("task_shape must be lookup, investigation or batch_research")
|
|
44
|
+
if provider not in (None, "openai", "parallel"):
|
|
45
|
+
raise ValueError("Professional provider must be openai or parallel")
|
|
46
|
+
tools = self._names(observed_tools, "observed_tools")
|
|
47
|
+
commands = self._names(available_commands, "available_commands")
|
|
48
|
+
extensions = self._names(installed_extensions, "installed_extensions")
|
|
49
|
+
failed = self._names(failed_routes, "failed_routes")
|
|
50
|
+
retrieval = SearchProviders(settings=self.settings)
|
|
51
|
+
selected_depth = ({"lookup": "quick", "investigation": "agentic", "batch_research": "deep"}[task_shape]
|
|
52
|
+
if depth == "auto" else depth)
|
|
53
|
+
if provider is not None:
|
|
54
|
+
selected_depth = "deep"
|
|
55
|
+
ordinary_agentic = selected_depth == "agentic" and task_shape != "batch_research"
|
|
56
|
+
candidates = []
|
|
57
|
+
missing = []
|
|
58
|
+
if selected_depth == "quick":
|
|
59
|
+
candidates.append({"route": "direct_retrieval", "entrypoint": "fetch_web / search_web",
|
|
60
|
+
"available": True, "execution_owner": "host",
|
|
61
|
+
"reason": "Read known URLs directly; use search when sources must be discovered."})
|
|
62
|
+
else:
|
|
63
|
+
if host == "claude_code":
|
|
64
|
+
packaged = "/bookmark-research:bookmark-research" in commands
|
|
65
|
+
if packaged or self._has(tools, "Workflow", "workflow"):
|
|
66
|
+
candidates.append({"route": "host_workflow",
|
|
67
|
+
"entrypoint": "/bookmark-research:bookmark-research" if packaged else "Workflow",
|
|
68
|
+
"available": True, "execution_owner": "claude_code", "run_mode": "background",
|
|
69
|
+
"resume_scope": "same_session_with_possible_child_replay",
|
|
70
|
+
"recipe": "hosts/claude/runtime.js"})
|
|
71
|
+
else:
|
|
72
|
+
missing.append("Claude workflow tool or packaged workflow command is not observed")
|
|
73
|
+
if "/deep-research" in commands and selected_depth == "deep" and task_shape != "batch_research":
|
|
74
|
+
candidates.append({"route": "host_deep_research", "entrypoint": "/deep-research",
|
|
75
|
+
"available": True, "execution_owner": "claude_code", "run_mode": "background",
|
|
76
|
+
"invocation": "explicit_command_required",
|
|
77
|
+
"scope_note": "Builtin question research does not guarantee enumeration of a local package."})
|
|
78
|
+
elif host == "dsh":
|
|
79
|
+
if self._has(tools, "workflow"):
|
|
80
|
+
candidates.append({"route": "host_workflow", "entrypoint": "workflow",
|
|
81
|
+
"available": True, "execution_owner": "dsh", "run_mode": "blocking",
|
|
82
|
+
"resume_scope": "not_established", "recipe": "hosts/dsh/workflow-call.py",
|
|
83
|
+
"result_note": "Persist the canonical result; rendered text may be truncated."})
|
|
84
|
+
else:
|
|
85
|
+
missing.append("The loaded DSH profile does not expose a workflow tool")
|
|
86
|
+
elif host == "pi":
|
|
87
|
+
if self._has(tools, "pi_subagent_workflow"):
|
|
88
|
+
candidates.append({"route": "host_workflow", "entrypoint": "pi_subagent_workflow",
|
|
89
|
+
"available": True, "execution_owner": "pi_subagents_extension", "run_mode": "detached",
|
|
90
|
+
"resume_scope": "registry_does_not_add_replay", "recipe": "hosts/pi/register-workflow.py"})
|
|
91
|
+
else:
|
|
92
|
+
missing.append("Pi workflow extension tool is not observed; installed package names alone are insufficient")
|
|
93
|
+
elif host == "codex":
|
|
94
|
+
missing.append("An equivalent standalone scripted workflow runtime was not established by the researched Codex docs")
|
|
95
|
+
if selected_depth == "deep" and provider is None:
|
|
96
|
+
for name, info in retrieval.registry["providers"].items():
|
|
97
|
+
native_research = info.get("native_research", {})
|
|
98
|
+
required = native_research.get("required_tools", [])
|
|
99
|
+
if required and all(self._has(tools, tool) for tool in required):
|
|
100
|
+
workflow_tools = [next(tool for tool in tools if self._has([tool], required_tool))
|
|
101
|
+
for required_tool in native_research["workflow_tools"]]
|
|
102
|
+
candidates.append({"route": "host_research_mcp", "provider": name,
|
|
103
|
+
"entrypoint": workflow_tools[0], "workflow_tools": workflow_tools,
|
|
104
|
+
"available": True, "execution_owner": name, "invocation_owner": "host", "run_mode": "provider_background",
|
|
105
|
+
"availability_basis": "observed_tools", "authentication_verified": False,
|
|
106
|
+
"resume_note": "Keep the returned run ID. Resume or observe that run until terminal; a timeout is not a new submission."})
|
|
107
|
+
native = {"codex": ("spawn_agent",), "claude_code": ("Agent", "Task", "subagent"),
|
|
108
|
+
"pi": ("subagent",), "dsh": ("subagent", "agent"), "unknown": ()}[host]
|
|
109
|
+
native_tools = [tool for tool in tools if self._has([tool], *native)]
|
|
110
|
+
if native_tools:
|
|
111
|
+
candidates.append({"route": "host_subagents", "entrypoint": native_tools[0],
|
|
112
|
+
"available": True, "execution_owner": host, "run_mode": "host_defined",
|
|
113
|
+
"recipe": "hosts/codex/delegate.md" if host == "codex" else "skills/bookmark-research/references/host-workflows.md"})
|
|
114
|
+
services = settings["professional_research"]
|
|
115
|
+
if selected_depth == "deep" or provider is not None:
|
|
116
|
+
for name in ([provider] if provider else ["openai", "parallel"]):
|
|
117
|
+
key = ResearchHttp.PROVIDERS[name]["key_env"]
|
|
118
|
+
credential = bool(self.credentials.get(key))
|
|
119
|
+
candidates.append({"route": "professional_service", "provider": name,
|
|
120
|
+
"entrypoint": "research_service_start", "execution_owner": name, "run_mode": "provider_background",
|
|
121
|
+
"available": services["enabled"] and credential, "authentication_verified": False,
|
|
122
|
+
"missing": ([] if services["enabled"] else ["professional_research.enabled"])
|
|
123
|
+
+ ([] if credential else [key])})
|
|
124
|
+
candidates.append({"route": "host_iterative_research",
|
|
125
|
+
"entrypoint": "search_web / fetch_web" if ordinary_agentic else "research_start / research_*",
|
|
126
|
+
"available": True, "execution_owner": "host", "run_mode": "host_defined",
|
|
127
|
+
"reason": "The current host model plans the inquiry, reads evidence, evaluates it and follows gaps; retrieval services support this loop."})
|
|
128
|
+
for candidate in candidates:
|
|
129
|
+
candidate["route_id"] = candidate["route"] + (":" + candidate["provider"] if "provider" in candidate else "")
|
|
130
|
+
if candidate["route_id"] in failed:
|
|
131
|
+
candidate["available"] = False
|
|
132
|
+
candidate["excluded_reason"] = "caller_reported_confirmed_failure"
|
|
133
|
+
if provider is not None:
|
|
134
|
+
choices = [c for c in candidates if c["route"] == "professional_service" and c.get("provider") == provider]
|
|
135
|
+
selected = next((c for c in choices if c["available"]), None)
|
|
136
|
+
else:
|
|
137
|
+
choices = candidates[:]
|
|
138
|
+
host_priority = (["host_workflow", "host_subagents", "host_iterative_research"]
|
|
139
|
+
if task_shape == "batch_research" and settings["research"]["prefer_host_workflows"] else
|
|
140
|
+
["host_subagents", "host_iterative_research", "host_workflow"])
|
|
141
|
+
if ordinary_agentic:
|
|
142
|
+
host_priority.remove("host_iterative_research")
|
|
143
|
+
host_priority.insert(0, "host_iterative_research")
|
|
144
|
+
configured = settings["professional_research"]["provider"]
|
|
145
|
+
# Host reasoning is the baseline. A saved API preference orders
|
|
146
|
+
# optional services; only an explicit provider overrides host work.
|
|
147
|
+
choices.sort(key=lambda c: host_priority.index(c["route"]) if c["route"] in host_priority else
|
|
148
|
+
len(host_priority) if c["route"] == "professional_service" and c.get("provider") == configured else
|
|
149
|
+
len(host_priority) + 1)
|
|
150
|
+
selected = next((c for c in choices if c["available"]), None)
|
|
151
|
+
if selected is None:
|
|
152
|
+
next_action = ({"tool": "research_services", "arguments": {"provider": provider},
|
|
153
|
+
"reason": "Inspect the explicitly selected service's configuration or confirmed failure; it was not substituted."}
|
|
154
|
+
if provider is not None else {"tool": "research_status",
|
|
155
|
+
"reason": "No untried applicable route remains. Inspect the existing evidence and report the unresolved gaps."})
|
|
156
|
+
workflow = []
|
|
157
|
+
elif selected_depth == "quick":
|
|
158
|
+
next_action = {"tools": ["fetch_web", "search_web"],
|
|
159
|
+
"reason": "Read known URLs; search only when a source must be discovered, then read it."}
|
|
160
|
+
workflow = ["fetch_web", "answer_with_sources"]
|
|
161
|
+
elif ordinary_agentic and selected["route"] == "host_iterative_research":
|
|
162
|
+
next_action = {"tools": ["search_web", "fetch_web"],
|
|
163
|
+
"reason": "The current host model first plans from the question and available context. Read known sources or search for missing evidence, assess the pages, then target unresolved questions. Use persistent research records when the task needs a resumable report."}
|
|
164
|
+
workflow = ["search_web", "fetch_web", "answer_with_sources"]
|
|
165
|
+
else:
|
|
166
|
+
next_action = {"tool": "research_start",
|
|
167
|
+
"reason": "The current host model first defines the questions, evidence gaps and work assignments. Freeze the full scope for new research; reuse existing research_status and evidence before delegating or retrieving more."}
|
|
168
|
+
workflow = ["research_start", "research_inventory"]
|
|
169
|
+
if selected["route"] == "professional_service":
|
|
170
|
+
workflow += ["research_service_prepare", "research_service_start", "research_service_status",
|
|
171
|
+
"research_service_result", "research_service_import", "research_fetch"]
|
|
172
|
+
elif selected["route"] == "host_research_mcp":
|
|
173
|
+
workflow += selected["workflow_tools"] + ["research_record", "research_import_evidence", "research_fetch"]
|
|
174
|
+
else:
|
|
175
|
+
workflow += [selected["entrypoint"]] if selected["route"] != "host_iterative_research" else []
|
|
176
|
+
workflow += ["research_search", "research_fetch"]
|
|
177
|
+
workflow += ["research_source", "research_record", "research_coverage", "research_finish"]
|
|
178
|
+
process = {"owner": "current_host_model",
|
|
179
|
+
"steps": (["read_or_search", "answer_with_sources"] if selected_depth == "quick" else
|
|
180
|
+
["plan", "search_or_read", "assess_evidence", "follow_evidence_gaps"] +
|
|
181
|
+
(["independent_review", "coverage_check", "synthesize_report"] if selected_depth == "deep" else
|
|
182
|
+
["synthesize_answer"])),
|
|
183
|
+
"collaboration": "Delegate independent questions, sources or verification only through currently available and permitted host capabilities. The parent plans and reviews the result; no subagent is required for the basic loop.",
|
|
184
|
+
"external_research": "Optional scoped support after host assessment, or an explicit user-selected service. Tool presence and saved API credentials do not replace the host's first pass."}
|
|
185
|
+
return {"depth": selected_depth, "host": host, "task_shape": task_shape, "selected": selected,
|
|
186
|
+
"candidates": candidates, "missing_or_unconfirmed": missing,
|
|
187
|
+
"next_action": next_action, "workflow": workflow, "research_process": process,
|
|
188
|
+
"fallback": {"execution_owner": "host", "failed_routes": failed,
|
|
189
|
+
"remaining_routes": [c["route_id"] for c in choices if c["available"] and c is not selected],
|
|
190
|
+
"on_confirmed_failure": "Call research_route again with cumulative failed_routes and current host observations; execute the next route in the same research session.",
|
|
191
|
+
"hold_on": ["pending", "queued", "in_progress", "unknown_outcome", "cancelled"],
|
|
192
|
+
"explicit_provider_is_exclusive": provider is not None},
|
|
193
|
+
"retrieval": {"search": {"providers": settings["search"]["providers"],
|
|
194
|
+
"fallback_providers": settings["search"]["fallback_providers"],
|
|
195
|
+
"mode": "parallel_then_fallback" if settings["search"]["fallback_providers"] else "parallel"},
|
|
196
|
+
"fetch": {"providers": Settings.fetch_providers(settings), "mode": "primary_then_parallel_fallback"}},
|
|
197
|
+
"observations": {"tools": tools, "commands": commands, "extensions": extensions,
|
|
198
|
+
"basis": "caller_reported_current_host", "runtime_tested": False},
|
|
199
|
+
"methods": [{"name": name, "reference": self.METHODS[name]} for name in settings["research"]["methods"]],
|
|
200
|
+
"source_scope": "Keep the frozen research inventory complete across groups and rounds; finish checks coverage.",
|
|
201
|
+
"execution_started": False, "configuration_changed": False,
|
|
202
|
+
"provider_fallback": "An explicitly selected unavailable service is reported unavailable; never silently substituted."}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Fuse normalized search batches without fetching pages or verifying claims.
|
|
2
|
+
|
|
3
|
+
RRF contributions are unique per (target, provider, query, canonical URL).
|
|
4
|
+
An optional ``targets`` list identifies requested targets with no batch at all.
|
|
5
|
+
Only snippets are retained; full source text belongs in a later fetch step.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
10
|
+
import ipaddress
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
RRF_K = 60
|
|
15
|
+
MAX_SNIPPET_CHARS = 1200
|
|
16
|
+
MAX_RANK = 2147483647
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _nonempty_string(value):
|
|
20
|
+
return isinstance(value, str) and bool(value.strip())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _canonical_url(raw_url):
|
|
24
|
+
"""Conservative URL identity, retaining queries and client-side routes."""
|
|
25
|
+
if not _nonempty_string(raw_url):
|
|
26
|
+
raise ValueError("url must be a nonempty string")
|
|
27
|
+
if any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in raw_url):
|
|
28
|
+
raise ValueError("url contains whitespace or control characters")
|
|
29
|
+
if "\\" in raw_url:
|
|
30
|
+
raise ValueError("url contains a backslash")
|
|
31
|
+
try:
|
|
32
|
+
parts = urlsplit(raw_url)
|
|
33
|
+
if parts.scheme.lower() not in ("http", "https"):
|
|
34
|
+
raise ValueError("only http and https URLs are accepted")
|
|
35
|
+
host = parts.hostname
|
|
36
|
+
port = parts.port
|
|
37
|
+
if not host or parts.username is not None or parts.password is not None:
|
|
38
|
+
raise ValueError("url must have a host and no userinfo")
|
|
39
|
+
if parts.netloc.startswith("[") or ":" in host:
|
|
40
|
+
ipaddress.IPv6Address(host)
|
|
41
|
+
host = "[" + host.lower() + "]"
|
|
42
|
+
else:
|
|
43
|
+
ascii_host = host.encode("idna").decode("ascii")
|
|
44
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*\.?", ascii_host):
|
|
45
|
+
raise ValueError("invalid URL host")
|
|
46
|
+
host = host.lower()
|
|
47
|
+
except (UnicodeError, ValueError) as exc:
|
|
48
|
+
raise ValueError(str(exc)) from exc
|
|
49
|
+
scheme = parts.scheme.lower()
|
|
50
|
+
if port is not None and (scheme, port) not in (("http", 80), ("https", 443)):
|
|
51
|
+
host += ":" + str(port)
|
|
52
|
+
# Ordinary anchors share a page. Hash-router URLs may represent other pages.
|
|
53
|
+
fragment = parts.fragment if parts.fragment.startswith(("/", "!")) else ""
|
|
54
|
+
# HTTP(S) requests use '/' for an empty path (RFC 3986 section 6.2.3).
|
|
55
|
+
canonical = urlunsplit((scheme, host, parts.path or "/", parts.query, fragment))
|
|
56
|
+
if not parts.query and "?" in raw_url.split("#", 1)[0]:
|
|
57
|
+
before_fragment, marker, after_fragment = canonical.partition("#")
|
|
58
|
+
canonical = before_fragment + "?" + (marker + after_fragment if marker else "")
|
|
59
|
+
return canonical
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _bucket():
|
|
63
|
+
return {"submitted": 0, "streams": {}, "error_count": 0, "invalid_count": 0}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _mode(providers, query_count):
|
|
67
|
+
if not providers:
|
|
68
|
+
return "no_provider"
|
|
69
|
+
if len(providers) > 1:
|
|
70
|
+
return "multi_provider"
|
|
71
|
+
return "single_provider_multi_query" if query_count > 1 else "single_provider_single_query"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fuse_results(payload):
|
|
75
|
+
"""Return independently ranked targets, provenance, failures and coverage.
|
|
76
|
+
|
|
77
|
+
Malformed top-level configuration raises ValueError. Invalid batches/results
|
|
78
|
+
are reported and do not discard valid siblings. Provider counts include
|
|
79
|
+
failed providers; successful-provider coverage is reported separately.
|
|
80
|
+
"""
|
|
81
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("batches"), list):
|
|
82
|
+
raise ValueError("payload must be an object with a batches array")
|
|
83
|
+
limit = payload.get("limit_per_target", 10)
|
|
84
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
|
|
85
|
+
raise ValueError("limit_per_target must be a positive integer")
|
|
86
|
+
requested = payload.get("targets", [])
|
|
87
|
+
if not isinstance(requested, list) or any(not _nonempty_string(t) for t in requested):
|
|
88
|
+
raise ValueError("targets must be an array of nonempty strings")
|
|
89
|
+
buckets = OrderedDict((target.strip(), _bucket()) for target in requested)
|
|
90
|
+
errors, invalid_results = [], []
|
|
91
|
+
|
|
92
|
+
def error(batch_index, message, target=None, provider=None, query=None, kind="invalid_batch"):
|
|
93
|
+
errors.append({"batch_index": batch_index, "target": target,
|
|
94
|
+
"provider": provider, "query": query, "kind": kind, "error": message})
|
|
95
|
+
if target in buckets:
|
|
96
|
+
buckets[target]["error_count"] += 1
|
|
97
|
+
|
|
98
|
+
for batch_index, batch in enumerate(payload["batches"]):
|
|
99
|
+
if not isinstance(batch, dict):
|
|
100
|
+
error(batch_index, "batch must be an object")
|
|
101
|
+
continue
|
|
102
|
+
target = batch.get("target")
|
|
103
|
+
if not _nonempty_string(target):
|
|
104
|
+
error(batch_index, "target must be a nonempty string")
|
|
105
|
+
continue
|
|
106
|
+
target = target.strip()
|
|
107
|
+
bucket = buckets.setdefault(target, _bucket())
|
|
108
|
+
bucket["submitted"] += 1
|
|
109
|
+
provider, query = batch.get("provider"), batch.get("query")
|
|
110
|
+
if not _nonempty_string(provider) or not _nonempty_string(query):
|
|
111
|
+
error(batch_index, "provider and query must be nonempty strings", target)
|
|
112
|
+
continue
|
|
113
|
+
provider, query = provider.strip().lower(), query.strip()
|
|
114
|
+
stream = bucket["streams"].setdefault((provider, query),
|
|
115
|
+
{"ok": False, "failed": False, "urls": {}})
|
|
116
|
+
status = batch.get("status")
|
|
117
|
+
if status not in ("ok", "error"):
|
|
118
|
+
stream["failed"] = True
|
|
119
|
+
error(batch_index, "status must be ok or error", target, provider, query)
|
|
120
|
+
continue
|
|
121
|
+
if status == "error":
|
|
122
|
+
stream["failed"] = True
|
|
123
|
+
error(batch_index, batch.get("error") or "provider returned an error",
|
|
124
|
+
target, provider, query, "provider_error")
|
|
125
|
+
continue
|
|
126
|
+
results = batch.get("results")
|
|
127
|
+
if not isinstance(results, list):
|
|
128
|
+
stream["failed"] = True
|
|
129
|
+
error(batch_index, "an ok batch must have a results array", target, provider, query)
|
|
130
|
+
continue
|
|
131
|
+
stream["ok"] = True
|
|
132
|
+
for position, item in enumerate(results, 1):
|
|
133
|
+
try:
|
|
134
|
+
if not isinstance(item, dict):
|
|
135
|
+
raise ValueError("result must be an object")
|
|
136
|
+
raw_url = item.get("url")
|
|
137
|
+
canonical = _canonical_url(raw_url)
|
|
138
|
+
rank = item.get("rank", position)
|
|
139
|
+
if isinstance(rank, bool) or not isinstance(rank, int) or not 1 <= rank <= MAX_RANK:
|
|
140
|
+
raise ValueError("rank must be an integer between 1 and " + str(MAX_RANK))
|
|
141
|
+
title = item.get("title", "")
|
|
142
|
+
if not isinstance(title, str):
|
|
143
|
+
raise ValueError("title must be a string")
|
|
144
|
+
snippet = item.get("snippet")
|
|
145
|
+
if snippet is None:
|
|
146
|
+
snippet = item.get("text", "")
|
|
147
|
+
if not isinstance(snippet, str):
|
|
148
|
+
raise ValueError("text or snippet must be a string")
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
invalid_results.append({"batch_index": batch_index, "result_position": position,
|
|
151
|
+
"target": target, "provider": provider, "query": query,
|
|
152
|
+
"raw_url": item.get("url") if isinstance(item, dict) else None,
|
|
153
|
+
"reason": str(exc)})
|
|
154
|
+
bucket["invalid_count"] += 1
|
|
155
|
+
continue
|
|
156
|
+
snippet, truncated = snippet[:MAX_SNIPPET_CHARS], len(snippet) > MAX_SNIPPET_CHARS
|
|
157
|
+
existing = stream["urls"].get(canonical)
|
|
158
|
+
if existing is None:
|
|
159
|
+
stream["urls"][canonical] = {
|
|
160
|
+
"rank": rank, "raw_urls": {raw_url}, "title": title,
|
|
161
|
+
"snippet": snippet, "snippet_truncated": truncated,
|
|
162
|
+
}
|
|
163
|
+
else:
|
|
164
|
+
existing["rank"] = min(existing["rank"], rank)
|
|
165
|
+
existing["raw_urls"].add(raw_url)
|
|
166
|
+
if title:
|
|
167
|
+
existing["title"] = min(t for t in (existing["title"], title) if t)
|
|
168
|
+
old = (len(existing["snippet"]), existing["snippet"], existing["snippet_truncated"])
|
|
169
|
+
new = (len(snippet), snippet, truncated)
|
|
170
|
+
if new > old:
|
|
171
|
+
existing["snippet"], existing["snippet_truncated"] = snippet, truncated
|
|
172
|
+
|
|
173
|
+
targets, all_providers, all_successful_providers = [], set(), set()
|
|
174
|
+
query_count = 0
|
|
175
|
+
for target, bucket in buckets.items():
|
|
176
|
+
streams = bucket["streams"]
|
|
177
|
+
by_url, by_provider = {}, {}
|
|
178
|
+
for (provider, query), stream in sorted(streams.items()):
|
|
179
|
+
coverage = by_provider.setdefault(provider, {"query_count": 0, "successful_queries": 0,
|
|
180
|
+
"failed_queries": 0, "unique_urls": set()})
|
|
181
|
+
coverage["query_count"] += 1
|
|
182
|
+
coverage["successful_queries"] += int(stream["ok"])
|
|
183
|
+
coverage["failed_queries"] += int(stream["failed"])
|
|
184
|
+
coverage["unique_urls"].update(stream["urls"])
|
|
185
|
+
for canonical, hit in stream["urls"].items():
|
|
186
|
+
raw_urls = sorted(hit["raw_urls"])
|
|
187
|
+
by_url.setdefault(canonical, []).append({
|
|
188
|
+
"provider": provider, "query": query, "rank": hit["rank"],
|
|
189
|
+
"url": raw_urls[0], "raw_urls": raw_urls, "title": hit["title"],
|
|
190
|
+
"snippet": hit["snippet"], "snippet_truncated": hit["snippet_truncated"],
|
|
191
|
+
})
|
|
192
|
+
ranked = []
|
|
193
|
+
for canonical, sources in by_url.items():
|
|
194
|
+
sources.sort(key=lambda source: (source["rank"], source["provider"], source["query"]))
|
|
195
|
+
ranked.append({
|
|
196
|
+
"url": sources[0]["url"], "canonical_url": canonical,
|
|
197
|
+
"raw_urls": sorted({url for source in sources for url in source["raw_urls"]}),
|
|
198
|
+
"title": next((source["title"] for source in sources if source["title"]), ""),
|
|
199
|
+
"rrf_score": sum(1.0 / (RRF_K + source["rank"]) for source in sources),
|
|
200
|
+
"provider_count": len({source["provider"] for source in sources}),
|
|
201
|
+
"evidence_count": len(sources), "sources": sources,
|
|
202
|
+
})
|
|
203
|
+
ranked.sort(key=lambda hit: (-hit["rrf_score"], hit["canonical_url"]))
|
|
204
|
+
successful_queries = sum(int(stream["ok"]) for stream in streams.values())
|
|
205
|
+
failed_queries = sum(int(stream["failed"]) for stream in streams.values())
|
|
206
|
+
has_issues = bool(bucket["error_count"] or bucket["invalid_count"])
|
|
207
|
+
if not bucket["submitted"]:
|
|
208
|
+
status = "missing"
|
|
209
|
+
elif not successful_queries:
|
|
210
|
+
status = "error"
|
|
211
|
+
elif has_issues:
|
|
212
|
+
status = "partial"
|
|
213
|
+
else:
|
|
214
|
+
status = "ok" if ranked else "empty"
|
|
215
|
+
successful_providers = sorted(p for p, coverage in by_provider.items() if coverage["successful_queries"])
|
|
216
|
+
for coverage in by_provider.values():
|
|
217
|
+
coverage["unique_urls"] = len(coverage["unique_urls"])
|
|
218
|
+
targets.append({
|
|
219
|
+
"target": target, "status": status, "providers": sorted(by_provider),
|
|
220
|
+
"provider_count": len(by_provider), "query_count": len(streams),
|
|
221
|
+
"mode": _mode(by_provider, len(streams)),
|
|
222
|
+
"coverage": {"submitted_batches": bucket["submitted"], "unique_queries": len(streams),
|
|
223
|
+
"successful_queries": successful_queries, "failed_queries": failed_queries,
|
|
224
|
+
"successful_providers": successful_providers,
|
|
225
|
+
"invalid_results": bucket["invalid_count"], "unique_urls": len(ranked),
|
|
226
|
+
"returned_urls": min(limit, len(ranked)), "truncated": len(ranked) > limit,
|
|
227
|
+
"by_provider": by_provider},
|
|
228
|
+
"results": ranked[:limit],
|
|
229
|
+
})
|
|
230
|
+
all_providers.update(by_provider)
|
|
231
|
+
all_successful_providers.update(successful_providers)
|
|
232
|
+
query_count += len(streams)
|
|
233
|
+
return {
|
|
234
|
+
"provider_count": len(all_providers), "providers": sorted(all_providers),
|
|
235
|
+
"successful_provider_count": len(all_successful_providers),
|
|
236
|
+
"target_count": len(targets), "query_count": query_count,
|
|
237
|
+
"mode": _mode(all_providers, query_count), "limit_per_target": limit,
|
|
238
|
+
"ranking": {"method": "rrf", "k": RRF_K,
|
|
239
|
+
"evidence_unit": "unique provider/query retrieval for this target and URL",
|
|
240
|
+
"independent_fact_confirmation": False},
|
|
241
|
+
"missing_targets": [item["target"] for item in targets if item["status"] == "missing"],
|
|
242
|
+
"uncovered_targets": [item["target"] for item in targets if not item["results"]],
|
|
243
|
+
"targets": targets, "errors": errors, "invalid_results": invalid_results,
|
|
244
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Single-request HTTP transport for documented research APIs, without retries."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import http.client
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
from credentials import Credentials
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ServiceError(RuntimeError):
|
|
15
|
+
def __init__(self, kind, message, uncertain=False, http_status=None, retry_after=None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.kind = kind
|
|
18
|
+
self.uncertain = uncertain
|
|
19
|
+
self.http_status = http_status
|
|
20
|
+
self.retry_after = retry_after
|
|
21
|
+
|
|
22
|
+
def details(self):
|
|
23
|
+
return {"error_kind": self.kind, "error": str(self),
|
|
24
|
+
"request_outcome_unknown": self.uncertain,
|
|
25
|
+
"http_status": self.http_status, "retry_after_seconds": self.retry_after}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
29
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
30
|
+
# Provider credentials must never follow an unexpected redirect.
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResearchHttp:
|
|
35
|
+
PROVIDERS = {
|
|
36
|
+
"openai": {"origin": "https://api.openai.com", "key_env": "OPENAI_API_KEY"},
|
|
37
|
+
"parallel": {"origin": "https://api.parallel.ai", "key_env": "PARALLEL_API_KEY"},
|
|
38
|
+
}
|
|
39
|
+
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
def __init__(self, timeout=30, credentials=None):
|
|
42
|
+
if type(timeout) not in (int, float) or not math.isfinite(timeout) or not 0 < timeout <= 60:
|
|
43
|
+
raise ValueError("Research API timeout must be greater than 0 and at most 60 seconds")
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.credentials = credentials if credentials is not None else Credentials()
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _unique_object(pairs):
|
|
49
|
+
value = {}
|
|
50
|
+
for key, item in pairs:
|
|
51
|
+
if key in value:
|
|
52
|
+
raise ValueError("Duplicate JSON field")
|
|
53
|
+
value[key] = item
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _invalid_constant(value):
|
|
58
|
+
raise ValueError("Non-finite JSON number")
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _finite_float(value):
|
|
62
|
+
number = float(value)
|
|
63
|
+
if not math.isfinite(number):
|
|
64
|
+
raise ValueError("Non-finite JSON number")
|
|
65
|
+
return number
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def _endpoint(cls, provider, method, path, payload):
|
|
69
|
+
if not isinstance(provider, str) or provider not in cls.PROVIDERS:
|
|
70
|
+
raise ValueError("Unsupported research provider")
|
|
71
|
+
if not isinstance(path, str) or not isinstance(method, str):
|
|
72
|
+
raise ValueError("Unsupported research API request")
|
|
73
|
+
parsed = urlsplit(path)
|
|
74
|
+
if parsed.scheme or parsed.netloc or parsed.fragment or any(ord(char) < 32 for char in path):
|
|
75
|
+
raise ValueError("Unsupported research API request")
|
|
76
|
+
suffix = r"[A-Za-z0-9_-]{1,192}"
|
|
77
|
+
if provider == "openai":
|
|
78
|
+
create = path == "/v1/responses" and method == "POST"
|
|
79
|
+
read = method == "GET" and (re.fullmatch(r"/v1/responses/resp_" + suffix, path)
|
|
80
|
+
or path in ("/v1/models/o3-deep-research", "/v1/models/o4-mini-deep-research"))
|
|
81
|
+
cancel = method == "POST" and re.fullmatch(r"/v1/responses/resp_" + suffix + r"/cancel", path)
|
|
82
|
+
else:
|
|
83
|
+
create = path == "/v1/tasks/runs" and method == "POST"
|
|
84
|
+
read = method == "GET" and re.fullmatch(r"/v1/tasks/runs/trun_" + suffix + r"(?:/result\?timeout=1)?", path)
|
|
85
|
+
cancel = False
|
|
86
|
+
if not (create or read or cancel):
|
|
87
|
+
raise ValueError("Unsupported research API request")
|
|
88
|
+
if (create and not isinstance(payload, dict)) or (read and payload is not None) or (cancel and payload not in (None, {})):
|
|
89
|
+
raise ValueError("Invalid payload for this research API operation")
|
|
90
|
+
|
|
91
|
+
def request(self, provider, method, path, payload=None):
|
|
92
|
+
self._endpoint(provider, method, path, payload)
|
|
93
|
+
info = self.PROVIDERS[provider]
|
|
94
|
+
key = self.credentials.get(info["key_env"])
|
|
95
|
+
if not key or any(not 33 <= ord(char) <= 126 for char in key):
|
|
96
|
+
raise ServiceError("authentication_required", "Configure " + info["key_env"] + " with setup or in the host environment")
|
|
97
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
98
|
+
if provider == "openai":
|
|
99
|
+
headers["Authorization"] = "Bearer " + key
|
|
100
|
+
else:
|
|
101
|
+
headers["x-api-key"] = key
|
|
102
|
+
try:
|
|
103
|
+
data = None if payload is None else json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
104
|
+
except (TypeError, ValueError, UnicodeError):
|
|
105
|
+
raise ValueError("Research API payload must contain valid JSON") from None
|
|
106
|
+
request = urllib.request.Request(info["origin"] + path, data=data, headers=headers, method=method)
|
|
107
|
+
try:
|
|
108
|
+
with urllib.request.build_opener(_NoRedirect()).open(request, timeout=self.timeout) as response:
|
|
109
|
+
body = response.read(self.MAX_RESPONSE_BYTES + 1)
|
|
110
|
+
if len(body) > self.MAX_RESPONSE_BYTES:
|
|
111
|
+
raise ServiceError("response_too_large", "Research API response exceeds the local size limit", uncertain=True)
|
|
112
|
+
result = json.loads(body.decode("utf-8"), object_pairs_hook=self._unique_object,
|
|
113
|
+
parse_constant=self._invalid_constant, parse_float=self._finite_float)
|
|
114
|
+
if not isinstance(result, dict):
|
|
115
|
+
raise ServiceError("result_format", "Research API returned a non-object response", uncertain=True)
|
|
116
|
+
return result
|
|
117
|
+
except urllib.error.HTTPError as error:
|
|
118
|
+
status = error.code
|
|
119
|
+
kind = {401: "authentication_required", 403: "authentication_required", 404: "run_unavailable",
|
|
120
|
+
408: "result_pending", 429: "rate_limited", 400: "invalid_request", 422: "invalid_request"}.get(status, "http_error")
|
|
121
|
+
retry = error.headers.get("Retry-After") if error.headers else None
|
|
122
|
+
retry = int(retry) if isinstance(retry, str) and len(retry) <= 5 and retry.isascii() and retry.isdigit() and int(retry) <= 86400 else None
|
|
123
|
+
try:
|
|
124
|
+
error.close()
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
raise ServiceError(kind, "Research API HTTP " + str(status),
|
|
128
|
+
uncertain=status >= 500 or status == 408 or (method == "POST" and 300 <= status < 400),
|
|
129
|
+
http_status=status, retry_after=retry) from None
|
|
130
|
+
except (TimeoutError, socket.timeout):
|
|
131
|
+
raise ServiceError("timeout", "Research API observation timed out", uncertain=True) from None
|
|
132
|
+
except urllib.error.URLError:
|
|
133
|
+
raise ServiceError("connection_error", "Research API connection failed", uncertain=True) from None
|
|
134
|
+
except (OSError, http.client.HTTPException):
|
|
135
|
+
# IncompleteRead and low-level socket failures may carry response
|
|
136
|
+
# bytes or request context. Never expose their exception strings.
|
|
137
|
+
raise ServiceError("connection_error", "Research API connection failed or response was interrupted", uncertain=True) from None
|
|
138
|
+
except (ValueError, UnicodeError):
|
|
139
|
+
raise ServiceError("result_format", "Research API response is not valid JSON", uncertain=True) from None
|