pythia-knowledge 1.0.0__py3-none-any.whl

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 (64) hide show
  1. pythia/__init__.py +3 -0
  2. pythia/cli.py +246 -0
  3. pythia/connectors/__init__.py +0 -0
  4. pythia/connectors/claude_code.py +467 -0
  5. pythia/connectors/common.py +99 -0
  6. pythia/connectors/confluence.py +803 -0
  7. pythia/connectors/github.py +444 -0
  8. pythia/connectors/incident_io.py +365 -0
  9. pythia/connectors/slack.py +674 -0
  10. pythia/consumer.py +268 -0
  11. pythia/daemon.py +650 -0
  12. pythia/db.py +80 -0
  13. pythia/docker-compose.yml +15 -0
  14. pythia/embed.py +39 -0
  15. pythia/events.py +339 -0
  16. pythia/evolution.py +392 -0
  17. pythia/ids.py +12 -0
  18. pythia/jobs.py +103 -0
  19. pythia/knowledge.py +494 -0
  20. pythia/llm/__init__.py +0 -0
  21. pythia/llm/client.py +328 -0
  22. pythia/mcp_server.py +73 -0
  23. pythia/migrations/001_schema.sql +536 -0
  24. pythia/migrations/__init__.py +0 -0
  25. pythia/pipeline/__init__.py +0 -0
  26. pythia/pipeline/adjudicate.py +297 -0
  27. pythia/pipeline/batch.py +254 -0
  28. pythia/pipeline/correct.py +227 -0
  29. pythia/pipeline/derive.py +317 -0
  30. pythia/pipeline/live.py +149 -0
  31. pythia/pipeline/process.py +472 -0
  32. pythia/pipeline/prompts.py +260 -0
  33. pythia/pipeline/reconcile.py +168 -0
  34. pythia/pipeline/schemas.py +154 -0
  35. pythia/pipeline/sweep.py +155 -0
  36. pythia/quality.py +359 -0
  37. pythia/reexplain.py +248 -0
  38. pythia/retrieval.py +137 -0
  39. pythia/services.py +41 -0
  40. pythia/settings.py +194 -0
  41. pythia/usage.py +135 -0
  42. pythia/value.py +265 -0
  43. pythia/vault.py +31 -0
  44. pythia/web/__init__.py +0 -0
  45. pythia/web/app.py +779 -0
  46. pythia/web/graph.py +167 -0
  47. pythia/web/processing.py +131 -0
  48. pythia/web/static/explore.js +242 -0
  49. pythia/web/static/style.css +120 -0
  50. pythia/web/templates/_now.html +25 -0
  51. pythia/web/templates/base.html +30 -0
  52. pythia/web/templates/config.html +231 -0
  53. pythia/web/templates/costs.html +30 -0
  54. pythia/web/templates/evolution.html +153 -0
  55. pythia/web/templates/explore.html +36 -0
  56. pythia/web/templates/label.html +22 -0
  57. pythia/web/templates/quality.html +141 -0
  58. pythia/web/templates/sources.html +249 -0
  59. pythia/web/templates/usage.html +33 -0
  60. pythia_knowledge-1.0.0.dist-info/METADATA +197 -0
  61. pythia_knowledge-1.0.0.dist-info/RECORD +64 -0
  62. pythia_knowledge-1.0.0.dist-info/WHEEL +4 -0
  63. pythia_knowledge-1.0.0.dist-info/entry_points.txt +2 -0
  64. pythia_knowledge-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,803 @@
