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/wiki.py ADDED
@@ -0,0 +1,383 @@
1
+ """Versioned, externally stored knowledge pages authored and reviewed by a host.
2
+
3
+ This module checks provenance, never semantic support. A saved extract is not a
4
+ Wiki page: the caller supplies topic/entity organization, prose and a declared
5
+ human or model review. Only active claims backed by accepted sources may enter.
6
+ """
7
+
8
+ import copy
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import uuid
14
+ from contextlib import ExitStack, contextmanager
15
+ from pathlib import Path
16
+
17
+ from research import ResearchSessions
18
+ from settings import Settings
19
+
20
+
21
+ class WikiStore:
22
+ """Publish immutable revisions, then atomically advance the current index."""
23
+
24
+ MAX_RECORD_BYTES = 900000
25
+ REVIEW_RUBRIC = (
26
+ "Check that each section is supported by its cited claims, preserves scope and uncertainty, "
27
+ "identifies the correct entities, and does not conceal contrary evidence. "
28
+ "Topic organization and semantic support are judgments by the named reviewer."
29
+ )
30
+
31
+ def __init__(self, directory=None, settings=None, research_sessions=None):
32
+ self.settings = settings if settings is not None else Settings()
33
+ selected = directory if directory is not None else self.settings.load().get("wiki", {}).get("directory", Settings.data_directory() / "wiki")
34
+ self.directory = Settings.external_path(str(selected), "Wiki directory")
35
+ self.research = research_sessions if research_sessions is not None else ResearchSessions(settings=self.settings)
36
+
37
+ @staticmethod
38
+ def _slug(value):
39
+ if not isinstance(value, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,79}", value):
40
+ raise ValueError("page_id must use 1 to 80 lowercase letters, digits or hyphens")
41
+ return value
42
+
43
+ @staticmethod
44
+ def _text(value, label, maximum=4000, single_line=False):
45
+ value = ResearchSessions._text(value, label, maximum)
46
+ if single_line and ("\n" in value or "\r" in value):
47
+ raise ValueError(label + " must be a single line")
48
+ return value
49
+
50
+ def _path(self, relative):
51
+ relative = Path(relative)
52
+ path = self.directory / relative
53
+ if relative.is_absolute() or self.directory not in path.resolve().parents:
54
+ raise ValueError("Wiki artifact must stay inside its directory")
55
+ if path.is_symlink() or any(parent.is_symlink() for parent in path.parents if parent != self.directory):
56
+ raise ValueError("Wiki artifacts must not use symlinks")
57
+ return path
58
+
59
+ def _index(self):
60
+ path = self._path("index.json")
61
+ if not path.exists():
62
+ return {"schema_version": 1, "pages": {}}
63
+ if path.stat().st_size > 8000000:
64
+ raise ValueError("Wiki index exceeds its size limit")
65
+ value = json.loads(path.read_text(encoding="utf-8"))
66
+ if value.get("schema_version") != 1 or not isinstance(value.get("pages"), dict):
67
+ raise ValueError("Unsupported Wiki index")
68
+ return value
69
+
70
+ def _revision(self, pointer):
71
+ raw = self._path(pointer["record_file"]).read_bytes()
72
+ if len(raw) > self.MAX_RECORD_BYTES or hashlib.sha256(raw).hexdigest() != pointer["sha256"]:
73
+ raise ValueError("Wiki revision hash changed or size limit exceeded")
74
+ value = json.loads(raw)
75
+ if value.get("schema_version") != 1 or value.get("revision") != pointer["revision"]:
76
+ raise ValueError("Unsupported or inconsistent Wiki revision")
77
+ markdown = self._path(pointer["markdown_file"]).read_bytes()
78
+ if hashlib.sha256(markdown).hexdigest() != pointer["markdown_sha256"]:
79
+ raise ValueError("Wiki Markdown hash changed; publish an explicit revision")
80
+ return value
81
+
82
+ def _page(self, page):
83
+ if not isinstance(page, dict) or set(page) != {"title", "kind", "sections", "links", "review"}:
84
+ raise ValueError("page needs title, kind, sections, links and review only")
85
+ value = copy.deepcopy(page)
86
+ value["title"] = self._text(value["title"], "Wiki title", 300, single_line=True)
87
+ if value["kind"] not in ("topic", "entity"):
88
+ raise ValueError("Wiki kind must be topic or entity")
89
+ if not isinstance(value["sections"], list) or not 1 <= len(value["sections"]) <= 16:
90
+ raise ValueError("A Wiki page needs 1 to 16 authored sections")
91
+ references = []
92
+ for section in value["sections"]:
93
+ if not isinstance(section, dict) or set(section) != {"heading", "text", "claims"}:
94
+ raise ValueError("Each section needs heading, text and claims only")
95
+ section["heading"] = self._text(section["heading"], "Section heading", 300, single_line=True)
96
+ section["text"] = self._text(section["text"], "Section text", 12000)
97
+ if not isinstance(section["claims"], list) or not 1 <= len(section["claims"]) <= 12:
98
+ raise ValueError("Each section needs 1 to 12 explicit claim references")
99
+ seen = set()
100
+ for reference in section["claims"]:
101
+ if not isinstance(reference, dict) or set(reference) != {"research_id", "claim_id"}:
102
+ raise ValueError("Each claim reference needs research_id and claim_id only")
103
+ self.research._path(reference["research_id"])
104
+ ResearchSessions._identifier(reference["claim_id"], "Claim id")
105
+ pair = (reference["research_id"], reference["claim_id"])
106
+ if pair in seen:
107
+ raise ValueError("Duplicate claim reference in a section")
108
+ seen.add(pair)
109
+ if pair not in references:
110
+ references.append(pair)
111
+ if len(references) > 24:
112
+ raise ValueError("A Wiki page may cite at most 24 distinct claims")
113
+ if not isinstance(value["links"], list) or len(value["links"]) > 32:
114
+ raise ValueError("Wiki links must contain at most 32 page relationships")
115
+ seen_links = set()
116
+ for link in value["links"]:
117
+ if not isinstance(link, dict) or set(link) != {"page_id", "relation"}:
118
+ raise ValueError("Each Wiki link needs page_id and relation only")
119
+ self._slug(link["page_id"])
120
+ link["relation"] = self._text(link["relation"], "Link relation", 500)
121
+ if link["page_id"] in seen_links:
122
+ raise ValueError("Duplicate Wiki link")
123
+ seen_links.add(link["page_id"])
124
+ review = value["review"]
125
+ if not isinstance(review, dict) or set(review) != {"method", "reviewer", "note"}:
126
+ raise ValueError("Wiki review needs method, reviewer and note only")
127
+ if review["method"] not in ("human", "model"):
128
+ raise ValueError("Wiki review method must be human or model")
129
+ review["reviewer"] = self._text(review["reviewer"], "Reviewer", 300, single_line=True)
130
+ review["note"] = self._text(review["note"], "Review note")
131
+ return value, references
132
+
133
+ def _evidence(self, research_id, claim_id, cache):
134
+ if research_id not in cache:
135
+ cache[research_id] = self.research._load(research_id)
136
+ path, state = cache[research_id]
137
+ claim = self.research._find(state["claims"], claim_id, "claim")
138
+ if claim.get("status", "active") != "active":
139
+ raise ValueError("Wiki cannot use a retracted claim: " + claim_id)
140
+ citations = []
141
+ for citation in claim["citations"]:
142
+ source = self.research._find(state["sources"], citation["source_id"], "source")
143
+ if source.get("review", {}).get("verdict") != "accepted":
144
+ raise ValueError("Wiki requires an accepted source review: " + source["id"])
145
+ if citation["source_sha256"] != source["sha256"] or citation["quote"] not in self.research._body(path, source):
146
+ raise ValueError("Wiki citation no longer matches saved evidence")
147
+ artifacts = {key: self.research._artifact(path, source[key])
148
+ for key in ("body_file", "manifest_file", "response_file")}
149
+ artifact_hashes = {key: hashlib.sha256(value.read_bytes()).hexdigest() for key, value in artifacts.items()}
150
+ citations.append({
151
+ "source_id": source["id"], "inventory_ids": source.get("inventory_ids", []),
152
+ "bookmark_refs": source.get("bookmark_refs", []), "url": source["url"],
153
+ "quote": citation["quote"], "source_sha256": source["sha256"],
154
+ "provider": source["provider"], "retrieved_at": source["retrieved_at"],
155
+ "review": copy.deepcopy(source["review"]),
156
+ "content_kind": source["content_kind"], "provenance": copy.deepcopy(source.get("provenance")),
157
+ "artifact_sha256": artifact_hashes, **{key: str(value) for key, value in artifacts.items()}
158
+ })
159
+ if not citations:
160
+ raise ValueError("Wiki claims require saved citations")
161
+ return {"research_id": research_id, "claim_id": claim_id, "statement": claim["statement"],
162
+ "confidence": claim["confidence"], "inference": claim["inference"], "citations": citations}
163
+
164
+ def _issues(self, record, index, cache=None):
165
+ cache = {} if cache is None else cache
166
+ issues = []
167
+ for saved in record["evidence"]:
168
+ try:
169
+ current = self._evidence(saved["research_id"], saved["claim_id"], cache)
170
+ # A revised review explanation is allowed; the accepted verdict,
171
+ # source identity, quote, content hash and claim must still hold.
172
+ def substantive(value):
173
+ value = copy.deepcopy(value)
174
+ for citation in value["citations"]:
175
+ citation.pop("review", None)
176
+ return value
177
+ if substantive(current) != substantive(saved):
178
+ raise ValueError("Claim or source mapping changed after Wiki publication")
179
+ except (OSError, ValueError, KeyError, TypeError) as error:
180
+ issues.append({"severity": "error", "code": "invalid_evidence", "research_id": saved["research_id"],
181
+ "claim_id": saved["claim_id"], "message": str(error)[:1000]})
182
+ for research_id in sorted({saved["research_id"] for saved in record["evidence"]}):
183
+ if research_id not in cache:
184
+ continue
185
+ key = ("source_freshness", research_id)
186
+ if key not in cache:
187
+ cache[key] = self.research.source_freshness(state=cache[research_id][1])
188
+ freshness = cache[key]
189
+ if freshness["requires_review"]:
190
+ issues.append({"severity": "warning", "code": "source_input_changed", "research_id": research_id,
191
+ "message": "The bookmark input changed after the cited research; review this page against the new source version.",
192
+ "source_freshness": freshness})
193
+ for link in record["resolved_links"]:
194
+ latest = index["pages"].get(link["page_id"])
195
+ if latest is None:
196
+ issues.append({"severity": "error", "code": "missing_link", "target_page_id": link["page_id"]})
197
+ elif latest["revision"] != link["revision"]:
198
+ issues.append({"severity": "warning", "code": "link_has_newer_revision", "target_page_id": link["page_id"],
199
+ "linked_revision": link["revision"], "current_revision": latest["revision"]})
200
+ if not self._path(link["markdown_file"]).is_file():
201
+ issues.append({"severity": "error", "code": "missing_link_artifact", "target_page_id": link["page_id"]})
202
+ return issues
203
+
204
+ @contextmanager
205
+ def _research_locks(self, references):
206
+ # Research mutations use these same locks. Keep accepted reviews and
207
+ # active claims stable through the Wiki publication transaction.
208
+ with ExitStack() as stack:
209
+ for research_id in sorted({reference[0] for reference in references}):
210
+ stack.enter_context(Settings._update_lock(self.research._path(research_id) / "state.json"))
211
+ yield
212
+
213
+ def write(self, page_id, page, change_note, expected_revision=0):
214
+ """Create at revision 0, or update the explicit revision read by the caller."""
215
+ page_id = self._slug(page_id)
216
+ page, references = self._page(page)
217
+ change_note = self._text(change_note, "Change note")
218
+ ResearchSessions._integer(expected_revision, "expected_revision", 0, 10000)
219
+ cache = {}
220
+ for reference in references:
221
+ self._evidence(*reference, cache)
222
+ Settings.external_path(str(self.directory), "Wiki directory")
223
+ self.directory.mkdir(parents=True, mode=0o700, exist_ok=True)
224
+ with self._research_locks(references), Settings._update_lock(self._path("index.json")):
225
+ index = self._index()
226
+ cache = {}
227
+ evidence = [self._evidence(*reference, cache) for reference in references]
228
+ previous = index["pages"].get(page_id)
229
+ if expected_revision != (previous["revision"] if previous else 0):
230
+ raise ValueError("Wiki revision changed; read the current page before updating")
231
+ if previous:
232
+ self._revision(previous)
233
+ elif len(index["pages"]) >= 5000:
234
+ raise ValueError("Wiki page limit reached")
235
+ resolved_links = []
236
+ for link in page["links"]:
237
+ if link["page_id"] == page_id or link["page_id"] not in index["pages"]:
238
+ raise ValueError("Wiki links must target an existing different page")
239
+ target = index["pages"][link["page_id"]]
240
+ self._revision(target)
241
+ resolved_links.append({**link, "revision": target["revision"], "title": target["title"],
242
+ "markdown_file": target["markdown_file"]})
243
+ revision = expected_revision + 1
244
+ stamp = ResearchSessions._now()
245
+ stem = "revisions/%s/%s-%s" % (page_id, revision, uuid.uuid4().hex[:12])
246
+ record = {"schema_version": 1, "page_id": page_id, "revision": revision,
247
+ "updated_at": stamp, "change_note": change_note, "previous": previous,
248
+ "page": page, "evidence": evidence, "resolved_links": resolved_links,
249
+ "review_rubric": self.REVIEW_RUBRIC}
250
+ raw = (json.dumps(record, ensure_ascii=False, allow_nan=False, indent=2) + "\n").encode("utf-8")
251
+ if len(raw) > self.MAX_RECORD_BYTES:
252
+ raise ValueError("Wiki page and citation snapshots exceed 900000 bytes; split the topic")
253
+ markdown = self._markdown(record, self._path(stem + ".md"))
254
+ pointer = {"revision": revision, "record_file": stem + ".json", "sha256": hashlib.sha256(raw).hexdigest(),
255
+ "markdown_file": stem + ".md", "markdown_sha256": hashlib.sha256(markdown.encode()).hexdigest(),
256
+ "title": page["title"], "kind": page["kind"], "updated_at": stamp}
257
+ ResearchSessions._write(self._path(pointer["record_file"]), raw.decode("utf-8"))
258
+ ResearchSessions._write(self._path(pointer["markdown_file"]), markdown)
259
+ index["pages"][page_id] = pointer
260
+ ResearchSessions._write(self._path("index.json"), index)
261
+ return {"page_id": page_id, **pointer, "status": "written", "directory": str(self.directory),
262
+ "artifacts": self._artifacts(pointer), "semantic_review": "caller_declared"}
263
+
264
+ def _artifacts(self, pointer):
265
+ return {key: str(self._path(pointer[key])) for key in ("record_file", "markdown_file")}
266
+
267
+ def get(self, page_id, revision=None):
268
+ page_id = self._slug(page_id)
269
+ if revision is not None:
270
+ ResearchSessions._integer(revision, "revision", 1, 10001)
271
+ index = self._index()
272
+ pointer = index["pages"].get(page_id)
273
+ while pointer is not None:
274
+ record = self._revision(pointer)
275
+ if record["page_id"] != page_id:
276
+ raise ValueError("Wiki revision belongs to a different page")
277
+ if revision is None or record["revision"] == revision:
278
+ issues = self._issues(record, index)
279
+ return {**record, "artifacts": self._artifacts(pointer), "validation": {
280
+ "status": "stale" if any(item["severity"] == "error" for item in issues) else
281
+ "needs_review" if any(item["code"] == "source_input_changed" for item in issues) else "current",
282
+ "issues": issues, "semantic_review": "caller_declared; not evaluated by lint"}}
283
+ if record["previous"] is not None and record["previous"]["revision"] >= record["revision"]:
284
+ raise ValueError("Invalid Wiki revision history")
285
+ pointer = record["previous"]
286
+ raise ValueError("Unknown Wiki page or revision")
287
+
288
+ def list(self, offset=0, limit=20):
289
+ ResearchSessions._integer(offset, "offset", 0, 10000000)
290
+ ResearchSessions._integer(limit, "limit", 1, 100)
291
+ rows = sorted(self._index()["pages"].items())
292
+ return {"directory": str(self.directory), "pages": [{"page_id": key, **value} for key, value in rows[offset:offset + limit]],
293
+ "total": len(rows), "next_offset": offset + limit if offset + limit < len(rows) else None}
294
+
295
+ def lint(self, page_id=None):
296
+ index = self._index()
297
+ ids = [self._slug(page_id)] if page_id is not None else sorted(index["pages"])
298
+ issues, cache = [], {}
299
+ for identifier in ids:
300
+ try:
301
+ if identifier not in index["pages"]:
302
+ raise ValueError("Unknown Wiki page")
303
+ record = self._revision(index["pages"][identifier])
304
+ if record["page_id"] != identifier:
305
+ raise ValueError("Wiki revision belongs to a different page")
306
+ issues.extend({"page_id": identifier, **issue} for issue in self._issues(record, index, cache))
307
+ previous = record["previous"]
308
+ while previous is not None:
309
+ older = self._revision(previous)
310
+ if older["page_id"] != identifier or older["revision"] >= record["revision"]:
311
+ raise ValueError("Invalid Wiki revision history")
312
+ record, previous = older, older["previous"]
313
+ except (OSError, ValueError, KeyError, TypeError) as error:
314
+ issues.append({"page_id": identifier, "severity": "error", "code": "artifact_error", "message": str(error)[:1000]})
315
+ return {"status": "error" if any(item["severity"] == "error" for item in issues) else "ok",
316
+ "pages_checked": len(ids), "issues": issues, "semantic_support": "not_scored",
317
+ "boundary": "Checks current source reviews, active claims, saved quotes, hashes, links and revision history only."}
318
+
319
+ def search(self, query, offset=0, limit=10):
320
+ query = self._text(query, "Wiki query", 500)
321
+ ResearchSessions._integer(offset, "offset", 0, 10000000)
322
+ ResearchSessions._integer(limit, "limit", 1, 100)
323
+ terms = list(dict.fromkeys(query.casefold().split()))
324
+ index, matches, excluded, cache = self._index(), [], [], {}
325
+ for page_id, pointer in index["pages"].items():
326
+ try:
327
+ record = self._revision(pointer)
328
+ if any(issue["severity"] == "error" for issue in self._issues(record, index, cache)):
329
+ excluded.append(page_id)
330
+ continue
331
+ page = record["page"]
332
+ body = "\n\n".join(section["heading"] + "\n" + section["text"] for section in page["sections"])
333
+ text = (page["title"] + "\n" + body).casefold()
334
+ if not all(term in text for term in terms):
335
+ continue
336
+ rank = sum(min(text.count(term), 20) + (5 if term in page["title"].casefold() else 0) for term in terms)
337
+ positions = [body.casefold().find(term) for term in terms if term in body.casefold()]
338
+ begin = max(0, min(positions, default=0) - 60)
339
+ matches.append((rank, {"page_id": page_id, "revision": pointer["revision"], "title": page["title"],
340
+ "kind": page["kind"], "snippet": body[begin:begin + 500],
341
+ "artifacts": self._artifacts(pointer)}))
342
+ except (OSError, ValueError, KeyError, TypeError):
343
+ excluded.append(page_id)
344
+ matches.sort(key=lambda row: (-row[0], row[1]["page_id"]))
345
+ return {"query": query, "results": [row[1] for row in matches[offset:offset + limit]], "total": len(matches),
346
+ "next_offset": offset + limit if offset + limit < len(matches) else None,
347
+ "excluded_stale_pages": len(excluded), "excluded_page_ids": excluded[:100],
348
+ "method": "casefolded literal terms over authored Wiki text; no semantic ranking"}
349
+
350
+ def _markdown(self, record, location):
351
+ page = record["page"]
352
+ link = lambda target: os.path.relpath(target, location.parent).replace(os.sep, "/")
353
+ refs = {(item["research_id"], item["claim_id"]): i + 1 for i, item in enumerate(record["evidence"])}
354
+ lines = ["# " + page["title"], "", "Kind: %s · Revision: %s · %s" % (page["kind"], record["revision"], record["updated_at"]),
355
+ "", "Reviewed by %s (%s). %s" % (page["review"]["reviewer"], page["review"]["method"], page["review"]["note"]),
356
+ "", "This revision preserves the review at publication. Run Wiki lint to check later source changes.", ""]
357
+ for section in page["sections"]:
358
+ lines.extend(["## " + section["heading"], "", section["text"], "", "Evidence: " + " ".join(
359
+ "[%s/%s](#evidence-%s)" % (item["research_id"], item["claim_id"], refs[(item["research_id"], item["claim_id"])])
360
+ for item in section["claims"]), ""])
361
+ if record["resolved_links"]:
362
+ lines.extend(["## Related pages", ""])
363
+ for target in record["resolved_links"]:
364
+ lines.append("- [%s](%s): %s (revision %s)" %
365
+ (target["title"], link(self._path(target["markdown_file"])), target["relation"], target["revision"]))
366
+ lines.append("")
367
+ lines.extend(["## Evidence", ""])
368
+ for i, evidence in enumerate(record["evidence"], 1):
369
+ lines.extend(["### Evidence %s" % i, "", "%s / %s: %s" %
370
+ (evidence["research_id"], evidence["claim_id"], evidence["statement"]), ""])
371
+ for citation in evidence["citations"]:
372
+ lines.extend(["Source %s · %s · Retrieved %s" % (citation["source_id"], citation["url"], citation["retrieved_at"]),
373
+ "", "Inventory IDs: " + (", ".join(citation["inventory_ids"]) or "Not associated with a frozen inventory"), ""])
374
+ if (citation["provenance"] or {}).get("kind") == "external_report":
375
+ lines.extend(["Secondary evidence: an external report. Pages cited by that report have not been read by importing it.", ""])
376
+ lines.extend("> " + line for line in citation["quote"].splitlines())
377
+ lines.extend(["", "[Saved text](%s) · [Manifest](%s) · [Response](%s) · SHA-256: `%s`" %
378
+ (link(citation["body_file"]), link(citation["manifest_file"]), link(citation["response_file"]),
379
+ citation["source_sha256"]), ""])
380
+ lines.extend(["## Update record", "", record["change_note"], ""])
381
+ if record["previous"]:
382
+ lines.extend(["[Previous revision](%s)" % link(self._path(record["previous"]["markdown_file"])), ""])
383
+ return "\n".join(lines) + "\n"