bookmark-research 0.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. package/src/wiki.py +383 -0
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