feedsummary-core 1.0.2__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.
@@ -0,0 +1,38 @@
1
+ # LICENSE HEADER MANAGED BY add-license-header
2
+ #
3
+ # BSD 3-Clause License
4
+ #
5
+ # Copyright (c) 2026, Martin Vesterlund
6
+ #
7
+ # Redistribution and use in source and binary forms, with or without
8
+ # modification, are permitted provided that the following conditions are met:
9
+ #
10
+ # 1. Redistributions of source code must retain the above copyright notice, this
11
+ # list of conditions and the following disclaimer.
12
+ #
13
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ # this list of conditions and the following disclaimer in the documentation
15
+ # and/or other materials provided with the distribution.
16
+ #
17
+ # 3. Neither the name of the copyright holder nor the names of its
18
+ # contributors may be used to endorse or promote products derived from
19
+ # this software without specific prior written permission.
20
+ #
21
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+ #
32
+
33
+ try:
34
+ from ._version import version as __version__
35
+ except Exception:
36
+ __version__ = "0+unknown"
37
+
38
+ __all__ = ["__version__"]
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '1.0.2'
32
+ __version_tuple__ = version_tuple = (1, 0, 2)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,94 @@
1
+ # LICENSE HEADER MANAGED BY add-license-header
2
+ #
3
+ # BSD 3-Clause License
4
+ #
5
+ # Copyright (c) 2026, Martin Vesterlund
6
+ #
7
+ # Redistribution and use in source and binary forms, with or without
8
+ # modification, are permitted provided that the following conditions are met:
9
+ #
10
+ # 1. Redistributions of source code must retain the above copyright notice, this
11
+ # list of conditions and the following disclaimer.
12
+ #
13
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ # this list of conditions and the following disclaimer in the documentation
15
+ # and/or other materials provided with the distribution.
16
+ #
17
+ # 3. Neither the name of the copyright holder nor the names of its
18
+ # contributors may be used to endorse or promote products derived from
19
+ # this software without specific prior written permission.
20
+ #
21
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+ #
32
+ from __future__ import annotations
33
+
34
+ from llmClient.fallback_client import FallbackLLMClient, FallbackPolicy
35
+
36
+ from typing import Any, Dict, List, Optional, Protocol
37
+
38
+
39
+ class LLMError(Exception):
40
+ pass
41
+
42
+
43
+ class LLMClient(Protocol):
44
+ async def chat(self, messages: List[Dict[str, str]], *, temperature: float = 0.2) -> str: ...
45
+
46
+
47
+ def _create_single_llm(llm_cfg: Dict[str, Any]):
48
+ provider = (llm_cfg.get("provider") or "ollama").lower()
49
+
50
+ if provider == "ollama_cloud":
51
+ from llmClient.ollama_cloud import OllamaCloudClient
52
+
53
+ return OllamaCloudClient(llm_cfg)
54
+
55
+ if provider == "ollama_local":
56
+ from llmClient.ollama_local import OllamaLocalClient, OllamaConfig
57
+
58
+ cfg = OllamaConfig(
59
+ base_url=str(llm_cfg.get("base_url", "http://localhost:11434")),
60
+ model=str(llm_cfg.get("model", "llama3.1:latest")),
61
+ max_rps=float(llm_cfg.get("max_rps", 1.0)),
62
+ first_byte_timeout_s=int(llm_cfg.get("first_byte_timeout_s", 900)),
63
+ sock_read_timeout_s=int(llm_cfg.get("sock_read_timeout_s", 300)),
64
+ progress_log_every_s=float(llm_cfg.get("progress_log_every_s", 2.0)),
65
+ max_retries=int(llm_cfg.get("max_retries", 3)),
66
+ )
67
+ return OllamaLocalClient(cfg)
68
+
69
+ raise ValueError(f"Unsupported LLM provider: {provider}")
70
+
71
+
72
+ def create_llm_client(config: Dict[str, Any]):
73
+ """
74
+ Bygger primary från config['llm'] och (om finns) fallback från config['llm_fallback'].
75
+
76
+ Fallback triggas när cloud slår i quota igen efter retry.
77
+ """
78
+ llm_cfg = config.get("llm") or {}
79
+ primary = _create_single_llm(llm_cfg)
80
+
81
+ fallback_cfg: Optional[Dict[str, Any]] = config.get("llm_fallback")
82
+ fallback = _create_single_llm(fallback_cfg) if isinstance(fallback_cfg, dict) else None
83
+
84
+ # policy kan ligga under llm.quota eller llm_fallback_policy – välj det du gillar
85
+ quota_cfg = llm_cfg.get("quota") or {}
86
+ policy = FallbackPolicy(
87
+ max_quota_retries=int(quota_cfg.get("max_quota_retries", 1)),
88
+ default_wait_s=int(quota_cfg.get("default_wait_s", 30)),
89
+ )
90
+
91
+ if fallback:
92
+ return FallbackLLMClient(primary=primary, fallback=fallback, policy=policy)
93
+
94
+ return primary
@@ -0,0 +1,113 @@
1
+ # LICENSE HEADER MANAGED BY add-license-header
2
+ #
3
+ # BSD 3-Clause License
4
+ #
5
+ # Copyright (c) 2026, Martin Vesterlund
6
+ #
7
+ # Redistribution and use in source and binary forms, with or without
8
+ # modification, are permitted provided that the following conditions are met:
9
+ #
10
+ # 1. Redistributions of source code must retain the above copyright notice, this
11
+ # list of conditions and the following disclaimer.
12
+ #
13
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ # this list of conditions and the following disclaimer in the documentation
15
+ # and/or other materials provided with the distribution.
16
+ #
17
+ # 3. Neither the name of the copyright holder nor the names of its
18
+ # contributors may be used to endorse or promote products derived from
19
+ # this software without specific prior written permission.
20
+ #
21
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+ #
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import logging
37
+ from dataclasses import dataclass
38
+ from typing import Dict, List, Optional, Protocol
39
+
40
+ from llm_client import LLMRateLimitError
41
+
42
+ log = logging.getLogger(__name__)
43
+
44
+
45
+ class LLMClient(Protocol):
46
+ async def chat(self, messages: List[Dict[str, str]], *, temperature: float = 0.2) -> str: ...
47
+
48
+
49
+ @dataclass
50
+ class FallbackPolicy:
51
+ max_quota_retries: int = 1 # "vid retry" -> om det slår i igen efter 1 retry: fallback
52
+ default_wait_s: int = 30 # om retry_after saknas
53
+ jitter_s: float = 0.0 # håll 0 om du vill vara deterministisk
54
+
55
+
56
+ class FallbackLLMClient:
57
+ """
58
+ Wrapper som kör primary, men om quota/rate-limit kvarstår efter retry -> fallback.
59
+
60
+ Beteende:
61
+ - Får vi LLMRateLimitError:
62
+ * vänta retry_after (eller default_wait_s)
63
+ * försök igen upp till max_quota_retries
64
+ - Om fortfarande rate limited:
65
+ * växla permanent till fallback (för resten av processen)
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ primary: LLMClient,
71
+ fallback: Optional[LLMClient],
72
+ policy: Optional[FallbackPolicy] = None,
73
+ ):
74
+ self.primary = primary
75
+ self.fallback = fallback
76
+ self.policy = policy or FallbackPolicy()
77
+ self._force_fallback = False
78
+
79
+ async def chat(self, messages: List[Dict[str, str]], *, temperature: float = 0.2) -> str:
80
+ # Om vi redan växlat till fallback
81
+ if self._force_fallback:
82
+ if not self.fallback:
83
+ raise RuntimeError("Fallback aktiverad men llm_fallback saknas i config.")
84
+ return await self.fallback.chat(messages, temperature=temperature)
85
+
86
+ # Försök primary med quota-retries
87
+ attempt = 0
88
+ while True:
89
+ try:
90
+ return await self.primary.chat(messages, temperature=temperature)
91
+ except LLMRateLimitError as e:
92
+ attempt += 1
93
+ wait_s = int(e.retry_after_seconds or self.policy.default_wait_s)
94
+
95
+ # Om vi redan testat retry och slår i igen -> fallback
96
+ if attempt > self.policy.max_quota_retries:
97
+ if not self.fallback:
98
+ # ingen fallback konfigurerad
99
+ raise
100
+ log.warning(
101
+ "LLM quota/rate-limit kvarstår efter %d retry(s). Växlar till fallback.",
102
+ self.policy.max_quota_retries,
103
+ )
104
+ self._force_fallback = True
105
+ return await self.fallback.chat(messages, temperature=temperature)
106
+
107
+ log.warning(
108
+ "LLM rate-limited (attempt %d/%d). Väntar %ss och retry...",
109
+ attempt,
110
+ self.policy.max_quota_retries,
111
+ wait_s,
112
+ )
113
+ await asyncio.sleep(wait_s)
@@ -0,0 +1,209 @@
1
+ # LICENSE HEADER MANAGED BY add-license-header
2
+ #
3
+ # BSD 3-Clause License
4
+ #
5
+ # Copyright (c) 2026, Martin Vesterlund
6
+ #
7
+ # Redistribution and use in source and binary forms, with or without
8
+ # modification, are permitted provided that the following conditions are met:
9
+ #
10
+ # 1. Redistributions of source code must retain the above copyright notice, this
11
+ # list of conditions and the following disclaimer.
12
+ #
13
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ # this list of conditions and the following disclaimer in the documentation
15
+ # and/or other materials provided with the distribution.
16
+ #
17
+ # 3. Neither the name of the copyright holder nor the names of its
18
+ # contributors may be used to endorse or promote products derived from
19
+ # this software without specific prior written permission.
20
+ #
21
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+ #
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import os
37
+ import time
38
+ from dataclasses import dataclass
39
+ from typing import Any, Dict, List, Optional
40
+ import logging
41
+ from ollama import AsyncClient
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class LLMRateLimitError(RuntimeError):
47
+ def __init__(self, message: str, retry_after_seconds: Optional[int] = None):
48
+ super().__init__(message)
49
+ self.retry_after_seconds = retry_after_seconds
50
+
51
+
52
+ class LLMAuthError(RuntimeError):
53
+ pass
54
+
55
+
56
+ class LLMUnavailableError(RuntimeError):
57
+ pass
58
+
59
+
60
+ @dataclass
61
+ class OllamaCloudConfig:
62
+ host: str = "https://ollama.com"
63
+ model: str = "gemma3:270m"
64
+ api_key: str = ""
65
+ # quota/preflight
66
+ preflight: bool = True
67
+ min_interval_seconds: float = 1.0
68
+ timeout_seconds: float = 180.0
69
+
70
+
71
+ def _resolve_env(value: str) -> str:
72
+ """
73
+ Stöd för ${VAR} och $VAR i config.yaml.
74
+ """
75
+ if not isinstance(value, str):
76
+ return str(value)
77
+ return os.path.expandvars(value)
78
+
79
+
80
+ def _extract_retry_after_seconds(exc: Exception) -> Optional[int]:
81
+ """
82
+ Försök läsa Retry-After från exception/response om biblioteket exponerar det.
83
+ (Ollama-python’s exceptions kan variera mellan versioner.)
84
+ """
85
+ # ResponseError i ollama-python brukar ha status_code + error, men inte alltid headers.
86
+ # Vi gör därför "best effort".
87
+ for attr in ("retry_after", "retry_after_seconds"):
88
+ v = getattr(exc, attr, None)
89
+ if isinstance(v, int):
90
+ return v
91
+ return None
92
+
93
+
94
+ def _is_status(exc: Exception, code: int) -> bool:
95
+ sc = getattr(exc, "status_code", None)
96
+ if isinstance(sc, int) and sc == code:
97
+ return True
98
+ # vissa varianter använder .status
99
+ st = getattr(exc, "status", None)
100
+ return isinstance(st, int) and st == code
101
+
102
+
103
+ class OllamaCloudClient:
104
+ """
105
+ LLMClient-implementation för Ollama Cloud API med gemma3:270m.
106
+
107
+ Använder officiella 'ollama' AsyncClient och kör mot host=https://ollama.com
108
+ med Authorization Bearer API key. :contentReference[oaicite:3]{index=3}
109
+
110
+ "Quota check" görs via preflight call till /api/tags (client.list()).
111
+ Det ger tidig signal för 401/429 innan vi drar igång stora prompts.
112
+ """
113
+
114
+ def __init__(self, cfg: Dict[str, Any]):
115
+ llm_cfg = cfg or {}
116
+ quota_cfg = llm_cfg.get("quota") or {}
117
+
118
+ self.cfg = OllamaCloudConfig(
119
+ host=str(llm_cfg.get("host", "https://ollama.com")),
120
+ model=str(llm_cfg.get("model", "gemma3:270m")),
121
+ api_key=_resolve_env(str(llm_cfg.get("api_key", ""))),
122
+ preflight=bool(quota_cfg.get("preflight", True)),
123
+ min_interval_seconds=float(quota_cfg.get("min_interval_seconds", 1.0)),
124
+ timeout_seconds=float(llm_cfg.get("timeout_seconds", 360.0)),
125
+ )
126
+
127
+ if not self.cfg.api_key:
128
+ raise LLMAuthError("Ollama Cloud kräver api_key i config.yaml (eller via env-var).")
129
+
130
+ self._client = AsyncClient(
131
+ host=self.cfg.host,
132
+ headers={"Authorization": f"Bearer {self.cfg.api_key}"},
133
+ timeout=self.cfg.timeout_seconds,
134
+ )
135
+ self.log = logging.getLogger(__name__)
136
+ self._last_call_ts: float = 0.0
137
+ self._preflight_ok_ts: float = 0.0
138
+
139
+ async def _throttle(self) -> None:
140
+ now = time.time()
141
+ dt = now - self._last_call_ts
142
+ if dt < self.cfg.min_interval_seconds:
143
+ await asyncio.sleep(self.cfg.min_interval_seconds - dt)
144
+ self._last_call_ts = time.time()
145
+
146
+ async def _preflight_quota_check(self) -> None:
147
+ if not self.cfg.preflight:
148
+ return
149
+
150
+ # Kör inte preflight onödigt ofta
151
+ now = time.time()
152
+ if (now - self._preflight_ok_ts) < 10.0:
153
+ return
154
+
155
+ try:
156
+ # list() motsvarar /api/tags och kräver auth på ollama.com
157
+ # :contentReference[oaicite:4]{index=4}
158
+ await self._client.list()
159
+ self._preflight_ok_ts = time.time()
160
+ except Exception as e:
161
+ if _is_status(e, 401) or _is_status(e, 403):
162
+ raise LLMAuthError("Ollama Cloud auth misslyckades (api_key fel/nekad).") from e
163
+ if _is_status(e, 429):
164
+ ra = _extract_retry_after_seconds(e)
165
+ raise LLMRateLimitError(
166
+ "Ollama Cloud: rate limit/quota uppnådd (preflight).", ra
167
+ ) from e
168
+ raise LLMUnavailableError(f"Ollama Cloud preflight misslyckades: {e}") from e
169
+
170
+ async def chat(self, messages: List[Dict[str, str]], temperature: float = 0.2) -> str:
171
+ """
172
+ Returnerar en enda textsträng (sammanfogad content).
173
+ Hanterar preflight quota-check och 429/timeout tydligt.
174
+ """
175
+ await self._throttle()
176
+ await self._preflight_quota_check()
177
+ self.log.info("LLM request start (model=%s)", self.cfg.model)
178
+ # Ollama API använder "options" för parametrar.
179
+ # (temperature är ett standard-alternativ i Ollama.)
180
+ payload_options = {"temperature": temperature}
181
+
182
+ try:
183
+ resp = await self._client.chat(
184
+ model=self.cfg.model,
185
+ messages=messages,
186
+ stream=False,
187
+ options=payload_options,
188
+ )
189
+ # resp kan vara dict-lik eller typed; vi stödjer båda:
190
+ if isinstance(resp, dict):
191
+ return (resp.get("message") or {}).get("content", "") or ""
192
+ return getattr(resp.message, "content", "") or ""
193
+ except Exception as e:
194
+ try:
195
+ body = getattr(e, "error", None) or getattr(e, "message", None) or str(e)
196
+ if isinstance(body, str) and body.strip():
197
+ self.log.error("Ollama Cloud error response/body:\n%s", body[:12000])
198
+ except Exception:
199
+ pass
200
+ if _is_status(e, 401) or _is_status(e, 403):
201
+ raise LLMAuthError("Ollama Cloud: auth misslyckades (api_key fel/nekad).") from e
202
+ if _is_status(e, 429):
203
+ ra = _extract_retry_after_seconds(e)
204
+ raise LLMRateLimitError("Ollama Cloud: rate limit/quota uppnådd.", ra) from e
205
+ # timeouts kan komma som httpx.TimeoutException under huven
206
+ name = e.__class__.__name__.lower()
207
+ if "timeout" in name:
208
+ raise LLMUnavailableError(f"Ollama Cloud: timeout: {e}") from e
209
+ raise LLMUnavailableError(f"Ollama Cloud: chat misslyckades: {e}") from e