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.
- sessionwiki/__init__.py +74 -0
- sessionwiki/__main__.py +6 -0
- sessionwiki/_frontmatter.py +118 -0
- sessionwiki/_system_pages.py +44 -0
- sessionwiki/adapter_config.py +124 -0
- sessionwiki/adapters/__init__.py +134 -0
- sessionwiki/adapters/base.py +191 -0
- sessionwiki/adapters/claude_code.py +77 -0
- sessionwiki/adapters/codex_cli.py +226 -0
- sessionwiki/adapters/contrib/__init__.py +1 -0
- sessionwiki/adapters/contrib/chatgpt.py +252 -0
- sessionwiki/adapters/contrib/copilot_chat.py +121 -0
- sessionwiki/adapters/contrib/copilot_cli.py +115 -0
- sessionwiki/adapters/contrib/cursor.py +100 -0
- sessionwiki/adapters/contrib/gemini_cli.py +74 -0
- sessionwiki/adapters/contrib/obsidian.py +121 -0
- sessionwiki/adapters/opencode.py +169 -0
- sessionwiki/adapters/status.py +58 -0
- sessionwiki/adapters/zcode.py +45 -0
- sessionwiki/audit.py +174 -0
- sessionwiki/backlinks.py +224 -0
- sessionwiki/build.py +2786 -0
- sessionwiki/cache.py +401 -0
- sessionwiki/candidates.py +309 -0
- sessionwiki/categories.py +158 -0
- sessionwiki/changelog_timeline.py +422 -0
- sessionwiki/cli.py +1687 -0
- sessionwiki/compare.py +404 -0
- sessionwiki/completion.py +191 -0
- sessionwiki/confidence.py +179 -0
- sessionwiki/config.py +74 -0
- sessionwiki/config_schedule.py +77 -0
- sessionwiki/context_md.py +182 -0
- sessionwiki/convert.py +1746 -0
- sessionwiki/docs_pages.py +725 -0
- sessionwiki/doctor.py +113 -0
- sessionwiki/exporters.py +680 -0
- sessionwiki/freshness.py +153 -0
- sessionwiki/graph.py +1058 -0
- sessionwiki/graphify_bridge.py +509 -0
- sessionwiki/ingest_queue.py +101 -0
- sessionwiki/lifecycle.py +167 -0
- sessionwiki/link_checker.py +148 -0
- sessionwiki/lint/__init__.py +170 -0
- sessionwiki/lint/rules/__init__.py +69 -0
- sessionwiki/lint/rules/_helpers.py +119 -0
- sessionwiki/lint/rules/claim_verification.py +44 -0
- sessionwiki/lint/rules/content_freshness.py +41 -0
- sessionwiki/lint/rules/contradiction_detection.py +45 -0
- sessionwiki/lint/rules/duplicate_detection.py +164 -0
- sessionwiki/lint/rules/entity_consistency.py +29 -0
- sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
- sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
- sessionwiki/lint/rules/frontmatter_validity.py +71 -0
- sessionwiki/lint/rules/index_sync.py +68 -0
- sessionwiki/lint/rules/link_integrity.py +34 -0
- sessionwiki/lint/rules/orphan_detection.py +43 -0
- sessionwiki/lint/rules/stale_candidates.py +59 -0
- sessionwiki/lint/rules/stale_reference_detection.py +42 -0
- sessionwiki/lint/rules/summary_accuracy.py +36 -0
- sessionwiki/lint/rules/tags_topics_convention.py +58 -0
- sessionwiki/lint/rules/tools_consistency.py +65 -0
- sessionwiki/lock.py +126 -0
- sessionwiki/log_reader.py +129 -0
- sessionwiki/manifest.py +132 -0
- sessionwiki/mcp/__init__.py +29 -0
- sessionwiki/mcp/__main__.py +5 -0
- sessionwiki/mcp/server.py +1061 -0
- sessionwiki/models_page.py +291 -0
- sessionwiki/obsidian_output.py +208 -0
- sessionwiki/opencode/__init__.py +1 -0
- sessionwiki/opencode/autosync.py +102 -0
- sessionwiki/opencode/export.py +419 -0
- sessionwiki/opencode/install.py +152 -0
- sessionwiki/pipeline.py +150 -0
- sessionwiki/project_topics.py +213 -0
- sessionwiki/py.typed +0 -0
- sessionwiki/quarantine.py +273 -0
- sessionwiki/queue.py +29 -0
- sessionwiki/references.py +224 -0
- sessionwiki/render/__init__.py +20 -0
- sessionwiki/render/css.py +988 -0
- sessionwiki/render/docs_css.py +463 -0
- sessionwiki/render/js.py +1337 -0
- sessionwiki/schema.py +270 -0
- sessionwiki/search_facets.py +150 -0
- sessionwiki/search_tree.py +184 -0
- sessionwiki/serve.py +91 -0
- sessionwiki/session_export.py +333 -0
- sessionwiki/skill_installer.py +112 -0
- sessionwiki/sync/__init__.py +13 -0
- sessionwiki/sync/status.py +120 -0
- sessionwiki/synth/__init__.py +14 -0
- sessionwiki/synth/agent_delegate.py +327 -0
- sessionwiki/synth/base.py +125 -0
- sessionwiki/synth/cli_helpers.py +98 -0
- sessionwiki/synth/estimate.py +146 -0
- sessionwiki/synth/ollama.py +379 -0
- sessionwiki/synth/openai_compat.py +325 -0
- sessionwiki/synth/pipeline.py +1489 -0
- sessionwiki/tag_utils.py +62 -0
- sessionwiki/tags.py +359 -0
- sessionwiki/vault.py +347 -0
- sessionwiki/viz_heatmap.py +305 -0
- sessionwiki/viz_tokens.py +429 -0
- sessionwiki/viz_tools.py +241 -0
- sessionwiki/watch.py +154 -0
- sessionwiki/zcode/__init__.py +43 -0
- sessionwiki/zcode/autosync.py +150 -0
- sessionwiki/zcode/export.py +568 -0
- sessionwiki/zcode/install.py +386 -0
- sessionwiki-2.0.0.dist-info/METADATA +303 -0
- sessionwiki-2.0.0.dist-info/RECORD +118 -0
- sessionwiki-2.0.0.dist-info/WHEEL +5 -0
- sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
- sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
- sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
- 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)."""
|