bookmark-research 0.6.0 → 0.7.0
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 +1 -1
- package/.codex-plugin/plugin.json +4 -1
- package/README.md +15 -9
- package/README.zh.md +16 -9
- package/config/providers.json +26 -0
- package/config/settings.example.json +36 -0
- package/docs/bookmark-research-architecture.md +2 -2
- package/docs/canvas-tree-reading-acceptance.md +98 -0
- package/docs/configuration.en.md +70 -0
- package/docs/configuration.md +70 -0
- package/docs/details.en.md +10 -6
- package/docs/details.md +3 -3
- package/docs/harness-compatibility.md +1 -1
- package/docs/installation.en.md +4 -2
- package/docs/installation.md +4 -2
- package/docs/instructions.en.md +1 -0
- package/docs/instructions.md +1 -0
- package/docs/user-guide.en.md +1 -1
- package/docs/user-guide.md +1 -1
- package/hosts/dsh/plugin.js +3 -0
- package/package.json +1 -1
- package/scripts/bench_sync.py +191 -0
- package/scripts/bench_tools_list.py +129 -0
- package/scripts/evaluate_ladder.py +149 -0
- package/scripts/export_bundle.py +2 -0
- package/scripts/host_install.py +4 -1
- package/scripts/install.py +120 -28
- package/skills/bookmark-research/SKILL.md +28 -9
- package/skills/bookmark-research/references/package-semantics.md +56 -6
- package/skills/bookmark-research/references/research-workflow.md +5 -2
- package/skills/bookmark-research/references/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/wiki.md +3 -1
- package/skills/bookmark-research/references/zh/package-semantics.md +56 -6
- package/skills/bookmark-research/references/zh/research-workflow.md +5 -2
- package/skills/bookmark-research/references/zh/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/zh/skill-guide.md +20 -8
- package/skills/bookmark-research/references/zh/wiki.md +3 -1
- package/src/archive.py +100 -0
- package/src/bookmark_index.py +808 -41
- package/src/browser_client.py +342 -0
- package/src/cli.py +298 -14
- package/src/credentials.py +2 -1
- package/src/fetch_cache.py +60 -0
- package/src/grok_search.py +272 -0
- package/src/jobs.py +349 -0
- package/src/junk.py +33 -0
- package/src/locate.py +862 -0
- package/src/mcp_dispatch.py +22 -6
- package/src/mcp_server.py +573 -23
- package/src/netscape.py +124 -0
- package/src/onboarding.py +59 -9
- package/src/perplexity.py +78 -0
- package/src/raw_library.py +183 -45
- package/src/readiness.py +40 -5
- package/src/ref_codec.py +70 -0
- package/src/research.py +125 -12
- package/src/research_plan.py +11 -1
- package/src/routing.py +5 -2
- package/src/search_results.py +80 -2
- package/src/settings.py +61 -16
- package/src/source_inputs.py +109 -3
- package/src/source_manager.py +71 -12
- package/src/source_versions.py +20 -2
- package/src/source_watcher.py +4 -2
- package/src/storage.py +60 -0
- package/src/url_classify.py +45 -0
- package/src/web_search.py +106 -10
- package/src/wiki.py +269 -18
- package/src/wiki_search.py +119 -27
package/src/readiness.py
CHANGED
|
@@ -64,8 +64,10 @@ class Readiness:
|
|
|
64
64
|
def _signature(self, row, salt):
|
|
65
65
|
# Only a salted signature is persisted; no keys, headers, or signatures
|
|
66
66
|
# are included in CLI/MCP results. Rotation invalidates cached checks.
|
|
67
|
+
cred_env = row.get("credential_env")
|
|
68
|
+
cred_val = self.credentials.get(cred_env) if cred_env and cred_env in self.credentials.NAMES else None
|
|
67
69
|
content = {"row": row, "settings": self.settings.load(),
|
|
68
|
-
"credential":
|
|
70
|
+
"credential": cred_val,
|
|
69
71
|
"registry": self.web.registry, "version": self.VERSION}
|
|
70
72
|
return hmac.new(salt.encode(), json.dumps(content, sort_keys=True).encode(), hashlib.sha256).hexdigest()
|
|
71
73
|
|
|
@@ -84,12 +86,19 @@ class Readiness:
|
|
|
84
86
|
preferences["search"]["providers"] + preferences["search"]["fallback_providers"] +
|
|
85
87
|
Settings.fetch_providers(preferences)))
|
|
86
88
|
described = self.web.describe(names)["providers"]
|
|
89
|
+
|
|
90
|
+
def get_cred_env(p):
|
|
91
|
+
key = p.upper() + "_API_KEY"
|
|
92
|
+
return key if key in self.credentials.NAMES else None
|
|
93
|
+
|
|
87
94
|
return [{"id": "retrieval:" + row["provider"], "kind": "retrieval", "provider": row["provider"],
|
|
88
|
-
"credential_env": row["provider"]
|
|
95
|
+
"credential_env": get_cred_env(row["provider"]),
|
|
89
96
|
"credential_configured": row["authentication_state"]["credential_configured"],
|
|
90
97
|
"authentication_mode": row["authentication_state"]["mode"],
|
|
91
98
|
"authentication_verified": False, "execution_verified": False,
|
|
92
|
-
"status": "
|
|
99
|
+
"status": ("disabled" if row["provider"] == "browser" and not preferences.get("browser", {}).get("enabled")
|
|
100
|
+
else "unchecked"),
|
|
101
|
+
"docs": row["docs"], "operations": row["operations"],
|
|
93
102
|
"installation": "included_adapter", "network_checked": False,
|
|
94
103
|
"next_step": "Run readiness --refresh for connection checks; --test-retrieval also uses retrieval quota."}
|
|
95
104
|
for row in described]
|
|
@@ -101,12 +110,25 @@ class Readiness:
|
|
|
101
110
|
key = ResearchHttp.PROVIDERS[name]["key_env"]
|
|
102
111
|
enabled = config["enabled"] and (config["provider"] in (None, name) or service == name)
|
|
103
112
|
configured = bool(self.credentials.get(key))
|
|
113
|
+
if not enabled and not configured:
|
|
114
|
+
diagnosis = "disabled_and_unconfigured"
|
|
115
|
+
next_step = "Run setup to enable professional_research and configure " + key + "."
|
|
116
|
+
elif not enabled and configured:
|
|
117
|
+
diagnosis = "disabled_with_credential_present"
|
|
118
|
+
next_step = "Credential " + key + " is present. Enable via settings.professional_research.enabled=true."
|
|
119
|
+
elif enabled and not configured:
|
|
120
|
+
diagnosis = "enabled_missing_credential"
|
|
121
|
+
next_step = "Service is enabled in settings, but missing API key. Configure " + key + " in environment or private credential file."
|
|
122
|
+
else:
|
|
123
|
+
diagnosis = "ready_for_check"
|
|
124
|
+
next_step = "Ready for deep research. Run readiness --refresh to verify connectivity."
|
|
104
125
|
rows.append({"id": "service:" + name, "kind": "professional_research", "provider": name,
|
|
105
126
|
"credential_env": key, "credential_configured": configured,
|
|
106
127
|
"authentication_verified": False, "execution_verified": False,
|
|
107
128
|
"network_checked": False, "enabled": enabled,
|
|
108
129
|
"status": "unchecked" if configured and enabled else "disabled" if not enabled else "missing_credentials",
|
|
109
|
-
"
|
|
130
|
+
"diagnosis": diagnosis,
|
|
131
|
+
"next_step": next_step,
|
|
110
132
|
"docs": "https://developers.openai.com/api/docs/guides/deep-research" if name == "openai"
|
|
111
133
|
else "https://docs.parallel.ai/task-api/guides/execute-task-run"})
|
|
112
134
|
return rows
|
|
@@ -116,6 +138,9 @@ class Readiness:
|
|
|
116
138
|
if test_retrieval:
|
|
117
139
|
operations = copy.deepcopy(row["operations"])
|
|
118
140
|
for purpose in ("search", "fetch"):
|
|
141
|
+
if not self.web.registry["providers"][name].get(purpose + "_tools"):
|
|
142
|
+
operations[purpose]["status"] = "unsupported"
|
|
143
|
+
continue
|
|
119
144
|
if operations[purpose]["missing"]:
|
|
120
145
|
operations[purpose]["status"] = "missing_credentials"
|
|
121
146
|
continue
|
|
@@ -140,8 +165,18 @@ class Readiness:
|
|
|
140
165
|
"capabilities": probe["capabilities"],
|
|
141
166
|
"next_step": "Search/fetch can be attempted. Catalog discovery does not verify retrieval authorization or quota."}
|
|
142
167
|
if probe["status"] == "not_probed":
|
|
168
|
+
missing = sorted({key for operation in row.get("operations", {}).values()
|
|
169
|
+
for key in operation.get("missing", [])})
|
|
170
|
+
supported = [purpose for purpose in ("search", "fetch")
|
|
171
|
+
if self.web.registry["providers"][name].get(purpose + "_tools")]
|
|
172
|
+
guidance = []
|
|
173
|
+
if missing:
|
|
174
|
+
guidance.append("Configure " + ", ".join(missing) + ".")
|
|
175
|
+
if supported:
|
|
176
|
+
guidance.append("Supported operations: " + ", ".join(supported) + ".")
|
|
177
|
+
guidance.append("Use --test-retrieval for an actual check; this may use provider quota.")
|
|
143
178
|
return {"status": "http_unchecked", "network_checked": False,
|
|
144
|
-
"next_step": "
|
|
179
|
+
"next_step": " ".join(guidance)}
|
|
145
180
|
return {"status": "failed", "network_checked": True,
|
|
146
181
|
"error_kind": probe.get("error_kind", "check_failed"),
|
|
147
182
|
"next_step": "Check credentials and connectivity, then refresh; another configured provider may remain available."}
|
package/src/ref_codec.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Process-local short reference registry.
|
|
2
|
+
|
|
3
|
+
Short refs reduce repeated addressing cost while the registry keeps the
|
|
4
|
+
authoritative source/version/object identity available to read and walk.
|
|
5
|
+
References are opaque handles; callers must resolve them through this module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import threading
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_MAX_REFS = 4096
|
|
14
|
+
_LOCK = threading.RLock()
|
|
15
|
+
_BY_REF = OrderedDict()
|
|
16
|
+
_BY_IDENTITY = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _scope(kind, identity):
|
|
20
|
+
digest = hashlib.sha256((kind + "\0" + "\0".join(str(v) for v in identity)).encode("utf-8")).hexdigest()
|
|
21
|
+
return digest[:4]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(kind, identity):
|
|
25
|
+
"""Register an identity and return a collision-checked short ref."""
|
|
26
|
+
if not isinstance(kind, str) or len(kind) != 1 or kind not in ("b", "w", "a"):
|
|
27
|
+
raise ValueError("ref kind must be b, w or a")
|
|
28
|
+
identity = tuple(str(value) for value in identity)
|
|
29
|
+
key = (kind, identity)
|
|
30
|
+
with _LOCK:
|
|
31
|
+
existing = _BY_IDENTITY.get(key)
|
|
32
|
+
if existing is not None:
|
|
33
|
+
_BY_REF.move_to_end(existing)
|
|
34
|
+
return existing
|
|
35
|
+
scope = _scope(kind, identity)
|
|
36
|
+
sequence = 1
|
|
37
|
+
while True:
|
|
38
|
+
ref = f"{kind}-{scope}-{sequence}"
|
|
39
|
+
previous = _BY_REF.get(ref)
|
|
40
|
+
if previous is None or previous == key:
|
|
41
|
+
break
|
|
42
|
+
sequence += 1
|
|
43
|
+
_BY_REF[ref] = key
|
|
44
|
+
_BY_IDENTITY[key] = ref
|
|
45
|
+
_BY_REF.move_to_end(ref)
|
|
46
|
+
while len(_BY_REF) > _MAX_REFS:
|
|
47
|
+
old_ref, old_key = _BY_REF.popitem(last=False)
|
|
48
|
+
if _BY_IDENTITY.get(old_key) == old_ref:
|
|
49
|
+
del _BY_IDENTITY[old_key]
|
|
50
|
+
return ref
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve(ref):
|
|
54
|
+
"""Resolve a short ref to its kind and bound identity, or return None."""
|
|
55
|
+
if not isinstance(ref, str):
|
|
56
|
+
return None
|
|
57
|
+
with _LOCK:
|
|
58
|
+
key = _BY_REF.get(ref)
|
|
59
|
+
if key is None:
|
|
60
|
+
return None
|
|
61
|
+
_BY_REF.move_to_end(ref)
|
|
62
|
+
kind, identity = key
|
|
63
|
+
return {"kind": kind, "identity": list(identity)}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clear():
|
|
67
|
+
"""Clear the process-local registry; intended for isolated tests."""
|
|
68
|
+
with _LOCK:
|
|
69
|
+
_BY_REF.clear()
|
|
70
|
+
_BY_IDENTITY.clear()
|
package/src/research.py
CHANGED
|
@@ -89,6 +89,18 @@ class ResearchSessions:
|
|
|
89
89
|
raise ValueError("Invalid research_id")
|
|
90
90
|
return research_id
|
|
91
91
|
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _slug(value, fallback=None):
|
|
94
|
+
raw = (value or "").strip().lower()
|
|
95
|
+
slug = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
|
|
96
|
+
slug = re.sub(r"-+", "-", slug)[:80]
|
|
97
|
+
if slug and re.fullmatch(r"[a-z0-9][a-z0-9-]{0,79}", slug):
|
|
98
|
+
return slug
|
|
99
|
+
if fallback is not None:
|
|
100
|
+
return ResearchSessions._slug(fallback)
|
|
101
|
+
from wiki import WikiStore
|
|
102
|
+
return WikiStore._slug(value)
|
|
103
|
+
|
|
92
104
|
@staticmethod
|
|
93
105
|
def _registry():
|
|
94
106
|
"""Return the location registry; an unreadable registry means legacy lookup only."""
|
|
@@ -526,7 +538,8 @@ class ResearchSessions:
|
|
|
526
538
|
self._integer(limit, key, 0, self.LIMITS[key])
|
|
527
539
|
preferences = self.settings.load()
|
|
528
540
|
names = self._providers(providers if providers is not None else preferences["search"]["providers"])
|
|
529
|
-
fetch_names =
|
|
541
|
+
fetch_names = ([name for name in names if name in Settings.FETCH_PROVIDERS]
|
|
542
|
+
or Settings.fetch_providers(preferences)) if providers is not None else Settings.fetch_providers(preferences)
|
|
530
543
|
search_fallbacks = [] if providers is not None else [name for name in preferences["search"]["fallback_providers"]
|
|
531
544
|
if name not in names]
|
|
532
545
|
if not isinstance(scope, str) or len(scope) > 12000 or "\x00" in scope:
|
|
@@ -703,11 +716,22 @@ class ResearchSessions:
|
|
|
703
716
|
with BookmarkIndex(self.db_path) as index:
|
|
704
717
|
manager = SourceManager(index)
|
|
705
718
|
with index.read_snapshot():
|
|
719
|
+
resolved_ids = {sid: index.resolve_source_id(sid) for sid in ids}
|
|
720
|
+
resolved_refs = ([{**ref, "source_id": index.resolve_source_id(ref["source_id"])}
|
|
721
|
+
for ref in bookmark_refs] if bookmark_refs else None)
|
|
706
722
|
snapshot = {"version": index.input_version(ids),
|
|
707
|
-
"states": {sid: manager.source_status(sid)["state"] for sid in ids}
|
|
723
|
+
"states": {sid: manager.source_status(resolved_ids[sid])["state"] for sid in ids},
|
|
724
|
+
"resolved_ids": resolved_ids}
|
|
708
725
|
if bookmark_refs:
|
|
709
726
|
snapshot["inventory"] = index.inventory(ids) if snapshot["version"] != frozen else None
|
|
710
727
|
cache[key] = snapshot
|
|
728
|
+
else:
|
|
729
|
+
with BookmarkIndex(self.db_path) as index:
|
|
730
|
+
resolved_refs = ([{**ref, "source_id": index.resolve_source_id(ref["source_id"])}
|
|
731
|
+
for ref in bookmark_refs] if bookmark_refs else None)
|
|
732
|
+
redirected = {old: new for old, new in snapshot.get("resolved_ids", {}).items() if old != new}
|
|
733
|
+
if redirected:
|
|
734
|
+
result["resolved_via_redirect"] = redirected
|
|
711
735
|
current, states = snapshot["version"], snapshot["states"]
|
|
712
736
|
changed = current != frozen
|
|
713
737
|
result.update(current_input_version=current, state="changed" if changed else "unchanged",
|
|
@@ -722,8 +746,18 @@ class ResearchSessions:
|
|
|
722
746
|
if hashlib.sha256(raw).hexdigest() != state.get("inventory_manifest_sha256"):
|
|
723
747
|
raise ValueError("Frozen input inventory hash changed")
|
|
724
748
|
cache[frozen_key] = json.loads(raw)
|
|
725
|
-
|
|
726
|
-
|
|
749
|
+
frozen_inventory = cache[frozen_key]
|
|
750
|
+
if redirected:
|
|
751
|
+
def remap(value):
|
|
752
|
+
if isinstance(value, dict):
|
|
753
|
+
return {key: (redirected.get(child, child) if key == "source_id" else remap(child))
|
|
754
|
+
for key, child in value.items()}
|
|
755
|
+
if isinstance(value, list):
|
|
756
|
+
return [remap(child) for child in value]
|
|
757
|
+
return value
|
|
758
|
+
frozen_inventory = remap(frozen_inventory)
|
|
759
|
+
relevant = scoped_input(frozen_inventory, resolved_refs) != scoped_input(
|
|
760
|
+
snapshot["inventory"], resolved_refs)
|
|
727
761
|
result.update(state="changed" if relevant else "unchanged", requires_review=relevant,
|
|
728
762
|
package_changed=True, comparison_scope="cited_bookmarks_and_context")
|
|
729
763
|
except (OSError, ValueError, KeyError, TypeError, sqlite3.Error) as exc:
|
|
@@ -1154,7 +1188,7 @@ class ResearchSessions:
|
|
|
1154
1188
|
{"fetch_calls": Settings.fetch_call_count(names[0], fresh) if fresh else 0}, execute)
|
|
1155
1189
|
|
|
1156
1190
|
def import_evidence(self, research_id, operation_id, question_id, url, text, provenance,
|
|
1157
|
-
inventory_ids=None, title=""):
|
|
1191
|
+
inventory_ids=None, title="", bookmark_refs=None):
|
|
1158
1192
|
"""Save host-read page text or a secondary report without fetching it.
|
|
1159
1193
|
|
|
1160
1194
|
A report can cite original input IDs, but those references never count
|
|
@@ -1188,14 +1222,16 @@ class ResearchSessions:
|
|
|
1188
1222
|
if "run_id" in provenance:
|
|
1189
1223
|
self._text(provenance["run_id"], "External run id", 2000)
|
|
1190
1224
|
supplied_ids = None
|
|
1225
|
+
is_internal_wiki = state.get("topic", "").startswith("Wiki: ") or state.get("brief", "").startswith("Wiki: ")
|
|
1191
1226
|
if inventory_ids is not None:
|
|
1192
1227
|
if not isinstance(inventory_ids, list):
|
|
1193
1228
|
raise ValueError("inventory_ids must be a list")
|
|
1194
1229
|
supplied_ids = list(dict.fromkeys(self._identifier(value, "Inventory id") for value in inventory_ids))
|
|
1195
|
-
|
|
1196
|
-
|
|
1230
|
+
if state.get("inventory") or not is_internal_wiki:
|
|
1231
|
+
for identifier in supplied_ids:
|
|
1232
|
+
self._find(state.get("inventory", []), identifier, "inventory item")
|
|
1197
1233
|
linked_ids, _ = self._source_links(state, url)
|
|
1198
|
-
if kind != "external_report" and supplied_ids is not None and set(supplied_ids) - set(linked_ids):
|
|
1234
|
+
if kind != "external_report" and supplied_ids is not None and (state.get("inventory") or not is_internal_wiki) and set(supplied_ids) - set(linked_ids):
|
|
1199
1235
|
raise ValueError("Imported page URL does not match the selected original inventory URL")
|
|
1200
1236
|
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
1201
1237
|
if "text_sha256" in provenance and provenance["text_sha256"] != digest:
|
|
@@ -1228,7 +1264,7 @@ class ResearchSessions:
|
|
|
1228
1264
|
"provenance": provenance, "review": {"verdict": "unreviewed", "text": None},
|
|
1229
1265
|
"inventory_ids": [] if kind == "external_report" else identifiers,
|
|
1230
1266
|
"referenced_inventory_ids": (supplied_ids or []) if kind == "external_report" else [],
|
|
1231
|
-
"bookmark_refs": [] if kind == "external_report" else refs}
|
|
1267
|
+
"bookmark_refs": bookmark_refs if bookmark_refs is not None else ([] if kind == "external_report" else refs)}
|
|
1232
1268
|
# The commit assigns the source id and writes its manifest together.
|
|
1233
1269
|
# The request snapshot cannot predict ids allocated by other writers.
|
|
1234
1270
|
return {"status": "ok", "sources": [source], "next_action":
|
|
@@ -1730,6 +1766,8 @@ class ResearchSessions:
|
|
|
1730
1766
|
policy = "suggest"
|
|
1731
1767
|
if policy == "off":
|
|
1732
1768
|
return {"policy": "off"}
|
|
1769
|
+
if state.get("topic", "").startswith("Wiki: ") or state.get("brief", "").startswith("Wiki: "):
|
|
1770
|
+
return {"policy": policy, "skip_reason": "internal_wiki_session", "candidates": [], "draft": None}
|
|
1733
1771
|
eligible = [claim["id"] for claim in state["claims"] if claim.get("status", "active") == "active"
|
|
1734
1772
|
and all(self._find(state["sources"], citation["source_id"], "source").get("review", {}).get("verdict")
|
|
1735
1773
|
== "accepted" for citation in claim["citations"])]
|
|
@@ -1746,7 +1784,75 @@ class ResearchSessions:
|
|
|
1746
1784
|
ranked = sorted(candidates.values(), key=lambda row: (-len(row["matched_terms"]), row["page_id"]))[:5]
|
|
1747
1785
|
except Exception: # A Wiki problem must never undo a saved report.
|
|
1748
1786
|
ranked = []
|
|
1787
|
+
draft_page = self._wiki_page(drafts, state)
|
|
1788
|
+
written_page = None
|
|
1789
|
+
skip_reason = None
|
|
1749
1790
|
if policy == "auto":
|
|
1791
|
+
if not drafts:
|
|
1792
|
+
skip_reason = "insufficient_evidence" if not state.get("claims") else "no_accepted_claims"
|
|
1793
|
+
elif state.get("status") == "active":
|
|
1794
|
+
skip_reason = "session_active"
|
|
1795
|
+
elif state.get("dry_run"):
|
|
1796
|
+
skip_reason = "dry_run"
|
|
1797
|
+
else:
|
|
1798
|
+
try:
|
|
1799
|
+
from wiki import WikiStore
|
|
1800
|
+
store = WikiStore(settings=self.settings, research_sessions=self)
|
|
1801
|
+
auto_page = copy.deepcopy(draft_page["page"])
|
|
1802
|
+
auto_page["review"] = {
|
|
1803
|
+
"method": "model",
|
|
1804
|
+
"reviewer": "auto_research",
|
|
1805
|
+
"note": "Auto-synthesized reviewed claims from research session %s" % state["research_id"]
|
|
1806
|
+
}
|
|
1807
|
+
if ranked:
|
|
1808
|
+
candidate_page_id = ranked[0]["page_id"]
|
|
1809
|
+
else:
|
|
1810
|
+
title = state.get("title") or state.get("topic") or state.get("brief") or state["research_id"]
|
|
1811
|
+
candidate_page_id = self._slug(title, fallback=state["research_id"])
|
|
1812
|
+
expected_rev = 0
|
|
1813
|
+
existing = None
|
|
1814
|
+
try:
|
|
1815
|
+
existing = store.get(candidate_page_id)
|
|
1816
|
+
except Exception:
|
|
1817
|
+
existing = None
|
|
1818
|
+
if existing and existing.get("page"):
|
|
1819
|
+
expected_rev = existing.get("revision", 0)
|
|
1820
|
+
merged_sections = copy.deepcopy(existing["page"].get("sections", []))
|
|
1821
|
+
existing_headings = {s.get("heading") for s in merged_sections}
|
|
1822
|
+
for s in auto_page.get("sections", []):
|
|
1823
|
+
if s.get("heading") not in existing_headings and len(merged_sections) < WikiStore.MAX_SECTIONS:
|
|
1824
|
+
merged_sections.append(s)
|
|
1825
|
+
existing_headings.add(s.get("heading"))
|
|
1826
|
+
auto_page["sections"] = merged_sections
|
|
1827
|
+
existing_links = existing["page"].get("links", [])
|
|
1828
|
+
merged_links = copy.deepcopy(existing_links)
|
|
1829
|
+
existing_link_targets = {l.get("page_id") for l in merged_links}
|
|
1830
|
+
for l in auto_page.get("links", []):
|
|
1831
|
+
if l.get("page_id") not in existing_link_targets and len(merged_links) < 32:
|
|
1832
|
+
merged_links.append(l)
|
|
1833
|
+
existing_link_targets.add(l.get("page_id"))
|
|
1834
|
+
auto_page["links"] = merged_links
|
|
1835
|
+
if existing["page"].get("aliases"):
|
|
1836
|
+
auto_page["aliases"] = existing["page"]["aliases"]
|
|
1837
|
+
write_result = store.write(
|
|
1838
|
+
candidate_page_id,
|
|
1839
|
+
auto_page,
|
|
1840
|
+
change_note="Auto-saved research %s" % state["research_id"],
|
|
1841
|
+
expected_revision=expected_rev
|
|
1842
|
+
)
|
|
1843
|
+
written_page = {
|
|
1844
|
+
"status": "written",
|
|
1845
|
+
"page_id": write_result["page_id"],
|
|
1846
|
+
"revision": write_result["revision"],
|
|
1847
|
+
"markdown_file": write_result.get("markdown_file")
|
|
1848
|
+
}
|
|
1849
|
+
except Exception as exc:
|
|
1850
|
+
conflict = "revision changed" in str(exc).lower()
|
|
1851
|
+
written_page = {
|
|
1852
|
+
"status": "conflict_aborted" if conflict else "failed",
|
|
1853
|
+
"error": str(exc),
|
|
1854
|
+
"skip_reason": "revision_conflict" if conflict else "write_failed"
|
|
1855
|
+
}
|
|
1750
1856
|
instruction = ("Without asking the user, write or update Wiki pages with the reviewed claims: update a matching "
|
|
1751
1857
|
"candidate page (read it with wiki_get and pass its expected_revision) or create a new page from "
|
|
1752
1858
|
"eligible_claims, citing only those claims and marking inference statements as inference. "
|
|
@@ -1755,9 +1861,16 @@ class ResearchSessions:
|
|
|
1755
1861
|
instruction = ("Present the candidate Wiki pages to update, or a proposed new page built from eligible_claims "
|
|
1756
1862
|
"(group them by question_id into draft sections; each claim carries exact first quotes and an "
|
|
1757
1863
|
"inference mark), to the user. Honor existing authorization; otherwise obtain confirmation before writing.")
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1864
|
+
follow_up = {"policy": policy, "candidates": ranked, "eligible_claim_ids": eligible, "eligible_claims": drafts,
|
|
1865
|
+
"draft": draft_page, "instruction": instruction,
|
|
1866
|
+
"work": {"tool": "wiki_work", "arguments": {"research_id": state["research_id"]}}}
|
|
1867
|
+
if written_page:
|
|
1868
|
+
follow_up["written_page"] = written_page
|
|
1869
|
+
follow_up.update(written_page)
|
|
1870
|
+
elif skip_reason:
|
|
1871
|
+
follow_up["status"] = "skipped"
|
|
1872
|
+
follow_up["skip_reason"] = skip_reason
|
|
1873
|
+
return follow_up
|
|
1761
1874
|
|
|
1762
1875
|
@staticmethod
|
|
1763
1876
|
def _question_gap(question, state):
|
package/src/research_plan.py
CHANGED
|
@@ -87,7 +87,17 @@ class ResearchPlan:
|
|
|
87
87
|
if not terms:
|
|
88
88
|
return [], None
|
|
89
89
|
try:
|
|
90
|
-
|
|
90
|
+
results = self._wiki().search_terms(terms, limit=limit)
|
|
91
|
+
if hasattr(self._wiki(), "_log_metric"):
|
|
92
|
+
pids = [row.get("page_id") for row in results if row.get("page_id")]
|
|
93
|
+
self._wiki()._log_metric(
|
|
94
|
+
"plan_research",
|
|
95
|
+
page_id=pids[0] if len(pids) == 1 else None,
|
|
96
|
+
page_ids=pids if len(pids) > 1 else None,
|
|
97
|
+
hit_count=len(results),
|
|
98
|
+
reused=bool(results)
|
|
99
|
+
)
|
|
100
|
+
return results, None
|
|
91
101
|
except Exception as exc: # local optional check; never hide the bookmark scope
|
|
92
102
|
return [], "Wiki preflight unavailable: " + str(exc)
|
|
93
103
|
|
package/src/routing.py
CHANGED
|
@@ -201,10 +201,12 @@ class ResearchRouting:
|
|
|
201
201
|
if host_search else "always"),
|
|
202
202
|
"deadline_seconds": settings["search"]["deadline_seconds"],
|
|
203
203
|
"first_usable_result_wins": True,
|
|
204
|
+
"backflow_tool": "merge_search_results",
|
|
204
205
|
"note": ("Host-native search is the default discovery channel. Use search_web when it is "
|
|
205
206
|
"unavailable, returns nothing or insufficient results, or the user asks for provider "
|
|
206
207
|
"diversity. For latency-sensitive work, issue both together and take the first "
|
|
207
|
-
"usable result; do not wait for a slow channel."
|
|
208
|
+
"usable result; do not wait for a slow channel. Host search results can be merged and "
|
|
209
|
+
"archived via merge_search_results."
|
|
208
210
|
if host_search else
|
|
209
211
|
"The host has no observed native search; search_web runs its configured providers "
|
|
210
212
|
"in parallel with fallbacks for unresolved queries.")}
|
|
@@ -213,7 +215,8 @@ class ResearchRouting:
|
|
|
213
215
|
return {"depth": selected_depth, "host": host, "task_shape": task_shape, "selected": selected,
|
|
214
216
|
"candidates": candidates, "missing_or_unconfirmed": missing,
|
|
215
217
|
"host_search": {"available": host_search is not None, "tool": host_search,
|
|
216
|
-
"role": "discovery_only; fetch_web remains the evidence read"
|
|
218
|
+
"role": "discovery_only; fetch_web remains the evidence read",
|
|
219
|
+
"backflow_tool": "merge_search_results"},
|
|
217
220
|
"discovery": discovery,
|
|
218
221
|
"next_action": next_action, "workflow": workflow, "research_process": process,
|
|
219
222
|
"fallback": {"execution_owner": "host", "failed_routes": failed,
|
package/src/search_results.py
CHANGED
|
@@ -7,6 +7,7 @@ Only snippets are retained; full source text belongs in a later fetch step.
|
|
|
7
7
|
|
|
8
8
|
from collections import OrderedDict
|
|
9
9
|
from urllib.parse import urlsplit, urlunsplit
|
|
10
|
+
import hashlib
|
|
10
11
|
import ipaddress
|
|
11
12
|
import re
|
|
12
13
|
|
|
@@ -71,13 +72,27 @@ def _mode(providers, query_count):
|
|
|
71
72
|
return "single_provider_multi_query" if query_count > 1 else "single_provider_single_query"
|
|
72
73
|
|
|
73
74
|
|
|
74
|
-
def fuse_results(payload):
|
|
75
|
+
def fuse_results(payload, archive=False, archive_dir=None, settings=None):
|
|
75
76
|
"""Return independently ranked targets, provenance, failures and coverage.
|
|
76
77
|
|
|
77
78
|
Malformed top-level configuration raises ValueError. Invalid batches/results
|
|
78
79
|
are reported and do not discard valid siblings. Provider counts include
|
|
79
80
|
failed providers; successful-provider coverage is reported separately.
|
|
81
|
+
Deduplicates URLs and content SHA-256 hashes, with optional archive persistence.
|
|
80
82
|
"""
|
|
83
|
+
if isinstance(payload, dict) and "batches" not in payload and isinstance(payload.get("streams"), list):
|
|
84
|
+
default_target = payload.get("target") or payload.get("query") or "default"
|
|
85
|
+
payload = dict(payload)
|
|
86
|
+
payload["batches"] = []
|
|
87
|
+
for s in payload["streams"]:
|
|
88
|
+
if isinstance(s, dict):
|
|
89
|
+
payload["batches"].append({
|
|
90
|
+
"target": s.get("target", default_target),
|
|
91
|
+
"provider": s.get("provider", "host"),
|
|
92
|
+
"query": s.get("query", payload.get("query", default_target)),
|
|
93
|
+
"status": s.get("status", "ok"),
|
|
94
|
+
"results": s.get("results") if "results" in s else s.get("items", [])
|
|
95
|
+
})
|
|
81
96
|
if not isinstance(payload, dict) or not isinstance(payload.get("batches"), list):
|
|
82
97
|
raise ValueError("payload must be an object with a batches array")
|
|
83
98
|
limit = payload.get("limit_per_target", 10)
|
|
@@ -88,6 +103,7 @@ def fuse_results(payload):
|
|
|
88
103
|
raise ValueError("targets must be an array of nonempty strings")
|
|
89
104
|
buckets = OrderedDict((target.strip(), _bucket()) for target in requested)
|
|
90
105
|
errors, invalid_results = [], []
|
|
106
|
+
total_submitted_results = 0
|
|
91
107
|
|
|
92
108
|
def error(batch_index, message, target=None, provider=None, query=None, kind="invalid_batch"):
|
|
93
109
|
errors.append({"batch_index": batch_index, "target": target,
|
|
@@ -130,6 +146,7 @@ def fuse_results(payload):
|
|
|
130
146
|
continue
|
|
131
147
|
stream["ok"] = True
|
|
132
148
|
for position, item in enumerate(results, 1):
|
|
149
|
+
total_submitted_results += 1
|
|
133
150
|
try:
|
|
134
151
|
if not isinstance(item, dict):
|
|
135
152
|
raise ValueError("result must be an object")
|
|
@@ -146,6 +163,9 @@ def fuse_results(payload):
|
|
|
146
163
|
snippet = item.get("text", "")
|
|
147
164
|
if not isinstance(snippet, str):
|
|
148
165
|
raise ValueError("text or snippet must be a string")
|
|
166
|
+
content = item.get("content")
|
|
167
|
+
if content is not None and not isinstance(content, str):
|
|
168
|
+
raise ValueError("content must be a string")
|
|
149
169
|
except ValueError as exc:
|
|
150
170
|
invalid_results.append({"batch_index": batch_index, "result_position": position,
|
|
151
171
|
"target": target, "provider": provider, "query": query,
|
|
@@ -153,25 +173,36 @@ def fuse_results(payload):
|
|
|
153
173
|
"reason": str(exc)})
|
|
154
174
|
bucket["invalid_count"] += 1
|
|
155
175
|
continue
|
|
176
|
+
|
|
177
|
+
text_body = content if content is not None else snippet
|
|
178
|
+
digest = hashlib.sha256(text_body.encode("utf-8")).hexdigest() if text_body else None
|
|
179
|
+
|
|
156
180
|
snippet, truncated = snippet[:MAX_SNIPPET_CHARS], len(snippet) > MAX_SNIPPET_CHARS
|
|
157
181
|
existing = stream["urls"].get(canonical)
|
|
158
182
|
if existing is None:
|
|
159
183
|
stream["urls"][canonical] = {
|
|
160
184
|
"rank": rank, "raw_urls": {raw_url}, "title": title,
|
|
161
185
|
"snippet": snippet, "snippet_truncated": truncated,
|
|
186
|
+
"content": content, "sha256": digest,
|
|
162
187
|
}
|
|
163
188
|
else:
|
|
164
189
|
existing["rank"] = min(existing["rank"], rank)
|
|
165
190
|
existing["raw_urls"].add(raw_url)
|
|
166
191
|
if title:
|
|
167
192
|
existing["title"] = min(t for t in (existing["title"], title) if t)
|
|
193
|
+
if content and (not existing.get("content") or len(content) > len(existing["content"])):
|
|
194
|
+
existing["content"] = content
|
|
195
|
+
existing["sha256"] = digest
|
|
168
196
|
old = (len(existing["snippet"]), existing["snippet"], existing["snippet_truncated"])
|
|
169
197
|
new = (len(snippet), snippet, truncated)
|
|
170
198
|
if new > old:
|
|
171
199
|
existing["snippet"], existing["snippet_truncated"] = snippet, truncated
|
|
200
|
+
if not existing.get("content"):
|
|
201
|
+
existing["sha256"] = digest
|
|
172
202
|
|
|
173
203
|
targets, all_providers, all_successful_providers = [], set(), set()
|
|
174
204
|
query_count = 0
|
|
205
|
+
all_ranked_by_target = {}
|
|
175
206
|
for target, bucket in buckets.items():
|
|
176
207
|
streams = bucket["streams"]
|
|
177
208
|
by_url, by_provider = {}, {}
|
|
@@ -188,17 +219,21 @@ def fuse_results(payload):
|
|
|
188
219
|
"provider": provider, "query": query, "rank": hit["rank"],
|
|
189
220
|
"url": raw_urls[0], "raw_urls": raw_urls, "title": hit["title"],
|
|
190
221
|
"snippet": hit["snippet"], "snippet_truncated": hit["snippet_truncated"],
|
|
222
|
+
"content": hit.get("content"), "sha256": hit.get("sha256"),
|
|
191
223
|
})
|
|
192
224
|
ranked = []
|
|
193
225
|
for canonical, sources in by_url.items():
|
|
194
226
|
sources.sort(key=lambda source: (source["rank"], source["provider"], source["query"]))
|
|
195
227
|
provider_count = len({source["provider"] for source in sources})
|
|
228
|
+
preferred_sha = next((s["sha256"] for s in sources if s.get("sha256")), None)
|
|
196
229
|
ranked.append({
|
|
197
230
|
"url": sources[0]["url"], "canonical_url": canonical,
|
|
198
231
|
"raw_urls": sorted({url for source in sources for url in source["raw_urls"]}),
|
|
199
232
|
"title": next((source["title"] for source in sources if source["title"]), ""),
|
|
233
|
+
"sha256": preferred_sha,
|
|
200
234
|
"rrf_score": sum(1.0 / (RRF_K + source["rank"]) for source in sources),
|
|
201
235
|
"provider_count": provider_count,
|
|
236
|
+
"providers": sorted({source["provider"] for source in sources}),
|
|
202
237
|
"corroborated": provider_count > 1,
|
|
203
238
|
"evidence_count": len(sources), "sources": sources,
|
|
204
239
|
})
|
|
@@ -217,6 +252,7 @@ def fuse_results(payload):
|
|
|
217
252
|
successful_providers = sorted(p for p, coverage in by_provider.items() if coverage["successful_queries"])
|
|
218
253
|
for coverage in by_provider.values():
|
|
219
254
|
coverage["unique_urls"] = len(coverage["unique_urls"])
|
|
255
|
+
all_ranked_by_target[target] = ranked
|
|
220
256
|
targets.append({
|
|
221
257
|
"target": target, "status": status, "providers": sorted(by_provider),
|
|
222
258
|
"provider_count": len(by_provider), "query_count": len(streams),
|
|
@@ -232,6 +268,44 @@ def fuse_results(payload):
|
|
|
232
268
|
all_providers.update(by_provider)
|
|
233
269
|
all_successful_providers.update(successful_providers)
|
|
234
270
|
query_count += len(streams)
|
|
271
|
+
|
|
272
|
+
all_canonical_urls = set()
|
|
273
|
+
sha_to_urls = {}
|
|
274
|
+
for target_items in all_ranked_by_target.values():
|
|
275
|
+
for item in target_items:
|
|
276
|
+
all_canonical_urls.add(item["canonical_url"])
|
|
277
|
+
h = item.get("sha256")
|
|
278
|
+
if h:
|
|
279
|
+
sha_to_urls.setdefault(h, set()).add(item["canonical_url"])
|
|
280
|
+
|
|
281
|
+
content_duplicates = [
|
|
282
|
+
{"sha256": h, "canonical_urls": sorted(urls)}
|
|
283
|
+
for h, urls in sha_to_urls.items() if len(urls) > 1
|
|
284
|
+
]
|
|
285
|
+
total_ranked = sum(len(items) for items in all_ranked_by_target.values())
|
|
286
|
+
deduplication = {
|
|
287
|
+
"submitted_results": total_submitted_results,
|
|
288
|
+
"unique_canonical_urls": len(all_canonical_urls),
|
|
289
|
+
"unique_content_hashes": len(sha_to_urls),
|
|
290
|
+
"duplicate_urls_merged": max(0, total_submitted_results - total_ranked),
|
|
291
|
+
"cross_target_shared_urls": max(0, total_ranked - len(all_canonical_urls)),
|
|
292
|
+
"content_duplicates": content_duplicates,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
archive_report = None
|
|
296
|
+
if archive or payload.get("archive") is True:
|
|
297
|
+
from archive import SourceArchive
|
|
298
|
+
from settings import Settings
|
|
299
|
+
target_dir = archive_dir
|
|
300
|
+
if not target_dir:
|
|
301
|
+
if settings:
|
|
302
|
+
target_dir = settings.load()["archive"]["directory"]
|
|
303
|
+
else:
|
|
304
|
+
target_dir = str(Settings.defaults()["archive"]["directory"])
|
|
305
|
+
store = SourceArchive(target_dir)
|
|
306
|
+
all_ranked_hits = [hit for target_items in all_ranked_by_target.values() for hit in target_items]
|
|
307
|
+
archive_report = store.save_search_batch(payload, all_ranked_hits)
|
|
308
|
+
|
|
235
309
|
return {
|
|
236
310
|
"provider_count": len(all_providers), "providers": sorted(all_providers),
|
|
237
311
|
"successful_provider_count": len(all_successful_providers),
|
|
@@ -243,5 +317,9 @@ def fuse_results(payload):
|
|
|
243
317
|
"independent_fact_confirmation": False},
|
|
244
318
|
"missing_targets": [item["target"] for item in targets if item["status"] == "missing"],
|
|
245
319
|
"uncovered_targets": [item["target"] for item in targets if not item["results"]],
|
|
246
|
-
"targets": targets,
|
|
320
|
+
"targets": targets,
|
|
321
|
+
"errors": errors, "invalid_results": invalid_results,
|
|
322
|
+
"deduplication": deduplication,
|
|
323
|
+
"archive": archive_report if archive_report is not None else {"status": "disabled"},
|
|
324
|
+
"backflow_contract": "normalized_host_search_v1",
|
|
247
325
|
}
|