1
+ """Confluence connector: allowlisted spaces and page trees, read through the v2 API with the owner's
2
+ token.
3
+
4
+ - **Page:** split by headings; each section is a piece of evidence. Backfill reads only the current version.
5
+ - **Edit:** an edit is not a retraction. A new or changed section is extracted with the rest of the page as
6
+ context. The section's previous version is kept as history (`superseded`), and the claims that came from it
7
+ go to the adjudicator with both versions, in a tension with a dossier: still valid, evolved or
8
+ wrong. A section that did not change costs nothing.
9
+ - **Comments:** footer and inline (with the commented excerpt), one stream per page; editing a
10
+ comment is a retraction, as in chat.
11
+ - **Deletion:** a deleted page really erases everything; an archived one is only marked; a page that left the
12
+ enabled tree leaves the base (like changing a filter). Never deletes on an incomplete listing.
13
+
14
+ Everything here is plumbing: read, convert the storage format to text, split by headings
15
+ (the structure comes from the page itself), compare hashes and store.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import html
21
+ import re
22
+ import time
23
+ from datetime import datetime, timedelta, timezone
24
+ from html.parser import HTMLParser
25
+ from typing import Any
26
+
27
+ import httpx
28
+ from psycopg.types.json import Jsonb
29
+
30
+ from .. import vault
31
+ from ..db import Database
32
+ from ..ids import new_id, sha
33
+ from .common import Item, SourceUnavailable, create_unit, sync_stream
34
+ from .common import NotFound as common_NotFound
35
+
36
+ KIND = "confluence"
37
+ SCOPES = ["read:page:confluence", "read:comment:confluence", "read:space:confluence",
38
+ "read:hierarchical-content:confluence", "read:user:confluence"]
39
+ MAX_UNIT_CHARS = 120_000
40
+ CONTEXT_CHARS = 60_000
41
+
42
+
43
+ class NotFound(common_NotFound):
44
+ """Confluence states that the page does not exist (deleted): a complete answer, not a failure."""
45
+
46
+
47
+ def source_id_for(space_key: str, root_id: str | None = None) -> str:
48
+ return f"{KIND}:tree:{root_id}" if root_id else f"{KIND}:space:{space_key}"
49
+
50
+
51
+ def _dt(value: str | None) -> datetime | None:
52
+ return datetime.fromisoformat(value.replace("Z", "+00:00")) if value else None
53
+
54
+
55
+ # ---------------------------------------------------------------- client
56
+
57
+
58
+ def resolve_credentials(cfg: dict) -> tuple[str, str]:
59
+ token = vault.get_secret(vault.CONFLUENCE_TOKEN)
60
+ email = (cfg.get("email") or "").strip()
61
+ if not token or not email or not cfg.get("site"):
62
+ raise SourceUnavailable("Confluence without site, email or token (Settings → Confluence)")
63
+ return email, token
64
+
65
+
66
+ class ConfluenceClient:
67
+ def __init__(self, site: str, email: str, token: str, token_type: str = "scoped",
68
+ transport: httpx.BaseTransport | None = None, cloud_id: str | None = None, max_wait_s: float = 60):
69
+ self.site = site.removeprefix("https://").strip("/")
70
+ self.token_type = token_type
71
+ self.transport = transport
72
+ self.max_wait_s = max_wait_s
73
+ if token_type == "scoped":
74
+ cloud_id = cloud_id or self._cloud_id()
75
+ base = f"https://api.atlassian.com/ex/confluence/{cloud_id}/wiki"
76
+ else:
77
+ base = f"https://{self.site}/wiki"
78
+ # Scoped and classic tokens both use basic authentication (email + token).
79
+ self.http = httpx.Client(base_url=base, transport=transport, timeout=30, auth=(email, token),
80
+ headers={"Accept": "application/json", "User-Agent": "pythia"})
81
+
82
+ def _cloud_id(self) -> str:
83
+ try:
84
+ r = httpx.Client(transport=self.transport, timeout=15).get(f"https://{self.site}/_edge/tenant_info")
85
+ r.raise_for_status()
86
+ return r.json()["cloudId"]
87
+ except (httpx.HTTPError, KeyError, ValueError) as exc:
88
+ raise SourceUnavailable(f"could not discover the cloudId of {self.site}: {exc}") from exc
89
+
90
+ def get(self, path: str, params: dict | None = None) -> Any:
91
+ waited = 0.0
92
+ while True:
93
+ try:
94
+ r = self.http.get(path, params=params)
95
+ except httpx.HTTPError as exc:
96
+ raise SourceUnavailable(f"Confluence unreachable: {exc}") from exc
97
+ if r.status_code == 429:
98
+ wait = float(r.headers.get("retry-after") or 30)
99
+ if waited + wait > self.max_wait_s:
100
+ raise SourceUnavailable("Confluence rate limit; will retry later")
101
+ time.sleep(wait)
102
+ waited += wait
103
+ continue
104
+ if r.status_code in (401, 403):
105
+ raise SourceUnavailable(f"Confluence rejected the token ({r.status_code}); check email, token and scopes")
106
+ if r.status_code == 404:
107
+ raise NotFound(path)
108
+ if r.status_code >= 500:
109
+ raise SourceUnavailable(f"Confluence {r.status_code}")
110
+ r.raise_for_status()
111
+ return r.json()
112
+
113
+ def post(self, path: str, body: dict) -> Any:
114
+ waited = 0.0
115
+ while True:
116
+ try:
117
+ r = self.http.post(path, json=body)
118
+ except httpx.HTTPError as exc:
119
+ raise SourceUnavailable(f"Confluence unreachable: {exc}") from exc
120
+ if r.status_code != 429:
121
+ break
122
+ wait = float(r.headers.get("retry-after") or 30)
123
+ if waited + wait > self.max_wait_s:
124
+ raise SourceUnavailable("Confluence rate limit; will retry later")
125
+ time.sleep(wait)
126
+ waited += wait
127
+ if r.status_code in (401, 403):
128
+ raise SourceUnavailable(f"Confluence rejected the token ({r.status_code})")
129
+ r.raise_for_status()
130
+ return r.json()
131
+
132
+ def pages(self, path: str, params: dict | None = None):
133
+ """Iterates a v2 cursor listing to the end (complete listing or error)."""
134
+ query = {"limit": 250, **(params or {})}
135
+ while path:
136
+ body = self.get(path, query)
137
+ yield from body.get("results") or []
138
+ nxt = (body.get("_links") or {}).get("next")
139
+ path, query = (nxt.removeprefix("/wiki"), None) if nxt else (None, None)
140
+
141
+
142
+ def verify(client: ConfluenceClient) -> None:
143
+ """A cheap read that requires the right token (and, with a scoped token, the space scope)."""
144
+ client.get("/api/v2/spaces", {"limit": 1})
145
+
146
+
147
+ def list_spaces(client: ConfluenceClient) -> list[dict]:
148
+ out = [{"id": s["id"], "key": s["key"], "name": s.get("name") or s["key"], "type": s.get("type"),
149
+ "status": s.get("status")} for s in client.pages("/api/v2/spaces", {"status": "current"})]
150
+ return sorted(out, key=lambda s: (s["type"] == "personal", s["name"].lower()))
151
+
152
+
153
+ def space_pages(client: ConfluenceClient, space_id: str, with_body: bool = False) -> list[dict]:
154
+ params = {"status": "current", **({"body-format": "storage"} if with_body else {})}
155
+ return list(client.pages(f"/api/v2/spaces/{space_id}/pages", params))
156
+
157
+
158
+ DESCENDANTS_DEPTH = 10 # maximum the API accepts; where the tree is deeper, fetch again from there
159
+
160
+
161
+ def space_nodes(client: "ConfluenceClient", space_id: str, pages: list[dict]) -> dict[str, dict]:
162
+ """Pages and folders of the space with each one's parent: {id: {"title", "parent", "type", "position"}}.
163
+ Folders do not come in the page listing, and the folders API needs another scope; the descendants of the
164
+ home page (hierarchy scope) bring both."""
165
+ nodes = {str(p["id"]): {"title": p["title"], "parent": str(p.get("parentId") or ""), "type": "page",
166
+ "position": p.get("position") or 0} for p in pages}
167
+ home = str(client.get(f"/api/v2/spaces/{space_id}").get("homepageId") or "")
168
+ stack = [home] if home else []
169
+ while stack:
170
+ root = stack.pop()
171
+ for d in client.pages(f"/api/v2/pages/{root}/descendants", {"depth": DESCENDANTS_DEPTH}):
172
+ nid = str(d["id"])
173
+ nodes[nid] = {"title": d.get("title") or nid, "parent": str(d.get("parentId") or ""),
174
+ "type": d.get("type") or "page", "position": d.get("childPosition") or 0}
175
+ if (d.get("depth") or 0) >= DESCENDANTS_DEPTH and (d.get("type") or "page") == "page":
176
+ stack.append(nid)
177
+ return nodes
178
+
179
+
180
+ def tree_of(pages: list[dict], root_id: str, nodes: dict[str, dict] | None = None) -> list[dict]:
181
+ """The root (page or folder) and all pages below it. `nodes` brings the folders, which do not come in the
182
+ page listing."""
183
+ parent_of = {nid: n["parent"] for nid, n in (nodes or {}).items()}
184
+ parent_of.update({str(p["id"]): str(p.get("parentId") or "") for p in pages})
185
+
186
+ def ancestors(node: str):
187
+ seen = set()
188
+ while node and node not in seen:
189
+ seen.add(node)
190
+ yield node
191
+ node = parent_of.get(node, "")
192
+
193
+ root = str(root_id)
194
+ return [p for p in pages if root in ancestors(str(p["id"]))]
195
+
196
+
197
+ def page_outline(nodes: dict[str, dict]) -> list[dict]:
198
+ """Pages and folders of the space in tree order, with depth (to choose roots on the web)."""
199
+ by_parent: dict[str, list[tuple[str, dict]]] = {}
200
+ for nid, n in nodes.items():
201
+ by_parent.setdefault(n["parent"] if n["parent"] in nodes else "", []).append((nid, n))
202
+ out = []
203
+
204
+ def walk(parent: str, depth: int):
205
+ for nid, n in sorted(by_parent.get(parent, []), key=lambda x: (x[1]["position"], x[1]["title"].lower())):
206
+ out.append({"id": nid, "title": n["title"], "depth": depth, "folder": n["type"] == "folder"})
207
+ walk(nid, depth + 1)
208
+
209
+ walk("", 0)
210
+ return out
211
+
212
+
213
+ # ---------------------------------------------------------------- storage format → text per section
214
+
215
+
216
+ class _Storage(HTMLParser):
217
+ """Confluence storage format (XHTML with `ac:`/`ri:` macros) → text, split on
218
+ headings. Formatting only: headings, paragraphs, lists, tables, code, mentions, links and macros.
219
+ Nothing is discarded: code blocks (CDATA) become text, and each macro's parameters appear
220
+ next to it (e.g. `[status: title=OBSOLETE]`, `[jira: key=PROJ-1]`)."""
221
+
222
+ BLOCK = {"p", "div", "li", "tr", "br", "pre", "blockquote", "ac:task", "hr"}
223
+
224
+ def __init__(self, users: dict[str, str]):
225
+ super().__init__(convert_charrefs=True)
226
+ self.users = users
227
+ self.sections: list[tuple[list[str], list[str]]] = [([], [])]
228
+ self.path: list[tuple[int, str]] = []
229
+ self.heading: int | None = None
230
+ self.heading_text: list[str] = []
231
+ self.cell = False
232
+ self.in_cell = 0 # inside a cell, paragraphs and lists do not break the table row
233
+ self.macros: list[dict] = [] # stack: {"name", "params": [(name, value)], "emitted"}
234
+ self.param: tuple[str, list[str]] | None = None
235
+
236
+ def _out(self) -> list[str]:
237
+ return self.sections[-1][1]
238
+
239
+ def _emit_macro_head(self) -> None:
240
+ m = self.macros[-1] if self.macros else None
241
+ if m and not m["emitted"]:
242
+ m["emitted"] = True
243
+ params = ", ".join(f"{k}={v}" for k, v in m["params"] if v)
244
+ self._write(f"[{m['name']}{': ' + params if params else ''}] ")
245
+
246
+ def _write(self, text: str) -> None:
247
+ (self.heading_text if self.heading is not None else self._out()).append(text)
248
+
249
+ def handle_starttag(self, tag, attrs):
250
+ a = dict(attrs)
251
+ if tag == "ac:structured-macro":
252
+ self.macros.append({"name": a.get("ac:name") or "macro", "params": [], "emitted": False})
253
+ elif tag == "ac:parameter":
254
+ self.param = (a.get("ac:name") or "", [])
255
+ elif tag in ("ac:rich-text-body", "ac:plain-text-body", "ac:plain-text-link-body"):
256
+ self._emit_macro_head()
257
+ elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
258
+ self.heading, self.heading_text = int(tag[1]), []
259
+ elif tag == "ri:user":
260
+ aid = a.get("ri:account-id", "")
261
+ self._write(f"@{self.users.get(aid, aid or 'someone')}")
262
+ elif tag == "ri:page":
263
+ self._write(f"[page: {a.get('ri:content-title', '?')}]")
264
+ elif tag == "ri:attachment":
265
+ self._write(f"[attachment: {a.get('ri:filename', '?')}]")
266
+ elif tag == "a" and a.get("href"):
267
+ self._pending_href = a["href"]
268
+ elif tag in ("td", "th"):
269
+ self._out().append(" | " if self.cell else "")
270
+ self.cell = True
271
+ self.in_cell += 1
272
+ elif tag == "tr":
273
+ self.cell = False
274
+ self._out().append("\n")
275
+ elif tag == "li":
276
+ self._out().append(" · " if self.in_cell else "\n- ")
277
+ elif tag in self.BLOCK:
278
+ self._out().append(" " if self.in_cell else "\n")
279
+
280
+ def handle_endtag(self, tag):
281
+ if tag == "ac:parameter" and self.param is not None:
282
+ name, parts = self.param
283
+ if self.macros:
284
+ self.macros[-1]["params"].append((name or "value", " ".join("".join(parts).split())))
285
+ self.param = None
286
+ elif tag == "ac:structured-macro" and self.macros:
287
+ self._emit_macro_head()
288
+ self.macros.pop()
289
+ elif tag == "a" and getattr(self, "_pending_href", None):
290
+ self._write(f" ({self._pending_href})")
291
+ self._pending_href = None
292
+ elif tag in ("h1", "h2", "h3", "h4", "h5", "h6") and self.heading is not None:
293
+ title = " ".join("".join(self.heading_text).split()) or "(untitled)"
294
+ level = self.heading
295
+ self.path = [(lvl, t) for lvl, t in self.path if lvl < level] + [(level, title)]
296
+ self.sections.append(([t for _, t in self.path], []))
297
+ self.heading = None
298
+ elif tag in ("td", "th"):
299
+ self.in_cell = max(0, self.in_cell - 1)
300
+ elif tag in ("p", "pre", "blockquote", "ac:plain-text-body"):
301
+ self._out().append(" " if self.in_cell else "\n")
302
+ elif tag == "tr":
303
+ self._out().append("\n")
304
+
305
+ def handle_data(self, data):
306
+ if self.param is not None:
307
+ self.param[1].append(data)
308
+ return
309
+ self._write(data)
310
+
311
+ def unknown_decl(self, data):
312
+ # Code blocks and `noformat` come in CDATA: they are content, not markup.
313
+ if data.startswith("CDATA["):
314
+ self._emit_macro_head()
315
+ self._write(data[len("CDATA["):])
316
+
317
+
318
+ def sections_of(storage: str, users: dict[str, str] | None = None) -> list[tuple[str, str]]:
319
+ """[(heading path, text)] in page order. A section without text is omitted."""
320
+ parser = _Storage(users or {})
321
+ parser.feed(storage or "")
322
+ parser.close()
323
+ out = []
324
+ for path, parts in parser.sections:
325
+ text = re.sub(r"[ \t]+", " ", "".join(parts))
326
+ text = re.sub(r" *\n *", "\n", text)
327
+ text = re.sub(r"\n\s*\n+", "\n", text).strip()
328
+ if text:
329
+ out.append((" > ".join(path) or "(start)", html.unescape(text)))
330
+ return out
331
+
332
+
333
+ def sections_with_raw(storage: str, users: dict[str, str]) -> list[tuple[str, str, str]]:
334
+ """(path, text with names, raw text with account IDs). The edit hash uses the raw one: renaming a
335
+ person (or failing to get the names on a read) is not an edit of the page."""
336
+ named, raw = sections_of(storage, users), sections_of(storage, {})
337
+ return [(p, t, raw[i][1] if i < len(raw) else t) for i, (p, t) in enumerate(named)]
338
+
339
+
340
+ def users_names(client: ConfluenceClient, account_ids: set[str], cache: dict[str, str]) -> dict[str, str]:
341
+ missing = [a for a in account_ids if a and a not in cache]
342
+ for i in range(0, len(missing), 100):
343
+ try:
344
+ for u in client.post("/api/v2/users-bulk", {"accountIds": missing[i:i + 100]}).get("results") or []:
345
+ cache[u["accountId"]] = u.get("publicName") or u.get("displayName") or u["accountId"]
346
+ except (SourceUnavailable, httpx.HTTPError):
347
+ break # without the user scope: the account ID stays (resolution still finds it through the catalog)
348
+ return cache
349
+
350
+
351
+ _MENTION = re.compile(r'ri:account-id="([^"]+)"')
352
+
353
+
354
+ # ---------------------------------------------------------------- reading
355
+
356
+
357
+ class ConfluenceConnector:
358
+ def __init__(self, db: Database, client: ConfluenceClient, cfg: dict, owner_name: str,
359
+ owner_aliases: list[str] | None = None):
360
+ self.db = db
361
+ self.api = client
362
+ self.cfg = cfg
363
+ self.owner_name = owner_name
364
+ self.owner_names = {n.lower() for n in [owner_name, *(owner_aliases or [])]}
365
+ self._users: dict[str, str] = {}
366
+
367
+ def _who(self, account_id: str | None) -> str:
368
+ name = self._users.get(account_id or "", account_id or "?")
369
+ # The owner is marked by the name he declared himself (Settings → Owner), as in the other sources.
370
+ return f"{name} ({self.owner_name}, the owner)" if name.lower() in self.owner_names else name
371
+
372
+ def _excluded(self, account_id: str | None) -> bool:
373
+ excluded = {a.lower() for a in self.cfg.get("exclude_authors") or []}
374
+ return bool(excluded & {(account_id or "").lower(), self._users.get(account_id or "", "").lower()})
375
+
376
+ def in_scope(self, source: dict) -> list[dict]:
377
+ cfg = source["config"]
378
+ pages = space_pages(self.api, cfg["space_id"])
379
+ if cfg.get("root_id"):
380
+ return tree_of(pages, cfg["root_id"], space_nodes(self.api, cfg["space_id"], pages))
381
+ blogs = list(self.api.pages(f"/api/v2/spaces/{cfg['space_id']}/blogposts", {"status": "current"}))
382
+ return pages + [{**b, "_blog": True} for b in blogs]
383
+
384
+ def scan(self, source: dict, now: datetime | None = None) -> dict:
385
+ now = now or datetime.now(timezone.utc)
386
+ settle = timedelta(minutes=float(self.cfg["settle_minutes"]))
387
+ revision = float(self.cfg["comment_revision_hours"]) * 3600
388
+ stats = {"pages": 0, "read": 0, "units": 0, "removed": 0, "archived": 0}
389
+ listing = self.in_scope(source) # complete or error
390
+ stats["pages"] = len(listing)
391
+ live = {str(p["id"]): p for p in listing}
392
+ if self.cfg.get("exclude_authors"): # the owner's list may contain names: resolve the authors before filtering
393
+ users_names(self.api, {str(p.get("authorId") or "") for p in listing}, self._users)
394
+ with self.db.tx() as conn:
395
+ known = {r["id"]: r for r in conn.execute(
396
+ "SELECT * FROM streams WHERE source_id = %s AND meta->>'confluence' = 'page'", (source["id"],)).fetchall()}
397
+ # Page read by another enabled source (space × tree): stays with it, unless it has
398
+ # released the page (left its tree) or is turned off.
399
+ elsewhere = {r["id"]: r for r in conn.execute(
400
+ """SELECT st.*, src.enabled AS owner_enabled FROM streams st JOIN sources src ON src.id = st.source_id
401
+ WHERE st.source_id LIKE 'confluence:%%' AND st.source_id <> %s
402
+ AND st.meta->>'confluence' = 'page' AND st.status = 'active'""", (source["id"],)).fetchall()}
403
+ for pid, p in live.items():
404
+ sid = f"{KIND}:page:{pid}"
405
+ other = elsewhere.get(sid)
406
+ if other and other["owner_enabled"] and not other["meta"].get("orphan_since"):
407
+ continue
408
+ if self._excluded(p.get("authorId")):
409
+ if sid in known:
410
+ self._remove_page(source, pid, "author ignored by the owner")
411
+ continue
412
+ if other:
413
+ self._take_over(source, pid) # the other source released the page: it now belongs to this one
414
+ st = known.get(sid) or (other and {**other, "meta": {**other["meta"], "version_seen": None}})
415
+ version = (p.get("version") or {}).get("number")
416
+ changed_at = _dt((p.get("version") or {}).get("createdAt")) or now
417
+ restored = st is not None and st["status"] != "active"
418
+ if st is not None and st["meta"].get("archived"):
419
+ self._mark(sid, {"archived": False}) # came back from the archived list
420
+ if st is None or restored or st["meta"].get("version_seen") != version:
421
+ if now - changed_at < settle and not restored:
422
+ continue # still being edited: wait for it to settle
423
+ stats["read"] += 1
424
+ stats["units"] += self._read_page(source, pid, p.get("_blog", False))
425
+ stats["units"] += self._read_comments(source, pid, now, p.get("_blog", False))
426
+ elif now.timestamp() - float(st["meta"].get("comments_at") or 0) >= revision:
427
+ stats["units"] += self._read_comments(source, pid, now, p.get("_blog", False))
428
+ # Known pages that vanished from the complete listing: deleted, archived or out of scope.
429
+ gone = [(sid, st) for sid, st in known.items() if sid.split(":")[-1] not in live and st["status"] == "active"]
430
+ if gone:
431
+ self._space_readable(source) # a 404 from lack of access to the space is not a deletion
432
+ for sid, st in gone:
433
+ pid = sid.split(":")[-1]
434
+ if st["meta"].get("archived"):
435
+ continue
436
+ fate = self._fate(pid, st["meta"].get("blog"))
437
+ if fate == "archived":
438
+ self._mark(sid, {"archived": True})
439
+ stats["archived"] += 1
440
+ elif fate == "deleted":
441
+ self._remove_page(source, pid, "deleted in Confluence")
442
+ stats["removed"] += 1
443
+ elif not st["meta"].get("orphan_since"):
444
+ # Left this source's scope: another enabled source may take over; otherwise it goes on the next read.
445
+ self._mark(sid, {"orphan_since": now.isoformat()})
446
+ elif now - _dt(st["meta"]["orphan_since"]) >= timedelta(minutes=float(self.cfg["poll_minutes"]) * 2):
447
+ self._remove_page(source, pid, "left the enabled scope")
448
+ stats["removed"] += 1
449
+ return stats
450
+
451
+ def _mark(self, stream_id: str, meta: dict) -> None:
452
+ with self.db.tx() as conn:
453
+ conn.execute("UPDATE streams SET meta = meta || %s WHERE id = %s", (Jsonb(meta), stream_id))
454
+
455
+ def _take_over(self, source: dict, page_id: str) -> None:
456
+ """The page moves to this source, with what has already been read from it (nothing is re-read or reprocessed)."""
457
+ ids = (f"{KIND}:page:{page_id}", f"{KIND}:comments:{page_id}")
458
+ with self.db.tx() as conn:
459
+ conn.execute("UPDATE streams SET source_id = %s, meta = meta - 'orphan_since' WHERE id IN (%s, %s)",
460
+ (source["id"], *ids))
461
+ conn.execute("UPDATE evidence SET source_id = %s WHERE stream_id IN (%s, %s)", (source["id"], *ids))
462
+ conn.execute("UPDATE units SET source_id = %s WHERE stream_id IN (%s, %s)", (source["id"], *ids))
463
+
464
+ def _space_readable(self, source: dict) -> None:
465
+ try:
466
+ self.api.get(f"/api/v2/spaces/{source['config']['space_id']}")
467
+ except NotFound as exc:
468
+ raise SourceUnavailable("the space is no longer accessible with this token; nothing was deleted") from exc
469
+
470
+ def _fate(self, page_id: str, blog: bool) -> str:
471
+ try:
472
+ p = self.api.get(f"/api/v2/{'blogposts' if blog else 'pages'}/{page_id}")
473
+ except NotFound:
474
+ return "deleted" # the space is still readable (checked before): the page no longer exists
475
+ status = p.get("status")
476
+ if status in ("trashed", "deleted", "purged"):
477
+ return "deleted"
478
+ return "archived" if status == "archived" else "moved"
479
+
480
+ def _remove_page(self, source: dict, page_id: str, reason: str) -> None:
481
+ from .. import knowledge as K
482
+ streams = (f"{KIND}:page:{page_id}", f"{KIND}:comments:{page_id}")
483
+ with self.db.tx() as conn:
484
+ ids = [r["id"] for r in conn.execute(
485
+ """SELECT id FROM evidence WHERE (stream_id IN (%s, %s) AND status IN ('active', 'superseded'))
486
+ OR (source_id = 'live:confluence' AND status = 'active' AND meta->>'page_id' = %s)""",
487
+ (*streams, page_id)).fetchall()]
488
+ if ids:
489
+ # The version history and the live reads of the page go away too.
490
+ K.tombstone_evidence(conn, "connector", ids, reason)
491
+ conn.execute("UPDATE evidence SET external_id = NULL WHERE id = ANY(%s)", (ids,))
492
+ # The page title is also text derived from it.
493
+ conn.execute("UPDATE streams SET status = 'deleted', title = NULL, locator = '' WHERE id IN (%s, %s)", streams)
494
+
495
+ # ------------------------------------------------------------ page
496
+
497
+ def _read_page(self, source: dict, page_id: str, blog: bool) -> int:
498
+ from .. import knowledge as K
499
+ p = self.api.get(f"/api/v2/{'blogposts' if blog else 'pages'}/{page_id}", {"body-format": "storage"})
500
+ storage = ((p.get("body") or {}).get("storage") or {}).get("value") or ""
501
+ users_names(self.api, set(_MENTION.findall(storage)) | {(p.get("version") or {}).get("authorId") or "",
502
+ p.get("authorId") or ""}, self._users)
503
+ version = (p.get("version") or {}).get("number")
504
+ when = _dt((p.get("version") or {}).get("createdAt")) or datetime.now(timezone.utc)
505
+ url = f"https://{self.api.site}/wiki{(p.get('_links') or {}).get('webui', '')}"
506
+ title = p.get("title") or "(untitled)"
507
+ seen: dict[str, int] = {}
508
+ items = []
509
+ for path, text, raw in sections_with_raw(storage, self._users):
510
+ seen[path] = seen.get(path, 0) + 1
511
+ key = sha(f"{path}#{seen[path]}")[:16]
512
+ items.append(Item(
513
+ external_id=f"sec:{page_id}:{key}", author=(p.get("version") or {}).get("authorId") or "",
514
+ role="page", occurred_at=when,
515
+ content=f"Page «{title}» · section: {path}\n{text}",
516
+ meta={"kind": "confluence", "page_id": page_id, "path": path, "version": version, "url": url,
517
+ "section_key": key, "text_hash": sha(raw), "raw_hash": sha(f"{path}\n{raw}")}))
518
+ sid = f"{KIND}:page:{page_id}"
519
+ with self.db.tx() as conn:
520
+ st = conn.execute("SELECT * FROM streams WHERE id = %s", (sid,)).fetchone()
521
+ conn.execute(
522
+ """INSERT INTO streams (id, source_id, locator, title, meta, last_change_at)
523
+ VALUES (%s, %s, %s, %s, %s, now())
524
+ ON CONFLICT (id) DO UPDATE SET source_id = EXCLUDED.source_id, title = EXCLUDED.title,
525
+ locator = EXCLUDED.locator, meta = streams.meta || EXCLUDED.meta, status = 'active', updated_at = now()""",
526
+ (sid, source["id"], url, title, Jsonb({"confluence": "page", "page_id": page_id, "blog": blog,
527
+ "version_seen": version, "space": source["config"]["space_key"],
528
+ "author_id": p.get("authorId"),
529
+ "author_name": self._users.get(p.get("authorId") or "", "")})))
530
+ existing = {r["external_id"]: r for r in conn.execute(
531
+ "SELECT id, external_id, content_hash, meta FROM evidence WHERE stream_id = %s AND status = 'active'",
532
+ (sid,)).fetchall()}
533
+ current = {it.external_id: it for it in items}
534
+ changed = [ext for ext, it in current.items() if ext in existing
535
+ and existing[ext]["content_hash"] != it.meta["raw_hash"]]
536
+ removed = [ext for ext in existing if ext not in current]
537
+ added = [ext for ext in current if ext not in existing]
538
+ # A section only renamed or moved (same text, another key): not a content change. The old
539
+ # version becomes history, the new one keeps supporting the same claims, and nothing is re-extracted.
540
+ by_text = {current[e].meta["text_hash"]: e for e in added}
541
+ moved = {old: by_text[existing[old]["meta"].get("text_hash")] for old in removed
542
+ if existing[old]["meta"].get("text_hash") in by_text}
543
+ removed = [e for e in removed if e not in moved]
544
+ quiet = set(moved.values())
545
+ old_ids = [existing[e]["id"] for e in changed + removed + list(moved)]
546
+ if old_ids:
547
+ # Previous version of the section: kept as history (not a retraction).
548
+ conn.execute(
549
+ """UPDATE evidence SET status = 'superseded', external_id = NULL,
550
+ meta = meta || %s WHERE id = ANY(%s)""",
551
+ (Jsonb({"superseded_at": when.isoformat(), "superseded_by_version": version}), old_ids))
552
+ fresh = [it for ext, it in current.items() if ext in added or ext in changed]
553
+ delta = st is not None and bool(st["meta"].get("version_seen")) and st["status"] == "active"
554
+ meta = {"confluence": {"page_id": page_id, "title": title, "url": url, "version": version,
555
+ "space": source["config"]["space_key"], "kind": "page"}, "delta": delta}
556
+ for it in [it for it in fresh if it.external_id in quiet]:
557
+ _store_quiet(conn, source["id"], sid, it)
558
+ to_extract = [it for it in fresh if it.external_id not in quiet]
559
+ created = 0
560
+ for i, part in enumerate(_split(to_extract)):
561
+ if create_unit(conn, source_id=source["id"], stream_id=sid, kind="confluence_page",
562
+ title=f"{title} (v{version})" + (f" · part {i + 1}" if i else ""), items=part,
563
+ meta={**meta, "part": i + 1}, priority=40 if delta else None):
564
+ created += 1
565
+ for old_ext, new_ext in moved.items():
566
+ new_ev = conn.execute("SELECT id FROM evidence WHERE stream_id = %s AND external_id = %s",
567
+ (sid, new_ext)).fetchone()
568
+ for c in _section_claims(conn, sid, existing[old_ext]["meta"].get("section_key")):
569
+ from .. import events
570
+ events.append(conn, "claim_corroborated", "connector", {"id": c, "evidence_ids": [new_ev["id"]]})
571
+ # Claims of the section (from any previous version) → adjudicator, with both versions.
572
+ for ext in changed + removed:
573
+ claim_ids = _section_claims(conn, sid, existing[ext]["meta"].get("section_key"))
574
+ if not claim_ids:
575
+ continue
576
+ new_ev = conn.execute("SELECT id FROM evidence WHERE stream_id = %s AND external_id = %s",
577
+ (sid, ext)).fetchone() if ext in changed else None
578
+ what = "changed" if ext in changed else "no longer appears with this title"
579
+ hint = ("" if ext in changed else " It may have been deleted, or only renamed/moved with changed text: "
580
+ "read the current page (read_confluence) before deciding.")
581
+ K.open_tension(
582
+ conn, "connector", claim_ids,
583
+ f"A section of the page «{title}» {what} in version {version} of Confluence.",
584
+ dossier={"detector": "confluence_edit",
585
+ "hypothesis": (f"The section {what}.{hint} Decide, claim by claim: still holds (current), "
586
+ f"evolved (superseded: it was true until the edit) or was wrong (retracted). "
587
+ f"The edit may be a correction or an evolution; the new text is not absolute truth, "
588
+ f"and neither is the status declared on the page."),
589
+ "evidence_ids": [existing[ext]["id"]] + ([new_ev["id"]] if new_ev else []),
590
+ "page_id": page_id,
591
+ "reason": f"edit of the page {url} (version {version})"})
592
+ return created
593
+
594
+ # ------------------------------------------------------------ comments (retraction on edit)
595
+
596
+ def _read_comments(self, source: dict, page_id: str, now: datetime, blog: bool = False) -> int:
597
+ items = []
598
+ base = "blogposts" if blog else "pages"
599
+ for kind in ("footer", "inline"):
600
+ roots = list(self.api.pages(f"/api/v2/{base}/{page_id}/{kind}-comments", {"body-format": "storage"}))
601
+ for c in roots:
602
+ for cm, reply in [(c, False)] + [(r, True) for r in self.api.pages(
603
+ f"/api/v2/{kind}-comments/{c['id']}/children", {"body-format": "storage"})]:
604
+ items.append((kind, cm, reply, c))
605
+ ids = {(cm.get("version") or {}).get("authorId") or "" for _k, cm, _r, _c in items}
606
+ for _k, cm, _r, _c in items:
607
+ ids |= set(_MENTION.findall(((cm.get("body") or {}).get("storage") or {}).get("value") or ""))
608
+ users_names(self.api, ids, self._users)
609
+ evid = []
610
+ for kind, cm, reply, root in items:
611
+ author = (cm.get("version") or {}).get("authorId")
612
+ if self._excluded(author):
613
+ continue
614
+ storage = ((cm.get("body") or {}).get("storage") or {}).get("value") or ""
615
+ body = "\n".join(t for _p, t in sections_of(storage, self._users))
616
+ anchor = ((root.get("properties") or {}).get("inlineOriginalSelection") or "") if kind == "inline" else ""
617
+ resolved = (root.get("resolutionStatus") or "") if kind == "inline" else ""
618
+ head = (f"{'Reply' if reply else 'Comment'} {'inline' if kind == 'inline' else ''} by {self._who(author)}"
619
+ + (" (resolved)" if resolved == "resolved" else "") + ":").replace(" ", " ")
620
+ evid.append(Item(
621
+ external_id=f"comment:{cm['id']}", author=author or "", role="comment",
622
+ occurred_at=_dt((cm.get("version") or {}).get("createdAt")) or now,
623
+ content=head + (f"\nCommented excerpt: «{anchor}»" if anchor else "") + f"\n{body}",
624
+ meta={"kind": "confluence_comment", "page_id": page_id, "root": str(root["id"]),
625
+ "author_name": self._users.get(author or "", ""), "raw_hash": sha(storage + anchor)}))
626
+ sid = f"{KIND}:comments:{page_id}"
627
+ created = 0
628
+ with self.db.tx() as conn:
629
+ page = conn.execute("SELECT title, locator FROM streams WHERE id = %s", (f"{KIND}:page:{page_id}",)).fetchone()
630
+ title = page["title"] if page else page_id
631
+ conn.execute(
632
+ """INSERT INTO streams (id, source_id, locator, title, meta, last_change_at)
633
+ VALUES (%s, %s, %s, %s, %s, now())
634
+ ON CONFLICT (id) DO UPDATE SET source_id = EXCLUDED.source_id, status = 'active', updated_at = now()""",
635
+ (sid, source["id"], page["locator"] if page else "", f"Comments · {title}",
636
+ Jsonb({"confluence": "comments", "page_id": page_id})))
637
+ fresh = sync_stream(conn, source_id=source["id"], stream_id=sid, items=evid, complete=True,
638
+ reason="comment edited, deleted in Confluence or outside the filters")
639
+ for i, part in enumerate(_split(fresh)):
640
+ if create_unit(conn, source_id=source["id"], stream_id=sid, kind="confluence_comments",
641
+ title=f"Comments · {title}" + (f" · part {i + 1}" if i else ""), items=part,
642
+ meta={"confluence": {"page_id": page_id, "title": title, "kind": "comments",
643
+ "url": page["locator"] if page else ""}}):
644
+ created += 1
645
+ conn.execute("UPDATE streams SET meta = meta || %s WHERE id = %s",
646
+ (Jsonb({"comments_at": now.timestamp()}), f"{KIND}:page:{page_id}"))
647
+ return created
648
+
649
+
650
+ def _section_claims(conn, stream_id: str, section_key: str | None) -> list[str]:
651
+ """Current claims supported by any version of this section (the current one or those kept as history):
652
+ a claim kept through an edit keeps following the later edits."""
653
+ if not section_key:
654
+ return []
655
+ return [r["id"] for r in conn.execute(
656
+ """SELECT DISTINCT c.id FROM claims c JOIN evidence e ON e.id = ANY(c.evidence_ids)
657
+ WHERE e.stream_id = %s AND e.meta->>'section_key' = %s AND e.status IN ('active', 'superseded')
658
+ AND c.status IN ('current', 'disputed')""", (stream_id, section_key)).fetchall()]
659
+
660
+
661
+ def _store_quiet(conn, source_id: str, stream_id: str, it: Item) -> None:
662
+ """Evidence without a unit: the section only changed place or title (the text is the same)."""
663
+ from .common import content_hash
664
+ conn.execute(
665
+ """INSERT INTO evidence (id, unit_id, source_id, stream_id, external_id, author, role, occurred_at,
666
+ content, content_hash, meta)
667
+ VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
668
+ (new_id("ev"), source_id, stream_id, it.external_id, it.author, it.role, it.occurred_at, it.content,
669
+ content_hash(it), Jsonb(it.meta)))
670
+
671
+
672
+ def _split(items: list[Item], limit: int = MAX_UNIT_CHARS) -> list[list[Item]]:
673
+ parts, current, size = [], [], 0
674
+ for it in items:
675
+ if current and size + len(it.content) > limit:
676
+ parts.append(current)
677
+ current, size = [], 0
678
+ current.append(it)
679
+ size += len(it.content)
680
+ if current:
681
+ parts.append(current)
682
+ return parts
683
+
684
+
685
+ # ---------------------------------------------------------------- context, estimate, live read
686
+
687
+
688
+ def reapply_filters(db, source: dict, cfg: dict) -> dict:
689
+ """The ignored-authors list changed: what came only from them goes away (pages created by them and
690
+ their comments); what holds again is re-read on the next read (the comments of all
691
+ pages are re-read, and removed pages come back as restored)."""
692
+ from .. import knowledge as K
693
+ excluded = {a.lower() for a in cfg.get("exclude_authors") or []}
694
+ out = {"retracted": 0, "pages": 0}
695
+ with db.tx() as conn:
696
+ pages = conn.execute(
697
+ """SELECT * FROM streams WHERE source_id = %s AND meta->>'confluence' = 'page' AND status = 'active'""",
698
+ (source["id"],)).fetchall()
699
+ connector = ConfluenceConnector(db, None, cfg, "")
700
+ for st in pages:
701
+ if excluded & {(st["meta"].get("author_id") or "").lower(), (st["meta"].get("author_name") or "").lower()}:
702
+ connector._remove_page(source, st["meta"]["page_id"], "author ignored by the owner")
703
+ out["pages"] += 1
704
+ with db.tx() as conn:
705
+ ids = [r["id"] for r in conn.execute(
706
+ """SELECT id FROM evidence WHERE source_id = %s AND status = 'active' AND role = 'comment'
707
+ AND (lower(author) = ANY(%s) OR lower(meta->>'author_name') = ANY(%s))""",
708
+ (source["id"], list(excluded), list(excluded))).fetchall()]
709
+ if ids:
710
+ K.tombstone_evidence(conn, "connector", ids, "outside the owner's filters")
711
+ conn.execute("UPDATE evidence SET external_id = NULL WHERE id = ANY(%s)", (ids,))
712
+ out["retracted"] = len(ids)
713
+ conn.execute("""UPDATE streams SET meta = meta - 'comments_at' WHERE source_id = %s""", (source["id"],))
714
+ return out
715
+
716
+
717
+ def unit_context(conn, unit: dict, evidence: list[dict]) -> list[dict]:
718
+ """A changed section arrives with the rest of the page as context; a comment arrives with its thread (the
719
+ comment it replies to and the earlier replies) and with the page. Only what is active, up to
720
+ CONTEXT_CHARS."""
721
+ m = (unit.get("meta") or {}).get("confluence") or {}
722
+ if not m:
723
+ return []
724
+ ids = [e["id"] for e in evidence]
725
+ thread = []
726
+ if m.get("kind") == "comments":
727
+ roots = {(e.get("meta") or {}).get("root") for e in evidence} - {None}
728
+ first = min(e["occurred_at"] for e in evidence)
729
+ thread = conn.execute(
730
+ """SELECT * FROM evidence WHERE stream_id = %s AND status = 'active' AND NOT (id = ANY(%s))
731
+ AND meta->>'root' = ANY(%s) AND occurred_at <= %s ORDER BY occurred_at""",
732
+ (unit["stream_id"], ids, list(roots), first)).fetchall()
733
+ page = conn.execute(
734
+ """SELECT * FROM evidence WHERE stream_id = %s AND status = 'active' AND NOT (id = ANY(%s))
735
+ ORDER BY seq""", (f"{KIND}:page:{m['page_id']}", ids)).fetchall()
736
+ out, size = [], 0
737
+ for r in list(thread) + list(page):
738
+ if size + len(r["content"]) > CONTEXT_CHARS:
739
+ break
740
+ out.append(r)
741
+ size += len(r["content"])
742
+ return out
743
+
744
+
745
+ def estimate_backfill(client: ConfluenceClient, source: dict, usd_per_char: float, read_pages: set[str]) -> dict:
746
+ """Before turning on: the text of the pages in scope (no LLM) × measured average cost per character.
747
+ Pages already read are left out of what remains."""
748
+ cfg = source["config"]
749
+ pages = space_pages(client, cfg["space_id"], with_body=True)
750
+ if cfg.get("root_id"):
751
+ pages = tree_of(pages, cfg["root_id"], space_nodes(client, cfg["space_id"], pages))
752
+ chars = new_chars = new = 0
753
+ for p in pages:
754
+ n = sum(len(t) for _path, t in sections_of(((p.get("body") or {}).get("storage") or {}).get("value") or ""))
755
+ chars += n
756
+ if str(p["id"]) not in read_pages:
757
+ new += 1
758
+ new_chars += n
759
+ return {"pages": len(pages), "chars": chars, "usd": round(chars * usd_per_char, 2), "new": new,
760
+ "usd_new": round(new_chars * usd_per_char, 2), "usd_per_char": usd_per_char,
761
+ "at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
762
+
763
+
764
+ def page_ref(ref: str) -> tuple[str | None, bool]:
765
+ """Page ID (and whether it is a blog post) from an ID, a page URL, a blog URL or `viewpage.action`."""
766
+ ref = ref.strip()
767
+ if ref.isdigit():
768
+ return ref, False
769
+ for pattern, blog in ((r"/blog/(?:\d{4}/\d{2}/\d{2}/)?(\d+)", True), (r"/pages/(\d+)", False),
770
+ (r"[?&]pageId=(\d+)", False)):
771
+ m = re.search(pattern, ref)
772
+ if m:
773
+ return m.group(1), blog
774
+ return None, False
775
+
776
+
777
+ def read_live(client: ConfluenceClient, ref: str) -> tuple[str, str, str]:
778
+ """Current page (ID or URL), as it is now: (locator, title, text). "Not found" is not "deleted":
779
+ it may be lack of access or a link I cannot read."""
780
+ page_id, blog = page_ref(ref)
781
+ if not page_id:
782
+ return ref, "", "cannot read this link (use the page ID or the full URL; short /x/ links are not supported)"
783
+ p = None
784
+ for kind in (("blogposts", "pages") if blog else ("pages", "blogposts")):
785
+ try:
786
+ p = client.get(f"/api/v2/{kind}/{page_id}", {"body-format": "storage"})
787
+ break
788
+ except NotFound:
789
+ continue
790
+ if p is None:
791
+ return ref, "", "could not find the page with this token (it may have been deleted or be restricted)"
792
+ if p.get("status") in ("trashed", "deleted", "purged"):
793
+ return ref, p.get("title") or page_id, "the page is in the Confluence trash (deleted)"
794
+ storage = ((p.get("body") or {}).get("storage") or {}).get("value") or ""
795
+ text = "\n\n".join(f"## {path}\n{t}" for path, t in sections_of(storage))
796
+ url = f"https://{client.site}/wiki{(p.get('_links') or {}).get('webui', '')}"
797
+ return url, p.get("title") or page_id, f"{p.get('title')} (version {(p.get('version') or {}).get('number')})\n\n{text}"
798
+
799
+
800
+ def client_from(cfg: dict) -> ConfluenceClient:
801
+ email, token = resolve_credentials(cfg)
802
+ return ConfluenceClient(cfg["site"], email, token, cfg.get("token_type") or "scoped", cloud_id=cfg.get("cloud_id"))
803
+