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/__init__.py +3 -0
- siftcode/__main__.py +11 -0
- siftcode/_version.py +59 -0
- siftcode/cli.py +531 -0
- siftcode/core/__init__.py +1 -0
- siftcode/core/analyzer.py +575 -0
- siftcode/core/cache.py +300 -0
- siftcode/core/config.py +258 -0
- siftcode/core/llm.py +295 -0
- siftcode/core/manual.py +208 -0
- siftcode/core/prompts.py +215 -0
- siftcode/data/__init__.py +0 -0
- siftcode/data/siftcode-logo.png +0 -0
- siftcode/gui/__init__.py +9 -0
- siftcode/gui/analyzer_view.py +560 -0
- siftcode/gui/app.py +252 -0
- siftcode/gui/guide_window.py +88 -0
- siftcode/gui/project_list.py +160 -0
- siftcode/gui/settings_view.py +430 -0
- siftcode/gui/widgets/__init__.py +1 -0
- siftcode/gui/widgets/exclusion_list.py +169 -0
- siftcode/gui/widgets/log_view.py +108 -0
- siftcode/gui/widgets/markdown_view.py +205 -0
- siftcode/gui/widgets/project_row.py +141 -0
- siftcode/gui/window.py +446 -0
- siftcode-0.3.0.dist-info/METADATA +413 -0
- siftcode-0.3.0.dist-info/RECORD +31 -0
- siftcode-0.3.0.dist-info/WHEEL +5 -0
- siftcode-0.3.0.dist-info/entry_points.txt +5 -0
- siftcode-0.3.0.dist-info/licenses/LICENSE +674 -0
- siftcode-0.3.0.dist-info/top_level.txt +1 -0
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()
|
siftcode/core/manual.py
ADDED
|
@@ -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
|