siftcode 0.3.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.
siftcode/core/llm.py ADDED
@@ -0,0 +1,295 @@
1
+ """
2
+ Ollama LLM client for Siftcode.
3
+
4
+ Provides a clean interface for chat-based interaction with locally-running
5
+ LLMs via Ollama's HTTP API. Handles retries, timeouts, and per-call model
6
+ overrides.
7
+
8
+ No GTK dependencies — this module is testable in isolation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import time
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ import requests
19
+
20
+ from siftcode.core.config import ModelConfig
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Exceptions
28
+ # ---------------------------------------------------------------------------
29
+
30
+ class LLMError(Exception):
31
+ """Base class for all LLM-related errors."""
32
+
33
+
34
+ class LLMConnectionError(LLMError):
35
+ """Raised when the Ollama server cannot be reached."""
36
+
37
+
38
+ class LLMTimeoutError(LLMError):
39
+ """Raised when a request times out."""
40
+
41
+
42
+ class LLMResponseError(LLMError):
43
+ """Raised when the server responds with an error or malformed payload."""
44
+
45
+
46
+ class LLMModelNotFoundError(LLMError):
47
+ """Raised when the requested model is not available in Ollama."""
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Response dataclass
52
+ # ---------------------------------------------------------------------------
53
+
54
+ @dataclass
55
+ class ChatResponse:
56
+ """Result of a single chat completion."""
57
+
58
+ text: str
59
+ model: str
60
+ duration_seconds: float
61
+ prompt_tokens: int = 0
62
+ completion_tokens: int = 0
63
+
64
+ @property
65
+ def total_tokens(self) -> int:
66
+ return self.prompt_tokens + self.completion_tokens
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Client
71
+ # ---------------------------------------------------------------------------
72
+
73
+ class OllamaClient:
74
+ """
75
+ Client for Ollama's /api/chat endpoint.
76
+
77
+ Example:
78
+ config = ModelConfig(name="qwen2.5-coder:14b", num_ctx=8192)
79
+ client = OllamaClient(config)
80
+
81
+ response = client.chat(
82
+ system="You are a code analyzer.",
83
+ user="Analyze this file: ...",
84
+ )
85
+ print(response.text)
86
+
87
+ # Override model for a single call
88
+ response = client.chat(
89
+ system="You are a technical writer.",
90
+ user="Write a guide...",
91
+ model="qwen2.5-coder:32b",
92
+ max_tokens=4000,
93
+ )
94
+ """
95
+
96
+ # Retry configuration
97
+ MAX_RETRIES = 3
98
+ RETRY_BACKOFF = 2.0 # seconds, multiplied by attempt number
99
+
100
+ def __init__(self, config: ModelConfig) -> None:
101
+ self.config = config
102
+ self._session = requests.Session()
103
+
104
+ # ------------------------------------------------------------------
105
+ # Public API
106
+ # ------------------------------------------------------------------
107
+
108
+ def chat(
109
+ self,
110
+ system: str,
111
+ user: str,
112
+ *,
113
+ model: str | None = None,
114
+ max_tokens: int | None = None,
115
+ temperature: float | None = None,
116
+ num_ctx: int | None = None,
117
+ timeout: int | None = None,
118
+ ) -> ChatResponse:
119
+ """
120
+ Send a chat request and return the response.
121
+
122
+ Args:
123
+ system: System prompt.
124
+ user: User message.
125
+ model: Override the model from config for this call.
126
+ max_tokens: Override max_tokens_file from config.
127
+ temperature: Override temperature from config.
128
+ num_ctx: Override num_ctx from config.
129
+ timeout: Override timeout from config.
130
+
131
+ Returns:
132
+ ChatResponse with the generated text.
133
+
134
+ Raises:
135
+ LLMConnectionError: Cannot reach Ollama.
136
+ LLMTimeoutError: Request timed out.
137
+ LLMResponseError: Server returned an error or bad payload.
138
+ LLMModelNotFoundError: Requested model is not installed.
139
+ """
140
+ model_name = model or self.config.name
141
+ max_tokens_val = max_tokens if max_tokens is not None else self.config.max_tokens_file
142
+ temperature_val = temperature if temperature is not None else self.config.temperature
143
+ num_ctx_val = num_ctx if num_ctx is not None else self.config.num_ctx
144
+ timeout_val = timeout if timeout is not None else self.config.timeout
145
+
146
+ payload = {
147
+ "model": model_name,
148
+ "stream": False,
149
+ "options": {
150
+ "temperature": temperature_val,
151
+ "num_predict": max_tokens_val,
152
+ "num_ctx": num_ctx_val,
153
+ },
154
+ "messages": [
155
+ {"role": "system", "content": system},
156
+ {"role": "user", "content": user},
157
+ ],
158
+ }
159
+
160
+ url = f"{self.config.ollama_url.rstrip('/')}/api/chat"
161
+
162
+ start = time.monotonic()
163
+ data = self._post_with_retry(url, payload, timeout_val, model_name)
164
+ duration = time.monotonic() - start
165
+
166
+ # Validate response shape
167
+ if "message" not in data or "content" not in data.get("message", {}):
168
+ raise LLMResponseError(
169
+ f"Malformed response from Ollama: missing 'message.content'. "
170
+ f"Got keys: {list(data.keys())}"
171
+ )
172
+
173
+ text = data["message"]["content"]
174
+
175
+ return ChatResponse(
176
+ text=text,
177
+ model=model_name,
178
+ duration_seconds=duration,
179
+ prompt_tokens=data.get("prompt_eval_count", 0),
180
+ completion_tokens=data.get("eval_count", 0),
181
+ )
182
+
183
+ def is_available(self) -> bool:
184
+ """Return True if the Ollama server responds to /api/tags."""
185
+ url = f"{self.config.ollama_url.rstrip('/')}/api/tags"
186
+ try:
187
+ r = self._session.get(url, timeout=5)
188
+ return r.status_code == 200
189
+ except requests.RequestException:
190
+ return False
191
+
192
+ def list_models(self) -> list[str]:
193
+ """Return the names of all models installed in Ollama."""
194
+ url = f"{self.config.ollama_url.rstrip('/')}/api/tags"
195
+ try:
196
+ r = self._session.get(url, timeout=10)
197
+ r.raise_for_status()
198
+ except requests.RequestException as e:
199
+ raise LLMConnectionError(
200
+ f"Cannot list models from {url}: {e}"
201
+ ) from e
202
+
203
+ try:
204
+ data = r.json()
205
+ except ValueError as e:
206
+ raise LLMResponseError(
207
+ f"Ollama returned invalid JSON for /api/tags: {e}"
208
+ ) from e
209
+
210
+ return [m["name"] for m in data.get("models", [])]
211
+
212
+ def has_model(self, name: str) -> bool:
213
+ """Return True if a model with the given name is installed."""
214
+ # Ollama model names include tags like "qwen2.5-coder:14b"
215
+ # Allow prefix matching so "qwen2.5-coder" matches "qwen2.5-coder:14b"
216
+ try:
217
+ available = self.list_models()
218
+ except LLMError:
219
+ return False
220
+ return any(name == m or m.startswith(name + ":") for m in available)
221
+
222
+ # ------------------------------------------------------------------
223
+ # Internal
224
+ # ------------------------------------------------------------------
225
+
226
+ def _post_with_retry(
227
+ self,
228
+ url: str,
229
+ payload: dict[str, Any],
230
+ timeout: int,
231
+ model_name: str,
232
+ ) -> dict[str, Any]:
233
+ """POST with retry on connection errors. Does not retry on 4xx."""
234
+ last_error: Exception | None = None
235
+
236
+ for attempt in range(1, self.MAX_RETRIES + 1):
237
+ try:
238
+ r = self._session.post(url, json=payload, timeout=timeout)
239
+ except requests.Timeout as e:
240
+ last_error = LLMTimeoutError(
241
+ f"Request to {url} timed out after {timeout}s "
242
+ f"(attempt {attempt}/{self.MAX_RETRIES})"
243
+ )
244
+ logger.warning(str(last_error))
245
+ if attempt < self.MAX_RETRIES:
246
+ time.sleep(self.RETRY_BACKOFF * attempt)
247
+ continue
248
+ raise last_error from e
249
+
250
+ except requests.ConnectionError as e:
251
+ last_error = LLMConnectionError(
252
+ f"Cannot connect to Ollama at {url}: {e}"
253
+ )
254
+ logger.warning(str(last_error))
255
+ if attempt < self.MAX_RETRIES:
256
+ time.sleep(self.RETRY_BACKOFF * attempt)
257
+ continue
258
+ raise last_error from e
259
+
260
+ except requests.RequestException as e:
261
+ raise LLMConnectionError(
262
+ f"Unexpected request error to {url}: {e}"
263
+ ) from e
264
+
265
+ # HTTP-level error handling (no retry)
266
+ if r.status_code == 404:
267
+ raise LLMModelNotFoundError(
268
+ f"Model '{model_name}' not found in Ollama. "
269
+ f"Run: ollama pull {model_name}"
270
+ )
271
+ if r.status_code >= 400:
272
+ raise LLMResponseError(
273
+ f"Ollama returned HTTP {r.status_code}: {r.text[:500]}"
274
+ )
275
+
276
+ # Parse JSON
277
+ try:
278
+ return r.json()
279
+ except ValueError as e:
280
+ raise LLMResponseError(
281
+ f"Ollama returned invalid JSON: {e}"
282
+ ) from e
283
+
284
+ # Only reached if loop never returns/raises
285
+ raise LLMConnectionError(f"All {self.MAX_RETRIES} retries failed: {last_error}")
286
+
287
+ def close(self) -> None:
288
+ """Close the underlying HTTP session."""
289
+ self._session.close()
290
+
291
+ def __enter__(self) -> "OllamaClient":
292
+ return self
293
+
294
+ def __exit__(self, *exc_info: Any) -> None:
295
+ self.close()
@@ -0,0 +1,208 @@
1
+ """
2
+ core/manual.py — Advanced Technical Analysis & Specification Generator.
3
+
4
+ Performs deep per-file analysis on source code using Ollama via OllamaClient,
5
+ leveraging AnalysisCache for zero redundant work and zero external UI dependencies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Callable, Dict, List, Optional
14
+ import tomllib
15
+
16
+ from siftcode.core.cache import AnalysisCache, file_hash
17
+ from siftcode.core.config import ProjectConfig
18
+ from siftcode.core.llm import LLMError, OllamaClient
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ ADVANCED_SYSTEM_PROMPT = """You are an expert software architect and technical writer.
23
+ Your task is to analyze the provided source code file and generate a strict, highly detailed Technical Specification in Markdown format.
24
+
25
+ You MUST follow this EXACT structure without deviating. Do NOT wrap the entire output in a global triple-backtick block.
26
+
27
+ # {filename} — Technical Documentation
28
+
29
+ | Field | Value |
30
+ |---|---|
31
+ | File | {relative_path} |
32
+ | Author | {author} |
33
+ | License | {license} |
34
+ | Version | {version} |
35
+
36
+ ## Overview
37
+ <Provide a clear, high-level summary of the file's primary responsibilities, architecture, and logic flow.>
38
+
39
+ ## Class & Function Reference
40
+ <For every class and stand-alone function in the file, provide its description and method table.>
41
+
42
+ ### ClassOrFunctionName
43
+ <Concise description of purpose>
44
+
45
+ Python Signature:
46
+ [Insert Python signature or class definition here]
47
+
48
+ | Method / Attribute | Signature / Type | Purpose |
49
+ |---|---|---|
50
+ | name | signature_or_type | description |
51
+
52
+ ## Test Cases
53
+ <Provide a comprehensive test case matrix describing expected behaviors and expected outcomes.>
54
+
55
+ | # | Method / Scenario | Input | Expected Result |
56
+ |---|---|---|---|
57
+ | 1 | method_name | input_description | expected_outcome |
58
+
59
+ ## Error Handling
60
+ <List all explicitly raised or caught exceptions, failure modes, and fallback logic.>
61
+
62
+ | Condition | Exception / Behavior | Raised / Handled By |
63
+ |---|---|---|
64
+ | trigger_condition | exception_type | function_or_method_name |
65
+
66
+ ## Summary
67
+ <Provide a brief 2-3 sentence summary concluding the technical design of this file.>
68
+ """
69
+
70
+
71
+ class SpecGenerator:
72
+ """Core generator for per-file technical specifications using Siftcode infrastructure."""
73
+
74
+ def __init__(
75
+ self,
76
+ config: ProjectConfig,
77
+ client: OllamaClient,
78
+ cache: AnalysisCache | None = None,
79
+ author: str = "",
80
+ license_name: str = "",
81
+ version: str = "",
82
+ ) -> None:
83
+ self.config = config
84
+ self.client = client
85
+ self.cache = cache if cache is not None else AnalysisCache(enabled=config.analysis.use_cache)
86
+
87
+ # Haetaan metatiedot dynaamisesti projektista, jos niitä ei ole erikseen annettu
88
+ project_meta = self._detect_project_metadata()
89
+ self.author = author or project_meta.get("author", "Not specified")
90
+ self.license_name = license_name or project_meta.get("license", "Not specified")
91
+ self.version = version or project_meta.get("version", "0.1.0")
92
+
93
+ def _detect_project_metadata(self) -> Dict[str, str]:
94
+ """Attempts to parse project author, license, and version from standard manifest files."""
95
+ meta: Dict[str, str] = {}
96
+ root = self.config.path
97
+
98
+ # 1. Tarkistetaan pyproject.toml
99
+ pyproject_path = root / "pyproject.toml"
100
+ if pyproject_path.exists():
101
+ try:
102
+ with open(pyproject_path, "rb") as f:
103
+ data = tomllib.load(f)
104
+
105
+ # Poetry / PEP 621 rakenteet
106
+ project_section = data.get("project", {})
107
+ tool_poetry = data.get("tool", {}).get("poetry", {})
108
+
109
+ meta["version"] = project_section.get("version") or tool_poetry.get("version")
110
+
111
+ # Lisenssi
112
+ license_field = project_section.get("license") or tool_poetry.get("license")
113
+ if isinstance(license_field, dict):
114
+ meta["license"] = license_field.get("text")
115
+ elif isinstance(license_field, str):
116
+ meta["license"] = license_field
117
+
118
+ # Tekijä(t)
119
+ authors = project_section.get("authors") or tool_poetry.get("authors")
120
+ if authors and isinstance(authors, list):
121
+ first_author = authors[0]
122
+ if isinstance(first_author, dict):
123
+ meta["author"] = first_author.get("name")
124
+ elif isinstance(first_author, str):
125
+ meta["author"] = first_author
126
+ except Exception as e:
127
+ logger.debug(f"Could not parse pyproject.toml for metadata: {e}")
128
+
129
+ # 2. Tarkistetaan package.json (Node.js)
130
+ package_json = root / "package.json"
131
+ if not meta and package_json.exists():
132
+ try:
133
+ import json
134
+ with open(package_json, "r", encoding="utf-8") as f:
135
+ data = json.load(f)
136
+ meta["version"] = data.get("version")
137
+ meta["license"] = data.get("license")
138
+ author_field = data.get("author")
139
+ if isinstance(author_field, dict):
140
+ meta["author"] = author_field.get("name")
141
+ elif isinstance(author_field, str):
142
+ meta["author"] = author_field
143
+ except Exception as e:
144
+ logger.debug(f"Could not parse package.json for metadata: {e}")
145
+
146
+ return meta
147
+
148
+ def generate_file_spec(
149
+ self,
150
+ file_path: Path,
151
+ ) -> str:
152
+ """Analyzes a single source file and returns its technical documentation in Markdown, with caching."""
153
+ if not file_path.exists() or not file_path.is_file():
154
+ raise FileNotFoundError(f"Target file does not exist: {file_path}")
155
+
156
+ try:
157
+ source_code = file_path.read_text(encoding="utf-8")
158
+ except Exception as e:
159
+ raise IOError(f"Could not read source file {file_path}: {e}") from e
160
+
161
+ rel_path = file_path.relative_to(self.config.path).as_posix()
162
+
163
+ key = file_hash(file_path)
164
+ model = self.config.model.name
165
+ cached = self.cache.get_file(key, model=model)
166
+ if cached is not None:
167
+ logger.debug(f"Spec generator cache hit: {rel_path}")
168
+ return cached
169
+
170
+ system_prompt = ADVANCED_SYSTEM_PROMPT.format(
171
+ filename=file_path.name,
172
+ relative_path=rel_path,
173
+ author=self.author,
174
+ license=self.license_name,
175
+ version=self.version,
176
+ )
177
+
178
+ try:
179
+ response = self.client.chat(
180
+ system=system_prompt,
181
+ user=f"Source code to analyze:\n\n```python\n{source_code}\n```",
182
+ )
183
+ spec_content = response.text.strip()
184
+ self.cache.put_file(key, path=rel_path, model=model, summary=spec_content)
185
+ return spec_content
186
+ except LLMError as e:
187
+ raise RuntimeError(f"LLM analysis failed for {rel_path}: {e}") from e
188
+
189
+ def generate_project_specs(
190
+ self,
191
+ file_list: List[Path],
192
+ progress_callback: Optional[Callable[[int, int, Path], None]] = None,
193
+ ) -> Dict[str, str]:
194
+ """
195
+ Iterates over a list of source files, generating a specification for each.
196
+ Returns a dictionary mapping relative file paths to their Markdown analysis.
197
+ """
198
+ results: Dict[str, str] = {}
199
+ total = len(file_list)
200
+
201
+ for idx, path in enumerate(file_list, start=1):
202
+ if progress_callback:
203
+ progress_callback(idx, total, path)
204
+
205
+ rel_key = path.relative_to(self.config.path).as_posix()
206
+ results[rel_key] = self.generate_file_spec(file_path=path)
207
+
208
+ return results