sessionwiki 2.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 (118) hide show
  1. sessionwiki/__init__.py +74 -0
  2. sessionwiki/__main__.py +6 -0
  3. sessionwiki/_frontmatter.py +118 -0
  4. sessionwiki/_system_pages.py +44 -0
  5. sessionwiki/adapter_config.py +124 -0
  6. sessionwiki/adapters/__init__.py +134 -0
  7. sessionwiki/adapters/base.py +191 -0
  8. sessionwiki/adapters/claude_code.py +77 -0
  9. sessionwiki/adapters/codex_cli.py +226 -0
  10. sessionwiki/adapters/contrib/__init__.py +1 -0
  11. sessionwiki/adapters/contrib/chatgpt.py +252 -0
  12. sessionwiki/adapters/contrib/copilot_chat.py +121 -0
  13. sessionwiki/adapters/contrib/copilot_cli.py +115 -0
  14. sessionwiki/adapters/contrib/cursor.py +100 -0
  15. sessionwiki/adapters/contrib/gemini_cli.py +74 -0
  16. sessionwiki/adapters/contrib/obsidian.py +121 -0
  17. sessionwiki/adapters/opencode.py +169 -0
  18. sessionwiki/adapters/status.py +58 -0
  19. sessionwiki/adapters/zcode.py +45 -0
  20. sessionwiki/audit.py +174 -0
  21. sessionwiki/backlinks.py +224 -0
  22. sessionwiki/build.py +2786 -0
  23. sessionwiki/cache.py +401 -0
  24. sessionwiki/candidates.py +309 -0
  25. sessionwiki/categories.py +158 -0
  26. sessionwiki/changelog_timeline.py +422 -0
  27. sessionwiki/cli.py +1687 -0
  28. sessionwiki/compare.py +404 -0
  29. sessionwiki/completion.py +191 -0
  30. sessionwiki/confidence.py +179 -0
  31. sessionwiki/config.py +74 -0
  32. sessionwiki/config_schedule.py +77 -0
  33. sessionwiki/context_md.py +182 -0
  34. sessionwiki/convert.py +1746 -0
  35. sessionwiki/docs_pages.py +725 -0
  36. sessionwiki/doctor.py +113 -0
  37. sessionwiki/exporters.py +680 -0
  38. sessionwiki/freshness.py +153 -0
  39. sessionwiki/graph.py +1058 -0
  40. sessionwiki/graphify_bridge.py +509 -0
  41. sessionwiki/ingest_queue.py +101 -0
  42. sessionwiki/lifecycle.py +167 -0
  43. sessionwiki/link_checker.py +148 -0
  44. sessionwiki/lint/__init__.py +170 -0
  45. sessionwiki/lint/rules/__init__.py +69 -0
  46. sessionwiki/lint/rules/_helpers.py +119 -0
  47. sessionwiki/lint/rules/claim_verification.py +44 -0
  48. sessionwiki/lint/rules/content_freshness.py +41 -0
  49. sessionwiki/lint/rules/contradiction_detection.py +45 -0
  50. sessionwiki/lint/rules/duplicate_detection.py +164 -0
  51. sessionwiki/lint/rules/entity_consistency.py +29 -0
  52. sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
  53. sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
  54. sessionwiki/lint/rules/frontmatter_validity.py +71 -0
  55. sessionwiki/lint/rules/index_sync.py +68 -0
  56. sessionwiki/lint/rules/link_integrity.py +34 -0
  57. sessionwiki/lint/rules/orphan_detection.py +43 -0
  58. sessionwiki/lint/rules/stale_candidates.py +59 -0
  59. sessionwiki/lint/rules/stale_reference_detection.py +42 -0
  60. sessionwiki/lint/rules/summary_accuracy.py +36 -0
  61. sessionwiki/lint/rules/tags_topics_convention.py +58 -0
  62. sessionwiki/lint/rules/tools_consistency.py +65 -0
  63. sessionwiki/lock.py +126 -0
  64. sessionwiki/log_reader.py +129 -0
  65. sessionwiki/manifest.py +132 -0
  66. sessionwiki/mcp/__init__.py +29 -0
  67. sessionwiki/mcp/__main__.py +5 -0
  68. sessionwiki/mcp/server.py +1061 -0
  69. sessionwiki/models_page.py +291 -0
  70. sessionwiki/obsidian_output.py +208 -0
  71. sessionwiki/opencode/__init__.py +1 -0
  72. sessionwiki/opencode/autosync.py +102 -0
  73. sessionwiki/opencode/export.py +419 -0
  74. sessionwiki/opencode/install.py +152 -0
  75. sessionwiki/pipeline.py +150 -0
  76. sessionwiki/project_topics.py +213 -0
  77. sessionwiki/py.typed +0 -0
  78. sessionwiki/quarantine.py +273 -0
  79. sessionwiki/queue.py +29 -0
  80. sessionwiki/references.py +224 -0
  81. sessionwiki/render/__init__.py +20 -0
  82. sessionwiki/render/css.py +988 -0
  83. sessionwiki/render/docs_css.py +463 -0
  84. sessionwiki/render/js.py +1337 -0
  85. sessionwiki/schema.py +270 -0
  86. sessionwiki/search_facets.py +150 -0
  87. sessionwiki/search_tree.py +184 -0
  88. sessionwiki/serve.py +91 -0
  89. sessionwiki/session_export.py +333 -0
  90. sessionwiki/skill_installer.py +112 -0
  91. sessionwiki/sync/__init__.py +13 -0
  92. sessionwiki/sync/status.py +120 -0
  93. sessionwiki/synth/__init__.py +14 -0
  94. sessionwiki/synth/agent_delegate.py +327 -0
  95. sessionwiki/synth/base.py +125 -0
  96. sessionwiki/synth/cli_helpers.py +98 -0
  97. sessionwiki/synth/estimate.py +146 -0
  98. sessionwiki/synth/ollama.py +379 -0
  99. sessionwiki/synth/openai_compat.py +325 -0
  100. sessionwiki/synth/pipeline.py +1489 -0
  101. sessionwiki/tag_utils.py +62 -0
  102. sessionwiki/tags.py +359 -0
  103. sessionwiki/vault.py +347 -0
  104. sessionwiki/viz_heatmap.py +305 -0
  105. sessionwiki/viz_tokens.py +429 -0
  106. sessionwiki/viz_tools.py +241 -0
  107. sessionwiki/watch.py +154 -0
  108. sessionwiki/zcode/__init__.py +43 -0
  109. sessionwiki/zcode/autosync.py +150 -0
  110. sessionwiki/zcode/export.py +568 -0
  111. sessionwiki/zcode/install.py +386 -0
  112. sessionwiki-2.0.0.dist-info/METADATA +303 -0
  113. sessionwiki-2.0.0.dist-info/RECORD +118 -0
  114. sessionwiki-2.0.0.dist-info/WHEEL +5 -0
  115. sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
  116. sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
  117. sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
  118. sessionwiki-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,379 @@
