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/locate.py
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
"""Shared Canvas locate primitive.
|
|
2
|
+
|
|
3
|
+
The MCP transport and the command-line entry point should use this module so
|
|
4
|
+
query normalization, metadata inference, ranking and evidence explanations do
|
|
5
|
+
not diverge between hosts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import tempfile
|
|
15
|
+
import zlib
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ref_codec import register as register_ref
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Keep this list focused on conversational glue. Note and Tag are metadata
|
|
22
|
+
# predicates, not free-text terms, so they are handled by ``locate_metadata``.
|
|
23
|
+
LOCATE_STOPWORDS = frozenset({
|
|
24
|
+
"关于", "定位", "一个", "就是", "然后", "主要", "这种", "帮", "书签", "东西", "好像",
|
|
25
|
+
"还有", "带", "有", "这个", "那个", "请", "帮忙", "者", "的", "和", "或", "与", "及",
|
|
26
|
+
"note", "notes", "tag", "tags", "标签",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
# The public locate contract is intentionally kept in one place for CLI/MCP
|
|
30
|
+
# adapters and parameter-matrix tests.
|
|
31
|
+
LOCATE_ARGUMENTS = (
|
|
32
|
+
"text", "url", "in", "source_id", "section", "folder_id", "tags",
|
|
33
|
+
"tag_colors", "has_note", "has_tags", "metadata_match", "limit", "offset", "handle", "refresh",
|
|
34
|
+
"package_path",
|
|
35
|
+
)
|
|
36
|
+
LOCATE_FIELD_WEIGHTS = {"title": 140, "url": 120, "tags": 110, "note": 90, "path": 60}
|
|
37
|
+
LOCATE_DIRECT_FIELDS = frozenset(("title", "url", "note", "tags"))
|
|
38
|
+
|
|
39
|
+
_LOCATE_HANDLE_PREFIX = "lh1."
|
|
40
|
+
_LOCATE_HANDLE_MAX_LENGTH = 16384
|
|
41
|
+
_LOCATE_HANDLE_MAX_PAYLOAD = 65536
|
|
42
|
+
_LOCATE_HANDLE_SIGNATURE_BYTES = 16
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def locate_ref(source_id, section_id, item_id, version_id=None):
|
|
46
|
+
"""Return a collision-checked short Canvas ref."""
|
|
47
|
+
return register_ref("b", (source_id, version_id or "", section_id, item_id))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def locate_why(item, terms):
|
|
51
|
+
"""Explain literal field hits using values from the indexed item.
|
|
52
|
+
|
|
53
|
+
Metadata predicates are deliberately excluded here. A ``has_note`` or
|
|
54
|
+
``has_tags`` filter is reported by the response's ``filters`` object; it is
|
|
55
|
+
not evidence that the query term matched the note or tags field.
|
|
56
|
+
"""
|
|
57
|
+
tags = item.get("tags") or []
|
|
58
|
+
if isinstance(tags, (list, tuple)):
|
|
59
|
+
tags = "\n".join(str(value) for value in tags)
|
|
60
|
+
values = {
|
|
61
|
+
"title": item.get("title") or "",
|
|
62
|
+
"note": item.get("note") or "",
|
|
63
|
+
"tags": tags,
|
|
64
|
+
"path": item.get("folder_path") or "",
|
|
65
|
+
"url": item.get("url") or "",
|
|
66
|
+
}
|
|
67
|
+
fields, matched_terms = [], []
|
|
68
|
+
for term in terms:
|
|
69
|
+
term = str(term)
|
|
70
|
+
needle = term.casefold()
|
|
71
|
+
if not needle:
|
|
72
|
+
continue
|
|
73
|
+
if term.isascii():
|
|
74
|
+
pattern = r"(?<![0-9A-Za-z])" + re.escape(term) + r"(?![0-9A-Za-z])"
|
|
75
|
+
term_fields = [field for field, value in values.items()
|
|
76
|
+
if re.search(pattern, str(value), re.I)]
|
|
77
|
+
else:
|
|
78
|
+
term_fields = [field for field, value in values.items()
|
|
79
|
+
if needle in str(value).casefold()]
|
|
80
|
+
if term_fields:
|
|
81
|
+
matched_terms.append(term)
|
|
82
|
+
for field in term_fields:
|
|
83
|
+
if field not in fields:
|
|
84
|
+
fields.append(field)
|
|
85
|
+
weight = max((LOCATE_FIELD_WEIGHTS[field] for field in fields), default=0)
|
|
86
|
+
return {
|
|
87
|
+
"terms": matched_terms,
|
|
88
|
+
"fields": fields,
|
|
89
|
+
"field_weights": {field: LOCATE_FIELD_WEIGHTS[field] for field in fields},
|
|
90
|
+
"weight": weight,
|
|
91
|
+
"match_mode": "literal_boundary" if any(str(term).isascii() for term in terms)
|
|
92
|
+
else "literal_substring",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def locate_query(text):
|
|
97
|
+
"""Keep common Chinese digit-plus-D notation searchable as 3D/2D."""
|
|
98
|
+
digits = {"一": "1", "二": "2", "三": "3", "四": "4", "五": "5",
|
|
99
|
+
"六": "6", "七": "7", "八": "8", "九": "9", "十": "10"}
|
|
100
|
+
return re.sub(r"([一二三四五六七八九十])\s*[dD](?=\b|[^0-9A-Za-z])",
|
|
101
|
+
lambda match: digits[match.group(1)] + "D", text)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def locate_metadata(text):
|
|
105
|
+
"""Extract only unambiguous note/tag language as structured filters."""
|
|
106
|
+
value = text.casefold()
|
|
107
|
+
has_note = bool(re.search(r"(?:有|带|含|包含|还有)\s*(?:些?\s*)?notes?(?![0-9a-z])", value))
|
|
108
|
+
has_tags = bool(re.search(r"(?:有|带|含|包含|还有)\s*(?:些?\s*)?(?:tags?|标签)(?![0-9a-z])", value))
|
|
109
|
+
return has_note, has_tags
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def locate_terms(terms):
|
|
113
|
+
"""Remove conversational terms while preserving index-provided ordering."""
|
|
114
|
+
return [term for term in terms if str(term).casefold() not in LOCATE_STOPWORDS]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def locate_match_mode(terms):
|
|
118
|
+
"""Return the literal matching mode implied by normalized search terms."""
|
|
119
|
+
return "literal_boundary" if any(str(term).isascii() for term in terms) else "literal_substring"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _handle_request(arguments):
|
|
123
|
+
"""Return the query arguments that define a continuation stream."""
|
|
124
|
+
return {key: value for key, value in arguments.items() if key not in ("handle", "offset")}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _handle_key_path(database_path=None):
|
|
128
|
+
"""Return a stable per-database key path shared by CLI and MCP processes."""
|
|
129
|
+
if database_path is not None and str(database_path) != ":memory:":
|
|
130
|
+
database = Path(database_path).expanduser().resolve()
|
|
131
|
+
directory = database.parent
|
|
132
|
+
identity = str(database)
|
|
133
|
+
else:
|
|
134
|
+
selected = os.environ.get("BOOKMARK_RESEARCH_DATA_DIR")
|
|
135
|
+
if selected:
|
|
136
|
+
directory = Path(selected).expanduser().resolve()
|
|
137
|
+
else:
|
|
138
|
+
xdg_home = os.environ.get("XDG_DATA_HOME")
|
|
139
|
+
base = (Path(xdg_home).expanduser()
|
|
140
|
+
if xdg_home and Path(xdg_home).is_absolute()
|
|
141
|
+
else Path.home() / ".local/share")
|
|
142
|
+
directory = (base / "bookmark-research").resolve()
|
|
143
|
+
directory = directory / "continuations"
|
|
144
|
+
identity = str(directory / "index.sqlite3")
|
|
145
|
+
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:20]
|
|
146
|
+
return directory / (".bookmark-research-locate-" + digest + ".key")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _fallback_handle_key_path(primary_path):
|
|
150
|
+
"""Return a per-user temp path for hosts that cannot write beside the index."""
|
|
151
|
+
owner = str(os.getuid()) if hasattr(os, "getuid") else (
|
|
152
|
+
os.environ.get("USERNAME") or os.environ.get("USER") or "default")
|
|
153
|
+
owner_digest = hashlib.sha256(owner.encode("utf-8")).hexdigest()[:12]
|
|
154
|
+
return (Path(tempfile.gettempdir()) / ("bookmark-research-" + owner_digest)
|
|
155
|
+
/ "continuations" / primary_path.name)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _load_or_create_handle_key(path):
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
160
|
+
if path.is_symlink():
|
|
161
|
+
raise ValueError("locate continuation key must not be a symbolic link")
|
|
162
|
+
if not path.exists():
|
|
163
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".locate-key-", dir=str(path.parent))
|
|
164
|
+
try:
|
|
165
|
+
key = os.urandom(32)
|
|
166
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
167
|
+
stream.write(key)
|
|
168
|
+
stream.flush()
|
|
169
|
+
os.fsync(stream.fileno())
|
|
170
|
+
try:
|
|
171
|
+
# Exclusive hard-link creation prevents concurrent callers
|
|
172
|
+
# from replacing the key and invalidating each other's handles.
|
|
173
|
+
os.link(temporary, str(path))
|
|
174
|
+
except FileExistsError:
|
|
175
|
+
pass
|
|
176
|
+
finally:
|
|
177
|
+
try:
|
|
178
|
+
os.unlink(temporary)
|
|
179
|
+
except FileNotFoundError:
|
|
180
|
+
pass
|
|
181
|
+
if path.is_symlink():
|
|
182
|
+
raise ValueError("locate continuation key must not be a symbolic link")
|
|
183
|
+
try:
|
|
184
|
+
os.chmod(path, 0o600)
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
key = path.read_bytes()
|
|
188
|
+
if len(key) != 32:
|
|
189
|
+
raise ValueError("locate continuation signing key is invalid")
|
|
190
|
+
return key
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _handle_key(database_path=None):
|
|
194
|
+
"""Load or atomically create a signing key, with a writable temp fallback."""
|
|
195
|
+
primary = _handle_key_path(database_path)
|
|
196
|
+
fallback = _fallback_handle_key_path(primary)
|
|
197
|
+
try:
|
|
198
|
+
return _load_or_create_handle_key(primary)
|
|
199
|
+
except OSError:
|
|
200
|
+
if fallback == primary:
|
|
201
|
+
raise
|
|
202
|
+
return _load_or_create_handle_key(fallback)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _b64encode(value):
|
|
206
|
+
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _b64decode(value):
|
|
210
|
+
if not isinstance(value, str) or re.fullmatch(r"[A-Za-z0-9_-]+", value) is None:
|
|
211
|
+
raise ValueError("invalid locate continuation encoding")
|
|
212
|
+
decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
213
|
+
if _b64encode(decoded) != value:
|
|
214
|
+
raise ValueError("non-canonical locate continuation encoding")
|
|
215
|
+
return decoded
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _remember_handle(arguments, database_path=None):
|
|
219
|
+
"""Encode query and filters into a signed, cross-process continuation handle."""
|
|
220
|
+
body = {"v": 1, "request": _handle_request(arguments)}
|
|
221
|
+
payload = json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
222
|
+
if len(payload) > _LOCATE_HANDLE_MAX_PAYLOAD:
|
|
223
|
+
raise ValueError("locate continuation arguments exceed the handle payload limit")
|
|
224
|
+
encoded = _b64encode(zlib.compress(payload, level=9))
|
|
225
|
+
signed = (_LOCATE_HANDLE_PREFIX + encoded).encode("ascii")
|
|
226
|
+
signature = hmac.new(_handle_key(database_path), signed, hashlib.sha256).digest()[:_LOCATE_HANDLE_SIGNATURE_BYTES]
|
|
227
|
+
handle = _LOCATE_HANDLE_PREFIX + encoded + "." + _b64encode(signature)
|
|
228
|
+
if len(handle) > _LOCATE_HANDLE_MAX_LENGTH:
|
|
229
|
+
raise ValueError("locate continuation handle exceeds the maximum length")
|
|
230
|
+
return handle
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _resolve_handle(handle, database_path=None):
|
|
234
|
+
"""Verify and decode a stateless continuation handle."""
|
|
235
|
+
if not isinstance(handle, str) or len(handle) > _LOCATE_HANDLE_MAX_LENGTH:
|
|
236
|
+
return None
|
|
237
|
+
try:
|
|
238
|
+
prefix, encoded, supplied_signature = handle.split(".", 2)
|
|
239
|
+
if prefix != "lh1" or not encoded or not supplied_signature:
|
|
240
|
+
return None
|
|
241
|
+
signed = (prefix + "." + encoded).encode("ascii")
|
|
242
|
+
expected_signature = hmac.new(
|
|
243
|
+
_handle_key(database_path), signed, hashlib.sha256).digest()[:_LOCATE_HANDLE_SIGNATURE_BYTES]
|
|
244
|
+
if not hmac.compare_digest(expected_signature, _b64decode(supplied_signature)):
|
|
245
|
+
return None
|
|
246
|
+
decoder = zlib.decompressobj()
|
|
247
|
+
payload = decoder.decompress(_b64decode(encoded), _LOCATE_HANDLE_MAX_PAYLOAD + 1)
|
|
248
|
+
if (len(payload) > _LOCATE_HANDLE_MAX_PAYLOAD or decoder.unconsumed_tail
|
|
249
|
+
or decoder.unused_data or not decoder.eof):
|
|
250
|
+
return None
|
|
251
|
+
body = json.loads(payload.decode("utf-8"))
|
|
252
|
+
if not isinstance(body, dict) or body.get("v") != 1 or not isinstance(body.get("request"), dict):
|
|
253
|
+
return None
|
|
254
|
+
return body["request"]
|
|
255
|
+
except (OSError, ValueError, TypeError, UnicodeError, zlib.error, json.JSONDecodeError):
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def continuation_scope(handle, database_path=None):
|
|
260
|
+
"""Return a continuation's original scope for transport adapters."""
|
|
261
|
+
saved = _resolve_handle(handle, database_path) if handle else None
|
|
262
|
+
request = saved or {}
|
|
263
|
+
scope = request.get("in", ["canvas"])
|
|
264
|
+
return scope if isinstance(scope, list) else ["canvas"]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def continuation_refresh_requested(handle, database_path=None):
|
|
268
|
+
"""Return whether a continuation's original query requires writable refresh."""
|
|
269
|
+
saved = _resolve_handle(handle, database_path) if handle else None
|
|
270
|
+
return bool(saved and saved.get("refresh"))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _query_terms(text):
|
|
274
|
+
"""Get conservative display terms for non-Canvas literal evidence."""
|
|
275
|
+
return [part for part in re.findall(r"[A-Za-z0-9][A-Za-z0-9_.-]*|[\u4e00-\u9fff]", text)
|
|
276
|
+
if part.casefold() not in LOCATE_STOPWORDS]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _why_for_result(row, terms, fields):
|
|
280
|
+
values = {field: str(row.get(field) or "") for field in fields}
|
|
281
|
+
matched, matched_fields = [], []
|
|
282
|
+
for term in terms:
|
|
283
|
+
found = [field for field, value in values.items() if term.casefold() in value.casefold()]
|
|
284
|
+
if found:
|
|
285
|
+
matched.append(term)
|
|
286
|
+
for field in found:
|
|
287
|
+
if field not in matched_fields:
|
|
288
|
+
matched_fields.append(field)
|
|
289
|
+
return {"terms": matched, "fields": matched_fields, "match_mode": "literal_substring"}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _locate_external(scope, text, url, offset, limit, wiki=None, raw=None):
|
|
293
|
+
if scope == "wiki":
|
|
294
|
+
if url is not None:
|
|
295
|
+
raise ValueError("locate wiki scope accepts text, not url")
|
|
296
|
+
if wiki is None:
|
|
297
|
+
raise ValueError("wiki locate adapter is unavailable")
|
|
298
|
+
payload = wiki.search(text, offset=offset, limit=limit)
|
|
299
|
+
terms = _query_terms(text)
|
|
300
|
+
hits = []
|
|
301
|
+
for row in payload.get("results", []):
|
|
302
|
+
page_id = row.get("page_id")
|
|
303
|
+
revision = row.get("revision")
|
|
304
|
+
if not page_id:
|
|
305
|
+
continue
|
|
306
|
+
evidence = {"ref": register_ref("w", (page_id, revision)),
|
|
307
|
+
"source_kind": "wiki", "source_id": page_id,
|
|
308
|
+
"wiki_revision": revision, "evidence_kind": "wiki_revision",
|
|
309
|
+
"title": row.get("title") or "", "snippet": row.get("snippet") or "",
|
|
310
|
+
"why": _why_for_result(row, terms, ("title", "snippet"))}
|
|
311
|
+
validation = row.get("validation") or {}
|
|
312
|
+
if validation:
|
|
313
|
+
evidence["validation"] = validation
|
|
314
|
+
if validation.get("status"):
|
|
315
|
+
evidence["content_state"] = ("usable" if validation["status"] == "ok"
|
|
316
|
+
else validation["status"])
|
|
317
|
+
hits.append(evidence)
|
|
318
|
+
total = payload.get("total", len(hits))
|
|
319
|
+
next_offset = payload.get("next_offset")
|
|
320
|
+
return hits, total, next_offset, terms
|
|
321
|
+
if raw is None:
|
|
322
|
+
raise ValueError("archive locate adapter is unavailable")
|
|
323
|
+
query = text or url
|
|
324
|
+
payload = raw.search(query, limit=limit, offset=offset, url=url)
|
|
325
|
+
terms = _query_terms(query)
|
|
326
|
+
hits = []
|
|
327
|
+
for row in payload.get("results", []):
|
|
328
|
+
occurrences = row.get("occurrences") or []
|
|
329
|
+
capture_id = next((item.get("capture_id") for item in occurrences if item.get("capture_id")), None)
|
|
330
|
+
archive_key = capture_id or row.get("sha256") or row.get("url")
|
|
331
|
+
if not archive_key:
|
|
332
|
+
continue
|
|
333
|
+
evidence = {"ref": register_ref("a", (capture_id or "", row.get("sha256") or "",
|
|
334
|
+
row.get("url") or "")), "source_kind": "archive",
|
|
335
|
+
"source_id": row.get("url") or row.get("sha256"),
|
|
336
|
+
"evidence_kind": "archive_capture", "title": row.get("title") or "",
|
|
337
|
+
"url": row.get("url") or "", "snippet": row.get("snippet") or "",
|
|
338
|
+
"sha256": row.get("sha256"),
|
|
339
|
+
"capture_id": capture_id,
|
|
340
|
+
"content_state": "usable" if row.get("snippet") else "content_insufficient",
|
|
341
|
+
"why": _why_for_result(row, terms, ("title", "snippet", "url"))}
|
|
342
|
+
hits.append({key: value for key, value in evidence.items() if value is not None})
|
|
343
|
+
total = payload.get("total", len(hits))
|
|
344
|
+
next_offset = payload.get("next_offset")
|
|
345
|
+
return hits, total, next_offset, terms
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def locate(index, source_manager, arguments, wiki=None, raw=None):
|
|
349
|
+
"""Run the P0 Canvas locate operation against an indexed source.
|
|
350
|
+
|
|
351
|
+
``source_manager`` is passed in by the host so this module remains
|
|
352
|
+
transport-independent. Continuation handles contain a signed, compressed
|
|
353
|
+
copy of the original query and filters, so page traversal works across CLI
|
|
354
|
+
invocations and MCP restarts.
|
|
355
|
+
"""
|
|
356
|
+
supplied = dict(arguments)
|
|
357
|
+
handle = supplied.get("handle")
|
|
358
|
+
offset = supplied.get("offset", 0)
|
|
359
|
+
if type(offset) is not int or offset < 0:
|
|
360
|
+
raise ValueError("offset must be a non-negative integer")
|
|
361
|
+
if handle is not None:
|
|
362
|
+
database_path = getattr(index, "db_path", None)
|
|
363
|
+
saved = _resolve_handle(handle, database_path)
|
|
364
|
+
if saved is None:
|
|
365
|
+
raise ValueError("unknown locate continuation handle")
|
|
366
|
+
# A handle owns the original query and filters. Only offset may vary
|
|
367
|
+
# on a continuation request, preventing accidental cross-query reads.
|
|
368
|
+
supplied = dict(saved, offset=offset, handle=handle)
|
|
369
|
+
request = _handle_request(supplied)
|
|
370
|
+
database_path = getattr(index, "db_path", None)
|
|
371
|
+
text = request.get("text")
|
|
372
|
+
url = request.get("url")
|
|
373
|
+
requested = request.get("in", ["canvas"])
|
|
374
|
+
if not isinstance(requested, list) or not requested or any(scope not in ("canvas", "wiki", "archive") for scope in requested):
|
|
375
|
+
raise ValueError('locate in must contain canvas, wiki or archive')
|
|
376
|
+
if len(requested) != 1:
|
|
377
|
+
raise ValueError("locate currently requires exactly one scope in")
|
|
378
|
+
source_id = request.get("source_id")
|
|
379
|
+
section = request.get("section")
|
|
380
|
+
folder_id = request.get("folder_id")
|
|
381
|
+
tags = request.get("tags")
|
|
382
|
+
tag_colors = request.get("tag_colors")
|
|
383
|
+
has_note = request.get("has_note")
|
|
384
|
+
has_tags = request.get("has_tags")
|
|
385
|
+
folder_listing = text is None and url is None and folder_id is not None
|
|
386
|
+
# A metadata filter is a query in its own right: "show me the bookmarks with
|
|
387
|
+
# a note" must not require inventing a search term. Only a request with no
|
|
388
|
+
# text, URL, folder and no metadata filter is ambiguous.
|
|
389
|
+
metadata_only = (text is None and url is None and folder_id is None
|
|
390
|
+
and any(value is not None for value in (tags, tag_colors, has_note, has_tags)))
|
|
391
|
+
if not folder_listing and not metadata_only and (text is None) == (url is None):
|
|
392
|
+
raise ValueError("locate requires exactly one of text or url, folder_id, or a metadata filter (tags/has_note/has_tags)")
|
|
393
|
+
metadata_match = request.get("metadata_match", "all")
|
|
394
|
+
limit = request.get("limit", 8)
|
|
395
|
+
refresh = request.get("refresh", False)
|
|
396
|
+
if requested[0] != "canvas":
|
|
397
|
+
if folder_listing:
|
|
398
|
+
raise ValueError("folder_id-only locate is supported for canvas scope")
|
|
399
|
+
unsupported = ("source_id", "section", "folder_id", "tags", "tag_colors",
|
|
400
|
+
"has_note", "has_tags", "metadata_match")
|
|
401
|
+
if any(request.get(key) is not None and not (key == "metadata_match" and request.get(key) == "all")
|
|
402
|
+
for key in unsupported):
|
|
403
|
+
raise ValueError("metadata filters are only supported for canvas locate")
|
|
404
|
+
if refresh:
|
|
405
|
+
raise ValueError("refresh is only supported for canvas locate")
|
|
406
|
+
hits, total_hits, next_offset, terms = _locate_external(
|
|
407
|
+
requested[0], text, url, offset, limit, wiki=wiki, raw=raw)
|
|
408
|
+
result = {"hits": hits, "total": total_hits, "in": requested,
|
|
409
|
+
"match_mode": "literal_substring", "source_ids": [],
|
|
410
|
+
"candidates": [], "filters": {}}
|
|
411
|
+
if text is not None:
|
|
412
|
+
result["query"] = text
|
|
413
|
+
else:
|
|
414
|
+
result["url"] = url
|
|
415
|
+
if next_offset is not None and next_offset < total_hits:
|
|
416
|
+
continuation = _remember_handle(request, database_path)
|
|
417
|
+
result["more"] = {"handle": continuation, "offset": next_offset, "total": total_hits}
|
|
418
|
+
result["next"] = {"tool": "locate", "handle": continuation,
|
|
419
|
+
"offset": next_offset, "reason": "continue_results"}
|
|
420
|
+
return result
|
|
421
|
+
if source_id is None:
|
|
422
|
+
source_ids = source_manager.live_sources()
|
|
423
|
+
if len(source_ids) != 1:
|
|
424
|
+
raise ValueError("source_required: locate requires source_id when the index has zero or multiple live sources")
|
|
425
|
+
else:
|
|
426
|
+
source_ids = [index.resolve_source_id(source_id)]
|
|
427
|
+
source_ids = sorted(dict.fromkeys(source_ids))
|
|
428
|
+
statuses = {}
|
|
429
|
+
for identifier in source_ids:
|
|
430
|
+
update = source_manager.refresh(identifier) if refresh else None
|
|
431
|
+
if update and update.get("state") in ("error", "unavailable"):
|
|
432
|
+
raise ValueError(update.get("error", "source is unavailable") + "; use refresh=false to read the saved index")
|
|
433
|
+
statuses[identifier] = source_manager.source_status(identifier)
|
|
434
|
+
|
|
435
|
+
hits, seen, candidates = [], set(), []
|
|
436
|
+
folder_candidates = []
|
|
437
|
+
folder_rows, hint_rows = {}, {}
|
|
438
|
+
terms_by_source = {}
|
|
439
|
+
text_match_mode = "literal_substring"
|
|
440
|
+
query = locate_query(text) if text is not None else None
|
|
441
|
+
inferred_note, inferred_tags = locate_metadata(query) if query is not None else (False, False)
|
|
442
|
+
if has_note is None and inferred_note:
|
|
443
|
+
has_note = True
|
|
444
|
+
if has_tags is None and inferred_tags:
|
|
445
|
+
has_tags = True
|
|
446
|
+
filters = {"section": section, "folder_id": folder_id, "tags": tags,
|
|
447
|
+
"tag_colors": tag_colors, "has_note": has_note, "has_tags": has_tags,
|
|
448
|
+
"metadata_match": metadata_match}
|
|
449
|
+
has_filters = any(value is not None for key, value in filters.items()
|
|
450
|
+
if key != "metadata_match") or metadata_match != "all"
|
|
451
|
+
metadata_requested = any(value is not None for key, value in filters.items()
|
|
452
|
+
if key in ("tags", "tag_colors", "has_note", "has_tags"))
|
|
453
|
+
with index.read_snapshot():
|
|
454
|
+
if folder_listing:
|
|
455
|
+
identifier = source_ids[0]
|
|
456
|
+
found = index.folder_children(
|
|
457
|
+
identifier, folder_id, section=section, tags=tags, tag_colors=tag_colors,
|
|
458
|
+
has_note=has_note, has_tags=has_tags, metadata_match=metadata_match,
|
|
459
|
+
limit=limit, offset=offset)
|
|
460
|
+
version = statuses[identifier].get("version_id")
|
|
461
|
+
parent = found["folder"]
|
|
462
|
+
parent_ref = locate_ref(identifier, parent["section_id"], folder_id, version)
|
|
463
|
+
folder = {"ref": parent_ref, "folder_id": folder_id,
|
|
464
|
+
"section_id": parent["section_id"], "title": parent.get("title") or "",
|
|
465
|
+
"path": list(parent.get("path") or []) +
|
|
466
|
+
([parent["title"]] if parent.get("title") else [])}
|
|
467
|
+
if version is not None:
|
|
468
|
+
folder["version_id"] = version
|
|
469
|
+
page_hits = []
|
|
470
|
+
for child in found["results"]:
|
|
471
|
+
hit = {"ref": locate_ref(identifier, child["section_id"], child["item_id"], version),
|
|
472
|
+
"kind": child["item_type"], "source_kind": "canvas", "source_id": identifier,
|
|
473
|
+
"evidence_kind": "folder_child", "section_id": child["section_id"],
|
|
474
|
+
"item_id": child["item_id"], "parent_id": child.get("parent_id"),
|
|
475
|
+
"title": child.get("title") or "",
|
|
476
|
+
"path": list(child.get("path") or []) +
|
|
477
|
+
([child["title"]] if child.get("title") else []),
|
|
478
|
+
"position": child.get("position")}
|
|
479
|
+
if child.get("url"):
|
|
480
|
+
hit["url"] = child["url"]
|
|
481
|
+
if child.get("note"):
|
|
482
|
+
hit["note"] = child["note"]
|
|
483
|
+
if child.get("note_color"):
|
|
484
|
+
hit["note_color"] = child["note_color"]
|
|
485
|
+
if child.get("tags"):
|
|
486
|
+
hit["tags"] = child["tags"]
|
|
487
|
+
if version is not None:
|
|
488
|
+
hit["version_id"] = version
|
|
489
|
+
page_hits.append(hit)
|
|
490
|
+
total_hits = found["total"]
|
|
491
|
+
coverage = {"matched": total_hits, "shown": len(page_hits),
|
|
492
|
+
"hidden": max(0, total_hits - len(page_hits))}
|
|
493
|
+
result = {"hits": page_hits, "total": total_hits, "in": ["canvas"],
|
|
494
|
+
"match_mode": "folder_children", "source_ids": source_ids,
|
|
495
|
+
"folder": folder, "coverage": coverage,
|
|
496
|
+
"filters": {key: value for key, value in filters.items()
|
|
497
|
+
if value is not None and not (key == "metadata_match" and value == "all")}}
|
|
498
|
+
next_offset = offset + len(page_hits)
|
|
499
|
+
next_message = ("%d direct children; read a child ref for its full context" % total_hits
|
|
500
|
+
if total_hits else "No direct children match this folder scope")
|
|
501
|
+
if next_offset < total_hits:
|
|
502
|
+
continuation = _remember_handle(request, database_path)
|
|
503
|
+
result["more"] = {"handle": continuation, "offset": next_offset, "total": total_hits}
|
|
504
|
+
result["next"] = {"tool": "locate", "handle": continuation,
|
|
505
|
+
"offset": next_offset, "reason": "continue_results",
|
|
506
|
+
"message": next_message}
|
|
507
|
+
else:
|
|
508
|
+
result["next"] = next_message
|
|
509
|
+
return result
|
|
510
|
+
if metadata_only:
|
|
511
|
+
# No term, no URL, no folder: the filters are the query. Ranking is
|
|
512
|
+
# recency so a "what did I note recently" question gets a useful page.
|
|
513
|
+
identifier = source_ids[0]
|
|
514
|
+
found = index.search(identifier, targets=[], section=section, folder_id=folder_id,
|
|
515
|
+
tags=tags, tag_colors=tag_colors, limit=limit, offset=offset,
|
|
516
|
+
has_note=has_note, has_tags=has_tags,
|
|
517
|
+
metadata_match=metadata_match, sort_by="added_at_desc")
|
|
518
|
+
version = statuses[identifier].get("version_id")
|
|
519
|
+
page_hits = []
|
|
520
|
+
for item in found["results"]:
|
|
521
|
+
hit = {"ref": locate_ref(identifier, item["section_id"], item["item_id"], version),
|
|
522
|
+
"source_kind": "canvas", "kind": item["item_type"],
|
|
523
|
+
"source_id": identifier, "evidence_kind": "bookmark_instance",
|
|
524
|
+
"section_id": item["section_id"], "item_id": item["item_id"],
|
|
525
|
+
"parent_id": item.get("parent_id"),
|
|
526
|
+
"title": item.get("title") or "",
|
|
527
|
+
"path": list(item.get("path") or []) + ([item["title"]] if item.get("title") else []),
|
|
528
|
+
"position": item.get("position"),
|
|
529
|
+
"why": {"terms": [], "fields": [], "field_weights": {}, "weight": 0,
|
|
530
|
+
"match_mode": "metadata"}}
|
|
531
|
+
if item.get("url"):
|
|
532
|
+
hit["url"] = item["url"]
|
|
533
|
+
if item.get("note"):
|
|
534
|
+
hit["note"] = item["note"]
|
|
535
|
+
if item.get("note_color"):
|
|
536
|
+
hit["note_color"] = item["note_color"]
|
|
537
|
+
if item.get("tags"):
|
|
538
|
+
hit["tags"] = item["tags"]
|
|
539
|
+
if version is not None:
|
|
540
|
+
hit["version_id"] = version
|
|
541
|
+
page_hits.append(hit)
|
|
542
|
+
total_hits = found["total"]
|
|
543
|
+
shown = offset + len(page_hits)
|
|
544
|
+
result = {"hits": page_hits, "total": total_hits, "in": ["canvas"],
|
|
545
|
+
"match_mode": "metadata", "source_ids": source_ids,
|
|
546
|
+
"filters": {key: value for key, value in filters.items()
|
|
547
|
+
if value is not None and not (key == "metadata_match" and value == "all")},
|
|
548
|
+
"coverage": {"matched": total_hits, "shown": len(page_hits),
|
|
549
|
+
"hidden": max(0, total_hits - shown)}}
|
|
550
|
+
next_message = ("%d bookmarks match the metadata filter; read a hit ref for its card"
|
|
551
|
+
% total_hits if total_hits else "No bookmark matches these metadata filters")
|
|
552
|
+
if shown < total_hits:
|
|
553
|
+
continuation = _remember_handle(request, database_path)
|
|
554
|
+
result["more"] = {"handle": continuation, "offset": shown, "total": total_hits}
|
|
555
|
+
result["next"] = {"tool": "locate", "handle": continuation,
|
|
556
|
+
"offset": shown, "reason": "continue_results",
|
|
557
|
+
"message": next_message}
|
|
558
|
+
else:
|
|
559
|
+
result["next"] = next_message
|
|
560
|
+
return result
|
|
561
|
+
if url is not None:
|
|
562
|
+
for identifier in source_ids:
|
|
563
|
+
if has_filters:
|
|
564
|
+
found = index.search(identifier, targets=[url], section=section, folder_id=folder_id,
|
|
565
|
+
tags=tags, tag_colors=tag_colors, limit=1000, offset=0,
|
|
566
|
+
score=True, has_note=has_note, has_tags=has_tags,
|
|
567
|
+
metadata_match=metadata_match)
|
|
568
|
+
if found["total"] > len(found["results"]):
|
|
569
|
+
raise ValueError("locate URL result exceeds the bounded page; narrow the URL or use the P0b continuation handle")
|
|
570
|
+
instances = [item for item in found["results"] if item.get("url") == url]
|
|
571
|
+
else:
|
|
572
|
+
found = index.lookup_url(url, source_id=identifier, limit=1000, offset=0)
|
|
573
|
+
if found["total_instances"] > len(found["instances"]):
|
|
574
|
+
raise ValueError("locate URL result exceeds the bounded page; narrow the URL or use the P0b continuation handle")
|
|
575
|
+
instances = found["instances"]
|
|
576
|
+
for item in instances:
|
|
577
|
+
key = (identifier, item["section_id"], item["item_id"])
|
|
578
|
+
if key in seen:
|
|
579
|
+
continue
|
|
580
|
+
seen.add(key)
|
|
581
|
+
version = statuses[identifier].get("version_id")
|
|
582
|
+
hit = {"ref": locate_ref(*key, version), "source_kind": "canvas",
|
|
583
|
+
"kind": "bookmark",
|
|
584
|
+
"source_id": identifier, "evidence_kind": "bookmark_instance",
|
|
585
|
+
"section_id": item["section_id"], "item_id": item["item_id"],
|
|
586
|
+
"title": item.get("title") or "", "url": item.get("url") or "",
|
|
587
|
+
"why": {"terms": [url], "fields": ["url"],
|
|
588
|
+
"field_weights": {"url": LOCATE_FIELD_WEIGHTS["url"]},
|
|
589
|
+
"weight": LOCATE_FIELD_WEIGHTS["url"], "match_mode": "exact"}}
|
|
590
|
+
if version is not None:
|
|
591
|
+
hit["version_id"] = version
|
|
592
|
+
hits.append(hit)
|
|
593
|
+
else:
|
|
594
|
+
for identifier in source_ids:
|
|
595
|
+
scope = index.plan_scope(identifier, query, max_cards=3, max_urls=8)
|
|
596
|
+
terms = locate_terms(scope.get("terms", []))
|
|
597
|
+
terms_by_source[identifier] = terms
|
|
598
|
+
if locate_match_mode(terms) == "literal_boundary":
|
|
599
|
+
text_match_mode = "literal_boundary"
|
|
600
|
+
candidates.append({
|
|
601
|
+
"source_id": identifier,
|
|
602
|
+
"terms": terms,
|
|
603
|
+
"cards": [{"section_id": card.get("section_id"),
|
|
604
|
+
"matched_terms": locate_terms(card.get("matched_terms", []))}
|
|
605
|
+
for card in scope.get("cards", [])[:3]],
|
|
606
|
+
"urls": [{"url": row.get("url"),
|
|
607
|
+
"matched_terms": locate_terms(row.get("matched_terms", []))}
|
|
608
|
+
for row in scope.get("urls", [])[:3]],
|
|
609
|
+
})
|
|
610
|
+
term_cards = scope.get("term_cards", {})
|
|
611
|
+
card_ranks = {card.get("section_id"): rank for rank, card in enumerate(scope.get("cards", []))}
|
|
612
|
+
for card in scope.get("cards", []):
|
|
613
|
+
result_section = section or card["section_id"]
|
|
614
|
+
targets = locate_terms(card.get("matched_terms", [])) or terms
|
|
615
|
+
found = index.search(identifier, targets=targets, section=result_section,
|
|
616
|
+
folder_id=folder_id, tags=tags, tag_colors=tag_colors,
|
|
617
|
+
limit=1000, offset=0, score=True, has_note=has_note,
|
|
618
|
+
has_tags=has_tags, metadata_match=metadata_match)
|
|
619
|
+
if found["total"] > len(found["results"]):
|
|
620
|
+
raise ValueError("locate text result exceeds the bounded page; narrow the query or use the P0b continuation handle")
|
|
621
|
+
for item in found["results"]:
|
|
622
|
+
key = (identifier, item["section_id"], item["item_id"])
|
|
623
|
+
if key in seen:
|
|
624
|
+
continue
|
|
625
|
+
why = locate_why(item, terms)
|
|
626
|
+
why["match_mode"] = text_match_mode
|
|
627
|
+
if not why["terms"] and not metadata_requested:
|
|
628
|
+
continue
|
|
629
|
+
seen.add(key)
|
|
630
|
+
rarity = sum(1.0 / max(term_cards.get(term, 1), 1)
|
|
631
|
+
for term in why["terms"])
|
|
632
|
+
version = statuses[identifier].get("version_id")
|
|
633
|
+
hit = {"ref": locate_ref(*key, version), "source_kind": "canvas",
|
|
634
|
+
"kind": "bookmark",
|
|
635
|
+
"source_id": identifier, "evidence_kind": "bookmark_instance",
|
|
636
|
+
"section_id": item["section_id"], "item_id": item["item_id"],
|
|
637
|
+
"title": item.get("title") or "", "url": item.get("url") or "",
|
|
638
|
+
"why": why}
|
|
639
|
+
hit["_locate_weight"] = why["weight"]
|
|
640
|
+
hit["_locate_rarity"] = rarity
|
|
641
|
+
hit["_locate_card_rank"] = card_ranks.get(item["section_id"], len(card_ranks))
|
|
642
|
+
hit["_locate_parent_id"] = item.get("parent_id")
|
|
643
|
+
if version is not None:
|
|
644
|
+
hit["version_id"] = version
|
|
645
|
+
hits.append(hit)
|
|
646
|
+
if text is not None and folder_id is None:
|
|
647
|
+
# "Where is the 2D / 3D folder?" is answered by the folder itself.
|
|
648
|
+
# Bookmark hits stay first (existing ranking contract); the folder is
|
|
649
|
+
# reported alongside them so the caller does not have to guess that
|
|
650
|
+
# folder_id can list its children.
|
|
651
|
+
for identifier in source_ids:
|
|
652
|
+
folder_terms = terms_by_source.get(identifier) or []
|
|
653
|
+
if not folder_terms:
|
|
654
|
+
continue
|
|
655
|
+
found_folders = index.search(identifier, targets=folder_terms, section=section,
|
|
656
|
+
item_types=["folder"], limit=100, offset=0)
|
|
657
|
+
for folder in found_folders["results"]:
|
|
658
|
+
why = locate_why(folder, folder_terms)
|
|
659
|
+
if len(why["terms"]) != len(folder_terms):
|
|
660
|
+
continue
|
|
661
|
+
version = statuses[identifier].get("version_id")
|
|
662
|
+
folder_candidates.append({
|
|
663
|
+
"ref": locate_ref(identifier, folder["section_id"], folder["item_id"], version),
|
|
664
|
+
"folder_id": folder["item_id"], "source_id": identifier,
|
|
665
|
+
"section_id": folder["section_id"],
|
|
666
|
+
"title": folder.get("title") or "",
|
|
667
|
+
"path": list(folder.get("path") or []) +
|
|
668
|
+
([folder["title"]] if folder.get("title") else [])})
|
|
669
|
+
if url is None:
|
|
670
|
+
for hit in hits:
|
|
671
|
+
if set(hit["why"]["fields"]) != {"path"}:
|
|
672
|
+
continue
|
|
673
|
+
scope_key = (hit["source_id"], hit["section_id"])
|
|
674
|
+
if scope_key in folder_rows:
|
|
675
|
+
continue
|
|
676
|
+
found_folders = index.search(
|
|
677
|
+
hit["source_id"], section=hit["section_id"], folder_id=folder_id,
|
|
678
|
+
item_types=["folder"], limit=1000, offset=0)
|
|
679
|
+
if found_folders["total"] > len(found_folders["results"]):
|
|
680
|
+
raise ValueError("locate folder inventory exceeds the bounded page; narrow the section")
|
|
681
|
+
folder_rows[scope_key] = {row["item_id"]: row for row in found_folders["results"]}
|
|
682
|
+
if not hits:
|
|
683
|
+
for identifier in source_ids:
|
|
684
|
+
for term in terms_by_source.get(identifier, []):
|
|
685
|
+
found_folders = index.search(
|
|
686
|
+
identifier, targets=[term], section=section, folder_id=folder_id,
|
|
687
|
+
item_types=["folder"], limit=1000, offset=0)
|
|
688
|
+
if found_folders["total"] > len(found_folders["results"]):
|
|
689
|
+
continue
|
|
690
|
+
for folder in found_folders["results"]:
|
|
691
|
+
if not locate_why(folder, [term])["terms"]:
|
|
692
|
+
continue
|
|
693
|
+
hint_key = (term, identifier, folder["section_id"], folder["item_id"])
|
|
694
|
+
hint_rows[hint_key] = {
|
|
695
|
+
"folder_id": folder["item_id"], "ref": locate_ref(
|
|
696
|
+
identifier, folder["section_id"], folder["item_id"],
|
|
697
|
+
statuses[identifier].get("version_id")),
|
|
698
|
+
"title": folder.get("title") or "",
|
|
699
|
+
"path": list(folder.get("path") or []) +
|
|
700
|
+
([folder["title"]] if folder.get("title") else []),
|
|
701
|
+
}
|
|
702
|
+
hits.sort(key=lambda hit: (-hit.pop("_locate_weight", 0),
|
|
703
|
+
-hit.pop("_locate_rarity", 0.0),
|
|
704
|
+
hit.pop("_locate_card_rank", 0), hit["source_id"],
|
|
705
|
+
hit["section_id"], hit["item_id"]))
|
|
706
|
+
direct_hits, inherited_hits, unclassified_hits = [], [], []
|
|
707
|
+
for hit in hits:
|
|
708
|
+
fields = set(hit["why"]["fields"])
|
|
709
|
+
if fields.intersection(LOCATE_DIRECT_FIELDS):
|
|
710
|
+
direct_hits.append(hit)
|
|
711
|
+
elif fields == {"path"}:
|
|
712
|
+
inherited_hits.append(hit)
|
|
713
|
+
else:
|
|
714
|
+
unclassified_hits.append(hit)
|
|
715
|
+
direct_hits.extend(unclassified_hits)
|
|
716
|
+
|
|
717
|
+
folder_groups = {}
|
|
718
|
+
unresolved_inherited = []
|
|
719
|
+
for hit in inherited_hits:
|
|
720
|
+
folders = folder_rows[(hit["source_id"], hit["section_id"])]
|
|
721
|
+
path_terms = hit["why"]["terms"]
|
|
722
|
+
current = folders.get(hit.get("_locate_parent_id"))
|
|
723
|
+
nearest = None
|
|
724
|
+
while current is not None:
|
|
725
|
+
folder_why = locate_why(current, path_terms)
|
|
726
|
+
if len(folder_why["terms"]) == len(path_terms):
|
|
727
|
+
nearest = current
|
|
728
|
+
break
|
|
729
|
+
current = folders.get(current.get("parent_id"))
|
|
730
|
+
if nearest is None and folder_id is not None:
|
|
731
|
+
nearest = folders.get(folder_id)
|
|
732
|
+
if nearest is None:
|
|
733
|
+
unresolved_inherited.append(hit)
|
|
734
|
+
continue
|
|
735
|
+
key = (hit["source_id"], hit["section_id"], nearest["item_id"])
|
|
736
|
+
group = folder_groups.setdefault(key, {"folder": nearest, "members": [], "terms": []})
|
|
737
|
+
group["members"].append(hit)
|
|
738
|
+
group["terms"] = list(dict.fromkeys(group["terms"] + path_terms))
|
|
739
|
+
|
|
740
|
+
folder_hits = []
|
|
741
|
+
for (identifier, section_id, folder_id), group in folder_groups.items():
|
|
742
|
+
folder = group["folder"]
|
|
743
|
+
version = statuses[identifier].get("version_id")
|
|
744
|
+
sample = []
|
|
745
|
+
for member in group["members"][:3]:
|
|
746
|
+
member.pop("_locate_parent_id", None)
|
|
747
|
+
sample.append({key: member[key] for key in
|
|
748
|
+
("ref", "source_id", "section_id", "item_id", "title", "url", "why")})
|
|
749
|
+
folder_hit = {
|
|
750
|
+
"kind": "folder", "ref": locate_ref(identifier, section_id, folder_id, version),
|
|
751
|
+
"source_kind": "canvas", "source_id": identifier,
|
|
752
|
+
"evidence_kind": "inherited_matches", "section_id": section_id,
|
|
753
|
+
"folder_id": folder_id, "item_id": folder_id,
|
|
754
|
+
"title": folder.get("title") or "",
|
|
755
|
+
"path": list(folder.get("path") or []) + ([folder["title"]] if folder.get("title") else []),
|
|
756
|
+
"count": len(group["members"]), "sample": sample,
|
|
757
|
+
"why": {"terms": group["terms"], "fields": ["path"],
|
|
758
|
+
"field_weights": {"path": LOCATE_FIELD_WEIGHTS["path"]},
|
|
759
|
+
"weight": LOCATE_FIELD_WEIGHTS["path"], "match_mode": text_match_mode},
|
|
760
|
+
}
|
|
761
|
+
if version is not None:
|
|
762
|
+
folder_hit["version_id"] = version
|
|
763
|
+
folder_hits.append(folder_hit)
|
|
764
|
+
folder_hits.sort(key=lambda hit: (hit["count"], hit["source_id"], hit["section_id"], hit["folder_id"]))
|
|
765
|
+
|
|
766
|
+
for hit in direct_hits + unresolved_inherited:
|
|
767
|
+
hit.pop("_locate_parent_id", None)
|
|
768
|
+
ranked_hits = direct_hits + folder_hits + unresolved_inherited[:2]
|
|
769
|
+
total_hits = len(ranked_hits)
|
|
770
|
+
page_hits = ranked_hits[offset:offset + limit]
|
|
771
|
+
direct_match_count = sum(bool(LOCATE_DIRECT_FIELDS.intersection(hit["why"]["fields"]))
|
|
772
|
+
for hit in direct_hits)
|
|
773
|
+
inherited_count = len(inherited_hits)
|
|
774
|
+
shown_direct = sum(hit.get("kind") != "folder" for hit in page_hits)
|
|
775
|
+
shown_samples = sum(len(hit.get("sample", [])) for hit in page_hits if hit.get("kind") == "folder")
|
|
776
|
+
shown = shown_direct + shown_samples
|
|
777
|
+
coverage = {
|
|
778
|
+
"matched": len(direct_hits) + inherited_count,
|
|
779
|
+
"shown": shown,
|
|
780
|
+
"hidden": max(0, len(direct_hits) + inherited_count - shown),
|
|
781
|
+
"folders": len(folder_hits),
|
|
782
|
+
"direct": {"matched": direct_match_count, "shown": sum(
|
|
783
|
+
bool(LOCATE_DIRECT_FIELDS.intersection(hit["why"]["fields"])) for hit in page_hits)},
|
|
784
|
+
"inherited": {"matched": inherited_count, "folders": len(folder_hits), "sampled": shown_samples},
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if not direct_match_count:
|
|
788
|
+
for folder in folder_hits:
|
|
789
|
+
for term in folder["why"]["terms"]:
|
|
790
|
+
key = (term, folder["source_id"], folder["section_id"], folder["folder_id"])
|
|
791
|
+
hint_rows[key] = {"folder_id": folder["folder_id"], "ref": folder["ref"],
|
|
792
|
+
"title": folder["title"], "path": folder["path"]}
|
|
793
|
+
by_term = {}
|
|
794
|
+
for (term, _, _, _), folder in hint_rows.items():
|
|
795
|
+
by_term.setdefault(term, []).append(folder)
|
|
796
|
+
hints = [{"term": term, "folders": folders} for term, folders in by_term.items()]
|
|
797
|
+
query_terms = list(dict.fromkeys(term for terms in terms_by_source.values() for term in terms))
|
|
798
|
+
matched_terms = {term for hit in direct_hits for term in hit["why"]["terms"]}
|
|
799
|
+
matched_terms.update(term for hit in folder_hits for term in hit["why"]["terms"])
|
|
800
|
+
missing_terms = [term for term in query_terms if term not in matched_terms]
|
|
801
|
+
if total_hits and not missing_terms:
|
|
802
|
+
hints = []
|
|
803
|
+
result = {"hits": page_hits, "total": total_hits,
|
|
804
|
+
"in": ["canvas"], "match_mode": "exact" if url is not None else text_match_mode,
|
|
805
|
+
"source_ids": source_ids, "candidates": candidates if total_hits == 0 else [],
|
|
806
|
+
"filters": {key: value for key, value in filters.items()
|
|
807
|
+
if value is not None and not (key == "metadata_match" and value == "all")}}
|
|
808
|
+
result["coverage"] = coverage
|
|
809
|
+
if hints:
|
|
810
|
+
result["hints"] = hints
|
|
811
|
+
next_offset = offset + len(page_hits)
|
|
812
|
+
if (total_hits == 1 and has_note is True and page_hits
|
|
813
|
+
and page_hits[0].get("kind") != "folder"
|
|
814
|
+
and "title" in page_hits[0]["why"]["fields"]):
|
|
815
|
+
next_message = "1 hit with title+note; stop if it matches"
|
|
816
|
+
elif direct_match_count and inherited_count:
|
|
817
|
+
top_score = max(hit["why"]["weight"] for hit in direct_hits
|
|
818
|
+
if LOCATE_DIRECT_FIELDS.intersection(hit["why"]["fields"]))
|
|
819
|
+
next_message = "%d direct hits, top score %d vs %d; read the first direct hit and stop if it matches" % (
|
|
820
|
+
direct_match_count, top_score, LOCATE_FIELD_WEIGHTS["path"])
|
|
821
|
+
elif direct_match_count:
|
|
822
|
+
next_message = "%d direct hits; read the first direct hit and stop if it matches" % direct_match_count
|
|
823
|
+
elif folder_hits and missing_terms and hints:
|
|
824
|
+
hint = hints[0]
|
|
825
|
+
next_message = "No full match for %r; %r appears in %d folders; try folder_id" % (
|
|
826
|
+
text, hint["term"], len(hint["folders"]))
|
|
827
|
+
elif folder_hits:
|
|
828
|
+
next_message = "All matches are inherited; use folder_id to narrow a folder"
|
|
829
|
+
elif hints:
|
|
830
|
+
hint = hints[0]
|
|
831
|
+
next_message = "No match for %r; %r appears in %d folders; try folder_id" % (
|
|
832
|
+
text, hint["term"], len(hint["folders"]))
|
|
833
|
+
elif unresolved_inherited:
|
|
834
|
+
next_message = "Inherited matches need a folder scope; inspect the reported sample and narrow by folder_id"
|
|
835
|
+
elif url is not None:
|
|
836
|
+
next_message = "No exact URL matches; verify the URL string"
|
|
837
|
+
else:
|
|
838
|
+
next_message = "No direct or inherited matches; refine the text query"
|
|
839
|
+
if next_offset < total_hits:
|
|
840
|
+
continuation = _remember_handle(request, database_path)
|
|
841
|
+
result["more"] = {"handle": continuation, "offset": next_offset, "total": total_hits}
|
|
842
|
+
result["next"] = {"tool": "locate", "handle": continuation,
|
|
843
|
+
"offset": next_offset, "reason": "continue_results",
|
|
844
|
+
"message": next_message}
|
|
845
|
+
else:
|
|
846
|
+
result["next"] = next_message
|
|
847
|
+
if folder_candidates:
|
|
848
|
+
result["folders"] = folder_candidates[:5]
|
|
849
|
+
message = ("Folder match: %s; pass its folder_id to list direct children."
|
|
850
|
+
% folder_candidates[0]["title"])
|
|
851
|
+
if isinstance(result.get("next"), dict):
|
|
852
|
+
result["next"].setdefault("message", "")
|
|
853
|
+
result["next"]["message"] += " " + message
|
|
854
|
+
else:
|
|
855
|
+
current = str(result.get("next") or "").strip()
|
|
856
|
+
result["next"] = (current + " " + message).strip() if current else message
|
|
857
|
+
if text is not None:
|
|
858
|
+
result["query"] = text
|
|
859
|
+
else:
|
|
860
|
+
result["url"] = url
|
|
861
|
+
return result
|
|
862
|
+
|