1
+ """Ollama backend for local LLM synthesis (v1.1.0 · #35).
2
+
3
+ Provides ``OllamaSynthesizer``, a stdlib-only HTTP client for Ollama's
4
+ ``/api/generate`` endpoint. The dependency is **optional**: the default
5
+ sessionwiki install stays on stdlib + ``markdown``, and this module only
6
+ touches ``urllib`` (also stdlib), so there is nothing extra to install.
7
+
8
+ Ollama must be running locally — by default at ``http://127.0.0.1:11434``.
9
+ This keeps synthesis private by default (no data ever leaves the box).
10
+
11
+ Design notes
12
+ ------------
13
+ - **Privacy by default**: ``base_url`` defaults to 127.0.0.1. If the user
14
+ points the backend at a remote host we log a warning once so they
15
+ know they've left the local-only path.
16
+ - **Graceful fallback**: ``is_available()`` probes ``/api/tags`` with a
17
+ short timeout. If the server is down ``synthesize_source_page()`` raises
18
+ ``OllamaUnavailableError``; the caller (``pipeline.synthesize_new_sessions``)
19
+ catches that, logs a warning, and skips the file without crashing the
20
+ sync.
21
+ - **Retries**: transient 5xx or ``socket.timeout`` errors retry with
22
+ exponential backoff (default 3 attempts, 0.5/1.0/2.0s). Connection
23
+ refused errors short-circuit — the server is simply not running.
24
+ - **No streaming**: we send ``stream: false`` because callers want the
25
+ complete synthesised page back, not a token stream. Streaming can land
26
+ later if a use case for it appears.
27
+
28
+ Configuration (``sessions_config.json`` / ``config.json``)::
29
+
30
+ "synthesis": {
31
+ "backend": "ollama",
32
+ "model": "llama3.1:8b",
33
+ "base_url": "http://127.0.0.1:11434",
34
+ "timeout": 60,
35
+ "max_retries": 3
36
+ }
37
+
38
+ Config parsing is done in :func:`load_ollama_config` so the CLI can
39
+ surface readable errors instead of stack traces.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import logging
46
+ import socket
47
+ import time
48
+ import urllib.error
49
+ import urllib.parse
50
+ import urllib.request
51
+ from dataclasses import dataclass
52
+ from typing import Any, Optional
53
+
54
+ from sessionwiki.synth.base import BaseSynthesizer
55
+
56
+ # ─── Constants ─────────────────────────────────────────────────────────
57
+
58
+ DEFAULT_BASE_URL = "http://127.0.0.1:11434"
59
+ DEFAULT_MODEL = "llama3.1:8b"
60
+ DEFAULT_TIMEOUT = 60 # seconds, per HTTP call
61
+ DEFAULT_MAX_RETRIES = 3 # includes the first attempt
62
+ DEFAULT_BACKOFF_BASE = 0.5 # seconds; doubles each retry
63
+
64
+ LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+
69
+ # ─── Exceptions ────────────────────────────────────────────────────────
70
+
71
+
72
+ class OllamaError(RuntimeError):
73
+ """Base class for Ollama backend failures."""
74
+
75
+
76
+ class OllamaUnavailableError(OllamaError):
77
+ """Raised when the Ollama server is unreachable (connection refused,
78
+ DNS failure, or health check fails)."""
79
+
80
+
81
+ class OllamaHTTPError(OllamaError):
82
+ """Raised when the server returns a non-2xx after exhausting retries."""
83
+
84
+ def __init__(self, status: int, body: str):
85
+ super().__init__(f"Ollama returned HTTP {status}: {body[:200]}")
86
+ self.status = status
87
+ self.body = body
88
+
89
+
90
+ # ─── Config ────────────────────────────────────────────────────────────
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class OllamaConfig:
95
+ """Resolved configuration for :class:`OllamaSynthesizer`."""
96
+
97
+ model: str = DEFAULT_MODEL
98
+ base_url: str = DEFAULT_BASE_URL
99
+ timeout: int = DEFAULT_TIMEOUT
100
+ max_retries: int = DEFAULT_MAX_RETRIES
101
+ backoff_base: float = DEFAULT_BACKOFF_BASE
102
+
103
+ @property
104
+ def generate_url(self) -> str:
105
+ return f"{self.base_url.rstrip('/')}/api/generate"
106
+
107
+ @property
108
+ def tags_url(self) -> str:
109
+ return f"{self.base_url.rstrip('/')}/api/tags"
110
+
111
+ @property
112
+ def is_local(self) -> bool:
113
+ """True if base_url resolves to localhost (privacy check)."""
114
+ try:
115
+ host = urllib.parse.urlparse(self.base_url).hostname or ""
116
+ except ValueError:
117
+ return False
118
+ return host in LOCAL_HOSTS
119
+
120
+
121
+ def load_ollama_config(cfg: Optional[dict[str, Any]]) -> OllamaConfig:
122
+ """Build an :class:`OllamaConfig` from the ``synthesis`` block of
123
+ ``sessions_config.json``.
124
+
125
+ Missing keys fall back to the module-level defaults so first-time
126
+ users don't have to configure anything to try it out::
127
+
128
+ { "synthesis": { "backend": "ollama" } }
129
+
130
+ is enough to reach a working local default.
131
+ """
132
+ synth = (cfg or {}).get("synthesis", {}) or {}
133
+ # Use `in` checks (not `or`) so an explicit 0 fails validation instead
134
+ # of being silently swapped for the default.
135
+ model = synth.get("model") or DEFAULT_MODEL
136
+ base_url = synth.get("base_url") or DEFAULT_BASE_URL
137
+ timeout = int(synth["timeout"]) if "timeout" in synth else DEFAULT_TIMEOUT
138
+ max_retries = (
139
+ int(synth["max_retries"]) if "max_retries" in synth else DEFAULT_MAX_RETRIES
140
+ )
141
+ backoff_base = (
142
+ float(synth["backoff_base"])
143
+ if "backoff_base" in synth
144
+ else DEFAULT_BACKOFF_BASE
145
+ )
146
+
147
+ if timeout <= 0:
148
+ raise ValueError(f"synthesis.timeout must be positive, got {timeout}")
149
+ if max_retries < 1:
150
+ raise ValueError(
151
+ f"synthesis.max_retries must be >= 1, got {max_retries}"
152
+ )
153
+
154
+ resolved = OllamaConfig(
155
+ model=model,
156
+ base_url=base_url,
157
+ timeout=timeout,
158
+ max_retries=max_retries,
159
+ backoff_base=backoff_base,
160
+ )
161
+
162
+ if not resolved.is_local:
163
+ logger.warning(
164
+ "Ollama backend pointed at non-local host %s — transcript data "
165
+ "will leave this machine. Set synthesis.base_url to http://127.0.0.1:11434 "
166
+ "to restore privacy-by-default.",
167
+ resolved.base_url,
168
+ )
169
+
170
+ return resolved
171
+
172
+
173
+ # ─── Synthesizer ───────────────────────────────────────────────────────
174
+
175
+
176
+ class OllamaSynthesizer(BaseSynthesizer):
177
+ """Synthesize wiki source pages via a local Ollama HTTP server.
178
+
179
+ The implementation uses only ``urllib`` so no third-party HTTP client
180
+ is required. Test injection uses the ``http_post`` / ``http_get``
181
+ kwargs so ``unittest.mock`` or a hand-rolled fake can substitute the
182
+ transport layer without a real socket.
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ config: Optional[OllamaConfig] = None,
188
+ *,
189
+ http_post: Optional[Any] = None,
190
+ http_get: Optional[Any] = None,
191
+ ):
192
+ self.config = config or OllamaConfig()
193
+ self._http_post = http_post or _urlopen_post
194
+ self._http_get = http_get or _urlopen_get
195
+
196
+ # ---- BaseSynthesizer interface --------------------------------
197
+
198
+ def is_available(self) -> bool:
199
+ """Probe ``/api/tags`` with a 2-second timeout.
200
+
201
+ Returns True iff the server responds 2xx. Any exception
202
+ (connection refused, DNS failure, HTTP 5xx, etc.) is swallowed
203
+ and returns False — callers should branch on this before calling
204
+ :meth:`synthesize_source_page`.
205
+ """
206
+ try:
207
+ status, _ = self._http_get(
208
+ self.config.tags_url, timeout=min(self.config.timeout, 2)
209
+ )
210
+ return 200 <= status < 300
211
+ except Exception as exc: # noqa: BLE001 — probe must never raise
212
+ logger.debug("Ollama availability probe failed: %s", exc)
213
+ return False
214
+
215
+ def synthesize_source_page(
216
+ self,
217
+ raw_body: str,
218
+ meta: dict[str, Any],
219
+ prompt_template: str,
220
+ ) -> str:
221
+ """Render ``prompt_template`` with the session body + metadata
222
+ and send it to Ollama. Returns the model's raw completion text.
223
+
224
+ Raises
225
+ ------
226
+ OllamaUnavailableError
227
+ The server could not be reached at all (connection refused,
228
+ DNS failure, etc.). Callers should skip synthesis and move on.
229
+ OllamaHTTPError
230
+ The server returned a non-2xx response after all retries.
231
+ """
232
+ # #py-h7 (#585): pipeline used to pre-render the prompt for us
233
+ # (with `body[:8000]` truncation + a `key: value` meta format),
234
+ # but that violated the BaseSynthesizer contract. Now we own the
235
+ # render. Mirror the previous 8 KB body cap so very long sessions
236
+ # don't blow Ollama's context window — agent_delegate uses the
237
+ # same cap; centralise here so the limit lives next to the prompt.
238
+ truncated_body = raw_body[:8000] if raw_body else ""
239
+ prompt = _render_prompt(prompt_template, raw_body=truncated_body, meta=meta)
240
+ payload = {
241
+ "model": self.config.model,
242
+ "prompt": prompt,
243
+ "stream": False,
244
+ }
245
+
246
+ data = self._call_generate(payload)
247
+ response = data.get("response", "")
248
+ if not isinstance(response, str):
249
+ raise OllamaError(
250
+ f"Ollama returned non-string response: {type(response).__name__}"
251
+ )
252
+ return response.strip()
253
+
254
+ # ---- internals -----------------------------------------------
255
+
256
+ def _call_generate(self, payload: dict[str, Any]) -> dict[str, Any]:
257
+ """POST to /api/generate with retry + backoff."""
258
+ last_exc: Optional[Exception] = None
259
+ for attempt in range(1, self.config.max_retries + 1):
260
+ try:
261
+ status, body = self._http_post(
262
+ self.config.generate_url,
263
+ payload,
264
+ timeout=self.config.timeout,
265
+ )
266
+ except OllamaUnavailableError:
267
+ # Connection refused / DNS failure — don't retry. The
268
+ # server simply isn't listening; retrying wastes time.
269
+ raise
270
+ except (socket.timeout, urllib.error.URLError) as exc:
271
+ last_exc = exc
272
+ logger.warning(
273
+ "Ollama request attempt %d/%d failed: %s",
274
+ attempt,
275
+ self.config.max_retries,
276
+ exc,
277
+ )
278
+ if attempt == self.config.max_retries:
279
+ raise OllamaError(f"Ollama call failed: {exc}") from exc
280
+ time.sleep(self.config.backoff_base * (2 ** (attempt - 1)))
281
+ continue
282
+
283
+ if 200 <= status < 300:
284
+ try:
285
+ return json.loads(body)
286
+ except ValueError as exc:
287
+ raise OllamaError(
288
+ f"Ollama returned non-JSON body: {exc}"
289
+ ) from exc
290
+
291
+ if 500 <= status < 600 and attempt < self.config.max_retries:
292
+ logger.warning(
293
+ "Ollama %s returned %d; retrying (%d/%d)",
294
+ self.config.generate_url,
295
+ status,
296
+ attempt,
297
+ self.config.max_retries,
298
+ )
299
+ time.sleep(self.config.backoff_base * (2 ** (attempt - 1)))
300
+ continue
301
+
302
+ raise OllamaHTTPError(status, body)
303
+
304
+ # Unreachable if max_retries >= 1, but keep the type checker honest
305
+ raise OllamaError(f"Ollama call failed after retries: {last_exc}")
306
+
307
+
308
+ # ─── HTTP transport (stdlib) ──────────────────────────────────────────
309
+
310
+
311
+ def _urlopen_post(
312
+ url: str, payload: dict[str, Any], *, timeout: float
313
+ ) -> tuple[int, str]:
314
+ """POST JSON and return (status, body) as text. Raises
315
+ :class:`OllamaUnavailableError` on connection refused / DNS failure."""
316
+ body = json.dumps(payload).encode("utf-8")
317
+ req = urllib.request.Request(
318
+ url,
319
+ data=body,
320
+ headers={"Content-Type": "application/json"},
321
+ method="POST",
322
+ )
323
+ try:
324
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
325
+ return resp.status, resp.read().decode("utf-8", errors="replace")
326
+ except urllib.error.HTTPError as exc:
327
+ # Server responded, but with an error code — treat as protocol
328
+ # failure and return the status/body so retry logic can decide.
329
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
330
+ return exc.code, body
331
+ except urllib.error.URLError as exc:
332
+ reason = exc.reason
333
+ if isinstance(reason, ConnectionRefusedError):
334
+ raise OllamaUnavailableError(
335
+ f"Ollama server refused connection at {url}. "
336
+ "Is `ollama serve` running?"
337
+ ) from exc
338
+ if isinstance(reason, socket.gaierror):
339
+ raise OllamaUnavailableError(
340
+ f"Ollama host DNS lookup failed for {url}: {reason}"
341
+ ) from exc
342
+ raise
343
+
344
+
345
+ def _urlopen_get(url: str, *, timeout: float) -> tuple[int, str]:
346
+ """GET and return (status, body). Same error-mapping rules as POST."""
347
+ try:
348
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
349
+ return resp.status, resp.read().decode("utf-8", errors="replace")
350
+ except urllib.error.HTTPError as exc:
351
+ body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
352
+ return exc.code, body
353
+ except urllib.error.URLError as exc:
354
+ reason = exc.reason
355
+ if isinstance(reason, ConnectionRefusedError):
356
+ raise OllamaUnavailableError(
357
+ f"Ollama server refused connection at {url}."
358
+ ) from exc
359
+ if isinstance(reason, socket.gaierror):
360
+ raise OllamaUnavailableError(
361
+ f"Ollama host DNS lookup failed for {url}: {reason}"
362
+ ) from exc
363
+ raise
364
+
365
+
366
+ # ─── Prompt rendering ─────────────────────────────────────────────────
367
+
368
+
369
+ def _render_prompt(
370
+ template: str, *, raw_body: str, meta: dict[str, Any]
371
+ ) -> str:
372
+ """Substitute ``{body}`` and ``{meta}`` placeholders in the template.
373
+
374
+ We deliberately use ``str.replace`` (not ``.format``) because session
375
+ bodies contain ``{}`` in code blocks — calling ``.format`` there would
376
+ raise ``KeyError``.
377
+ """
378
+ meta_dump = json.dumps(meta, indent=2, default=str, sort_keys=True)
379
+ return template.replace("{body}", raw_body).replace("{meta}", meta_dump)
@@ -0,0 +1,325 @@
1
+ """OpenAI-compatible backend for LLM synthesis.
2
+
3
+ Talks to any server that exposes the OpenAI ``/v1`` chat-completions
4
+ surface: vLLM, SGLang, LM Studio, LiteLLM, or a hosted provider. Stdlib
5
+ only — the same ``urllib`` approach as the Ollama backend, no SDK.
6
+
7
+ Configuration (``examples/sessions_config.json``)::
8
+
9
+ "synthesis": {
10
+ "backend": "openai",
11
+ "model": "qwen3.8",
12
+ "base_url": "http://127.0.0.1:8000/v1",
13
+ "api_key": "",
14
+ "timeout": 180,
15
+ "max_retries": 3,
16
+ "max_tokens": 1500,
17
+ "temperature": 0.1,
18
+ "max_concurrency": 10
19
+ }
20
+
21
+ ``base_url`` must include the API prefix (``/v1``). ``api_key`` is
22
+ optional; when set it is sent as ``Authorization: Bearer``.
23
+
24
+ Thinking models (Qwen3) are told to skip the reasoning phase with
25
+ ``chat_template_kwargs: {"enable_thinking": false}``. The flag can be
26
+ disabled with ``"enable_thinking": true`` in config, in which case the
27
+ backend falls back to ``reasoning_content`` when ``content`` is empty.
28
+ A strict server that rejects ``chat_template_kwargs`` with HTTP 400 is
29
+ retried once without the field.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import logging
36
+ import time
37
+ import urllib.error
38
+ import urllib.parse
39
+ import urllib.request
40
+ from dataclasses import dataclass
41
+ from typing import Any
42
+
43
+ from sessionwiki.synth.base import BaseSynthesizer
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ DEFAULT_BASE_URL = "http://127.0.0.1:8001/v1"
48
+ DEFAULT_MODEL = "qwen3.8"
49
+ DEFAULT_TIMEOUT = 180
50
+ DEFAULT_MAX_RETRIES = 3
51
+ DEFAULT_BACKOFF_BASE = 1.0
52
+ DEFAULT_MAX_TOKENS = 1500
53
+ DEFAULT_TEMPERATURE = 0.1
54
+
55
+
56
+ class OpenAICompatError(RuntimeError):
57
+ """Base class for OpenAI-compatible backend failures."""
58
+
59
+
60
+ class OpenAICompatUnavailableError(OpenAICompatError):
61
+ """Raised when the server is unreachable or the health probe fails."""
62
+
63
+
64
+ class OpenAICompatHTTPError(OpenAICompatError):
65
+ """Raised when the server returns a non-2xx response.
66
+
67
+ Retryable statuses (429, 5xx) are retried by ``_post_chat``; other
68
+ statuses reach the caller immediately.
69
+ """
70
+
71
+ def __init__(self, status: int, body: str):
72
+ super().__init__(f"OpenAI-compatible server returned HTTP {status}: {body[:200]}")
73
+ self.status = status
74
+ self.body = body
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class OpenAICompatConfig:
79
+ base_url: str = DEFAULT_BASE_URL
80
+ model: str = DEFAULT_MODEL
81
+ api_key: str = ""
82
+ timeout: int = DEFAULT_TIMEOUT
83
+ max_retries: int = DEFAULT_MAX_RETRIES
84
+ backoff_base: float = DEFAULT_BACKOFF_BASE
85
+ max_tokens: int = DEFAULT_MAX_TOKENS
86
+ temperature: float = DEFAULT_TEMPERATURE
87
+ enable_thinking: bool = False
88
+
89
+ @property
90
+ def chat_url(self) -> str:
91
+ return f"{self.base_url.rstrip('/')}/chat/completions"
92
+
93
+ @property
94
+ def models_url(self) -> str:
95
+ return f"{self.base_url.rstrip('/')}/models"
96
+
97
+ @property
98
+ def is_local(self) -> bool:
99
+ host = urllib.parse.urlsplit(self.base_url).hostname or ""
100
+ return host in {"127.0.0.1", "localhost", "::1"}
101
+
102
+
103
+ def load_openai_config(cfg: dict[str, Any] | None) -> OpenAICompatConfig:
104
+ synth = (cfg or {}).get("synthesis", {}) or {}
105
+ model = synth.get("model") or DEFAULT_MODEL
106
+ base_url = synth.get("base_url") or DEFAULT_BASE_URL
107
+ api_key = str(synth.get("api_key") or "")
108
+ timeout = int(synth["timeout"]) if "timeout" in synth else DEFAULT_TIMEOUT
109
+ max_retries = (
110
+ int(synth["max_retries"]) if "max_retries" in synth else DEFAULT_MAX_RETRIES
111
+ )
112
+ backoff_base = (
113
+ float(synth["backoff_base"])
114
+ if "backoff_base" in synth
115
+ else DEFAULT_BACKOFF_BASE
116
+ )
117
+ max_tokens = (
118
+ int(synth["max_tokens"]) if "max_tokens" in synth else DEFAULT_MAX_TOKENS
119
+ )
120
+ temperature = (
121
+ float(synth["temperature"])
122
+ if "temperature" in synth
123
+ else DEFAULT_TEMPERATURE
124
+ )
125
+ enable_thinking = bool(synth.get("enable_thinking", False))
126
+
127
+ if timeout <= 0:
128
+ raise ValueError(f"synthesis.timeout must be positive, got {timeout}")
129
+ if max_retries < 1:
130
+ raise ValueError(
131
+ f"synthesis.max_retries must be >= 1, got {max_retries}"
132
+ )
133
+ if max_tokens < 1:
134
+ raise ValueError(
135
+ f"synthesis.max_tokens must be >= 1, got {max_tokens}"
136
+ )
137
+ if backoff_base < 0:
138
+ raise ValueError(
139
+ f"synthesis.backoff_base must be >= 0, got {backoff_base}"
140
+ )
141
+ resolved = OpenAICompatConfig(
142
+ base_url=base_url,
143
+ model=model,
144
+ api_key=api_key,
145
+ timeout=timeout,
146
+ max_retries=max_retries,
147
+ backoff_base=backoff_base,
148
+ max_tokens=max_tokens,
149
+ temperature=temperature,
150
+ enable_thinking=enable_thinking,
151
+ )
152
+ if not resolved.is_local:
153
+ logger.warning(
154
+ "OpenAI-compatible backend pointed at non-local host %s — transcript "
155
+ "data will leave this machine. Set synthesis.base_url to a loopback "
156
+ "address to keep transcripts local.",
157
+ resolved.base_url,
158
+ )
159
+ return resolved
160
+
161
+
162
+ def _render_prompt(template: str, *, raw_body: str, meta: dict[str, Any]) -> str:
163
+ meta_dump = json.dumps(meta, indent=2, default=str, sort_keys=True)
164
+ return template.replace("{body}", raw_body).replace("{meta}", meta_dump)
165
+
166
+
167
+ def _truncate_body(body: str, limit: int = 12000) -> str:
168
+ """Keep the opening and the conclusion of a long transcript.
169
+
170
+ A pure head slice loses the outcome, which is the most valuable
171
+ part of a session. Two thirds head plus one third tail keeps both
172
+ the problem statement and the final result.
173
+ """
174
+ if not body or len(body) <= limit:
175
+ return body
176
+ head_len = (limit * 2) // 3
177
+ tail_len = limit - head_len
178
+ return (
179
+ body[:head_len]
180
+ + "\n\n...[middle of transcript truncated]...\n\n"
181
+ + body[-tail_len:]
182
+ )
183
+
184
+
185
+ class OpenAICompatSynthesizer(BaseSynthesizer):
186
+ """Synthesize source pages through an OpenAI-compatible ``/v1`` API."""
187
+
188
+ def __init__(self, config: OpenAICompatConfig | None = None):
189
+ self.config = config or OpenAICompatConfig()
190
+
191
+ @property
192
+ def name(self) -> str:
193
+ return f"OpenAICompatSynthesizer({self.config.model})"
194
+
195
+ def is_available(self) -> bool:
196
+ try:
197
+ body = self._http("GET", self.config.models_url, None, timeout=10)
198
+ except Exception:
199
+ return False
200
+ try:
201
+ data = json.loads(body)
202
+ except json.JSONDecodeError:
203
+ return False
204
+ return isinstance(data, dict) and isinstance(data.get("data"), list)
205
+
206
+ def synthesize_source_page(
207
+ self,
208
+ raw_body: str,
209
+ meta: dict[str, Any],
210
+ prompt_template: str,
211
+ ) -> str:
212
+ # Cap the transcript like the Ollama backend: the page is a
213
+ # summary, and a 400 KB session would dominate the context.
214
+ truncated = _truncate_body(raw_body, limit=12000)
215
+ prompt = _render_prompt(prompt_template, raw_body=truncated, meta=meta)
216
+ payload: dict[str, Any] = {
217
+ "model": self.config.model,
218
+ "messages": [{"role": "user", "content": prompt}],
219
+ "temperature": self.config.temperature,
220
+ "max_tokens": self.config.max_tokens,
221
+ "stream": False,
222
+ }
223
+ if not self.config.enable_thinking:
224
+ payload["chat_template_kwargs"] = {"enable_thinking": False}
225
+
226
+ data = self._post_chat(payload)
227
+ choices = data.get("choices") or []
228
+ if not choices:
229
+ raise OpenAICompatError("response has no choices")
230
+ message = choices[0].get("message") or {}
231
+ content = message.get("content")
232
+ if not isinstance(content, str) or not content.strip():
233
+ # Some servers put the whole answer in reasoning_content
234
+ # when thinking is enabled. Use it rather than losing the
235
+ # generation, but only as a fallback.
236
+ reasoning = message.get("reasoning_content")
237
+ if isinstance(reasoning, str) and reasoning.strip():
238
+ content = reasoning
239
+ if not isinstance(content, str) or not content.strip():
240
+ raise OpenAICompatError("response content is empty")
241
+ return content.strip()
242
+
243
+ # ---- internals -----------------------------------------------
244
+
245
+ def _post_chat(self, payload: dict[str, Any]) -> dict[str, Any]:
246
+ last_exc: Exception | None = None
247
+ fallback_used = False
248
+ attempt = 1
249
+ while attempt <= self.config.max_retries:
250
+ try:
251
+ body = self._http(
252
+ "POST", self.config.chat_url, payload, timeout=self.config.timeout
253
+ )
254
+ data = json.loads(body)
255
+ if not isinstance(data, dict):
256
+ raise OpenAICompatError("response is not a JSON object")
257
+ return data
258
+ except OAIConnectionError:
259
+ raise
260
+ except OpenAICompatHTTPError as exc:
261
+ if (
262
+ exc.status == 400
263
+ and "chat_template_kwargs" in payload
264
+ and not fallback_used
265
+ ):
266
+ # vLLM understands chat_template_kwargs; a strict
267
+ # OpenAI-compatible server rejects it with 400.
268
+ # Retry once without the key.
269
+ payload = {
270
+ key: value
271
+ for key, value in payload.items()
272
+ if key != "chat_template_kwargs"
273
+ }
274
+ fallback_used = True
275
+ continue
276
+ if 400 <= exc.status < 500 and exc.status != 429:
277
+ raise OpenAICompatError(
278
+ f"non-retryable HTTP {exc.status}: {exc.body[:200]}"
279
+ ) from exc
280
+ last_exc = exc
281
+ except (OSError, TimeoutError, json.JSONDecodeError) as exc:
282
+ last_exc = exc
283
+
284
+ if attempt < self.config.max_retries:
285
+ logger.warning(
286
+ "OpenAI-compatible request failed (attempt %d/%d): %s; retrying",
287
+ attempt,
288
+ self.config.max_retries,
289
+ last_exc,
290
+ )
291
+ time.sleep(self.config.backoff_base * (2 ** (attempt - 1)))
292
+ attempt += 1
293
+
294
+ raise OpenAICompatError(
295
+ f"chat completion failed after retries: {last_exc}"
296
+ )
297
+
298
+ def _http(
299
+ self,
300
+ method: str,
301
+ url: str,
302
+ payload: dict[str, Any] | None,
303
+ *,
304
+ timeout: int,
305
+ ) -> str:
306
+ data = None
307
+ headers = {"Accept": "application/json"}
308
+ if payload is not None:
309
+ data = json.dumps(payload).encode("utf-8")
310
+ headers["Content-Type"] = "application/json"
311
+ if self.config.api_key:
312
+ headers["Authorization"] = f"Bearer {self.config.api_key}"
313
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
314
+ try:
315
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
316
+ return resp.read().decode("utf-8", errors="replace")
317
+ except urllib.error.HTTPError as exc:
318
+ body = exc.read().decode("utf-8", errors="replace")
319
+ raise OpenAICompatHTTPError(exc.code, body) from exc
320
+ except urllib.error.URLError as exc:
321
+ raise OAIConnectionError(str(exc.reason)) from exc
322
+
323
+
324
+ class OAIConnectionError(OpenAICompatUnavailableError):
325
+ """Raised when the server cannot be reached (no retry)."""