codemble 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.
- codemble/__init__.py +4 -0
- codemble/adapters/__init__.py +38 -0
- codemble/adapters/base.py +199 -0
- codemble/adapters/discovery.py +190 -0
- codemble/adapters/project.py +206 -0
- codemble/adapters/python_ast.py +766 -0
- codemble/adapters/typescript_tree_sitter.py +1263 -0
- codemble/checks/__init__.py +19 -0
- codemble/checks/service.py +380 -0
- codemble/cli.py +161 -0
- codemble/graph/__init__.py +17 -0
- codemble/graph/finalize.py +91 -0
- codemble/graph/layout.py +132 -0
- codemble/lens/__init__.py +18 -0
- codemble/lens/javascript_typescript.py +82 -0
- codemble/lens/python.py +71 -0
- codemble/llm/__init__.py +6 -0
- codemble/llm/providers.py +137 -0
- codemble/llm/study.py +439 -0
- codemble/progress/__init__.py +9 -0
- codemble/progress/store.py +160 -0
- codemble/server/__init__.py +6 -0
- codemble/server/app.py +273 -0
- codemble/server/runtime.py +68 -0
- codemble/web_dist/assets/index-DOBVd_-M.css +1 -0
- codemble/web_dist/assets/index-cIG6GGIB.js +5238 -0
- codemble/web_dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- codemble/web_dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- codemble/web_dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- codemble/web_dist/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-500-normal-C-QwvIb3.woff +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-500-normal-XI1O8euf.woff2 +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-700-normal-CkoCYOiI.woff +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-700-normal-DHcmzUO5.woff2 +0 -0
- codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-BEdayliK.woff2 +0 -0
- codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-CPSmNJAU.woff +0 -0
- codemble/web_dist/index.html +18 -0
- codemble-0.3.0.dist-info/METADATA +417 -0
- codemble-0.3.0.dist-info/RECORD +43 -0
- codemble-0.3.0.dist-info/WHEEL +4 -0
- codemble-0.3.0.dist-info/entry_points.txt +2 -0
- codemble-0.3.0.dist-info/licenses/LICENSE +202 -0
- codemble-0.3.0.dist-info/licenses/NOTICE +2 -0
codemble/llm/study.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Deep study module: source, grounding, provider narration, and disk cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tokenize
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Mapping
|
|
13
|
+
|
|
14
|
+
from codemble.adapters.base import Edge, Graph, Node
|
|
15
|
+
from codemble.lens import lens_notes
|
|
16
|
+
from codemble.llm.providers import (
|
|
17
|
+
AnthropicProvider,
|
|
18
|
+
NarrationProvider,
|
|
19
|
+
OpenAIProvider,
|
|
20
|
+
ProviderError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
PROMPT_VERSION = "study-v2"
|
|
24
|
+
_CACHE_SCHEMA = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UnknownNodeError(LookupError):
|
|
28
|
+
"""The requested node is not present in the parser graph."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StudySourceError(RuntimeError):
|
|
32
|
+
"""The parser-proven source can no longer be read safely."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GroundingError(RuntimeError):
|
|
36
|
+
"""Provider output references evidence outside the supplied graph context."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StudyService:
|
|
40
|
+
"""Return a complete, evidence-bounded study payload through one interface."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
graph: Graph,
|
|
45
|
+
*,
|
|
46
|
+
provider: NarrationProvider | None = None,
|
|
47
|
+
cache_root: Path | None = None,
|
|
48
|
+
setup_message: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._graph = graph
|
|
51
|
+
self._project_root = Path(graph.project_root).resolve()
|
|
52
|
+
self._nodes = {node.id: node for node in graph.nodes}
|
|
53
|
+
self._provider = provider
|
|
54
|
+
self._cache_root = cache_root or Path.home() / ".codemble" / "cache" / "explanations"
|
|
55
|
+
self._setup_message = setup_message or (
|
|
56
|
+
"Set ANTHROPIC_API_KEY or OPENAI_API_KEY, then restart Codemble."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_environment(
|
|
61
|
+
cls,
|
|
62
|
+
graph: Graph,
|
|
63
|
+
*,
|
|
64
|
+
environ: Mapping[str, str] | None = None,
|
|
65
|
+
config_path: Path | None = None,
|
|
66
|
+
cache_root: Path | None = None,
|
|
67
|
+
) -> StudyService:
|
|
68
|
+
"""Build the module from environment variables or ``~/.codemble/config``."""
|
|
69
|
+
|
|
70
|
+
values = dict(os.environ if environ is None else environ)
|
|
71
|
+
path = config_path or Path.home() / ".codemble" / "config"
|
|
72
|
+
config, config_error = _read_config(path)
|
|
73
|
+
provider_name = (values.get("CODEMBLE_PROVIDER") or config.get("provider") or "").lower()
|
|
74
|
+
generic_key = config.get("api_key")
|
|
75
|
+
anthropic_key = (
|
|
76
|
+
values.get("ANTHROPIC_API_KEY")
|
|
77
|
+
or config.get("anthropic_api_key")
|
|
78
|
+
or (generic_key if provider_name == "anthropic" else None)
|
|
79
|
+
)
|
|
80
|
+
openai_key = (
|
|
81
|
+
values.get("OPENAI_API_KEY")
|
|
82
|
+
or config.get("openai_api_key")
|
|
83
|
+
or (generic_key if provider_name == "openai" else None)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if not provider_name:
|
|
87
|
+
if anthropic_key:
|
|
88
|
+
provider_name = "anthropic"
|
|
89
|
+
elif openai_key:
|
|
90
|
+
provider_name = "openai"
|
|
91
|
+
|
|
92
|
+
provider: NarrationProvider | None = None
|
|
93
|
+
setup_message = config_error
|
|
94
|
+
if provider_name == "anthropic" and anthropic_key:
|
|
95
|
+
provider = AnthropicProvider(
|
|
96
|
+
anthropic_key,
|
|
97
|
+
values.get("CODEMBLE_ANTHROPIC_MODEL")
|
|
98
|
+
or config.get("anthropic_model")
|
|
99
|
+
or config.get("model")
|
|
100
|
+
or "claude-sonnet-5",
|
|
101
|
+
)
|
|
102
|
+
elif provider_name == "openai" and openai_key:
|
|
103
|
+
provider = OpenAIProvider(
|
|
104
|
+
openai_key,
|
|
105
|
+
values.get("CODEMBLE_OPENAI_MODEL")
|
|
106
|
+
or config.get("openai_model")
|
|
107
|
+
or config.get("model")
|
|
108
|
+
or "gpt-5.4-mini",
|
|
109
|
+
)
|
|
110
|
+
elif provider_name and provider_name not in {"anthropic", "openai"}:
|
|
111
|
+
setup_message = (
|
|
112
|
+
"CODEMBLE_PROVIDER must be 'anthropic' or 'openai'; structure remains available."
|
|
113
|
+
)
|
|
114
|
+
elif provider_name:
|
|
115
|
+
setup_message = (
|
|
116
|
+
f"Add the {provider_name.upper()} API key to the environment or {path}, "
|
|
117
|
+
"then restart Codemble."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return cls(
|
|
121
|
+
graph,
|
|
122
|
+
provider=provider,
|
|
123
|
+
cache_root=cache_root,
|
|
124
|
+
setup_message=setup_message,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def study(self, node_id: str) -> dict[str, object]:
|
|
128
|
+
"""Return real source, parser neighbors, and a bounded narration state."""
|
|
129
|
+
|
|
130
|
+
node = self._nodes.get(node_id)
|
|
131
|
+
if node is None:
|
|
132
|
+
raise UnknownNodeError(node_id)
|
|
133
|
+
source = self._read_source(node)
|
|
134
|
+
neighbors = self._neighbors(node)
|
|
135
|
+
annotations = sorted(
|
|
136
|
+
(
|
|
137
|
+
annotation
|
|
138
|
+
for annotation in self._graph.concept_annotations
|
|
139
|
+
if annotation.node_id == node.id
|
|
140
|
+
),
|
|
141
|
+
key=lambda item: (item.lineno, item.concept, item.end_lineno),
|
|
142
|
+
)
|
|
143
|
+
lens = lens_notes(node.language, annotations)
|
|
144
|
+
for note in lens:
|
|
145
|
+
note["citation"] = f"{node.file}:{note['line']}"
|
|
146
|
+
explanation = (
|
|
147
|
+
{
|
|
148
|
+
"status": "partial",
|
|
149
|
+
"message": (
|
|
150
|
+
"Narration is unavailable because the language parser reported "
|
|
151
|
+
"syntax errors in this file. "
|
|
152
|
+
"The raw source remains visible."
|
|
153
|
+
),
|
|
154
|
+
}
|
|
155
|
+
if node.partial
|
|
156
|
+
else self._explain(node, source, neighbors, lens)
|
|
157
|
+
)
|
|
158
|
+
return {
|
|
159
|
+
"node": asdict(node),
|
|
160
|
+
"source": source,
|
|
161
|
+
"neighbors": neighbors,
|
|
162
|
+
"lens": lens,
|
|
163
|
+
"explanation": explanation,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def _read_source(self, node: Node) -> dict[str, object]:
|
|
167
|
+
source_path = (self._project_root / node.file).resolve()
|
|
168
|
+
if not source_path.is_relative_to(self._project_root) or not source_path.is_file():
|
|
169
|
+
raise StudySourceError("The parser-proven source file is no longer available.")
|
|
170
|
+
try:
|
|
171
|
+
if node.language == "python":
|
|
172
|
+
with tokenize.open(source_path) as source_file:
|
|
173
|
+
source_text = source_file.read()
|
|
174
|
+
else:
|
|
175
|
+
source_text = source_path.read_bytes().decode("utf-8", errors="replace")
|
|
176
|
+
all_lines = source_text.splitlines()
|
|
177
|
+
except (OSError, SyntaxError, UnicodeDecodeError) as error:
|
|
178
|
+
raise StudySourceError("The parser-proven source could not be decoded safely.") from error
|
|
179
|
+
start = max(1, node.lineno)
|
|
180
|
+
end = min(max(start, node.end_lineno), len(all_lines))
|
|
181
|
+
return {
|
|
182
|
+
"file": node.file,
|
|
183
|
+
"start_line": start,
|
|
184
|
+
"end_line": end,
|
|
185
|
+
"lines": [
|
|
186
|
+
{"number": line_number, "text": all_lines[line_number - 1]}
|
|
187
|
+
for line_number in range(start, end + 1)
|
|
188
|
+
],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
def _neighbors(self, node: Node) -> list[dict[str, object]]:
|
|
192
|
+
observations: dict[tuple[str, str, int], dict[str, object]] = {}
|
|
193
|
+
for edge in self._graph.edges:
|
|
194
|
+
neighbor_id = _neighbor_id(edge, node.id)
|
|
195
|
+
neighbor = self._nodes.get(neighbor_id) if neighbor_id else None
|
|
196
|
+
if neighbor is None:
|
|
197
|
+
continue
|
|
198
|
+
key = (neighbor.id, edge.kind, edge.lineno)
|
|
199
|
+
observations[key] = {
|
|
200
|
+
"node_id": neighbor.id,
|
|
201
|
+
"name": neighbor.name,
|
|
202
|
+
"kind": neighbor.kind,
|
|
203
|
+
"file": neighbor.file,
|
|
204
|
+
"line": neighbor.lineno,
|
|
205
|
+
"citation": f"{neighbor.file}:{neighbor.lineno}",
|
|
206
|
+
"relationship": edge.kind,
|
|
207
|
+
"certain": edge.certain,
|
|
208
|
+
"observed_line": edge.lineno,
|
|
209
|
+
}
|
|
210
|
+
return [observations[key] for key in sorted(observations)]
|
|
211
|
+
|
|
212
|
+
def _explain(
|
|
213
|
+
self,
|
|
214
|
+
node: Node,
|
|
215
|
+
source: dict[str, object],
|
|
216
|
+
neighbors: list[dict[str, object]],
|
|
217
|
+
lens: list[dict[str, object]],
|
|
218
|
+
) -> dict[str, object]:
|
|
219
|
+
if self._provider is None:
|
|
220
|
+
return {
|
|
221
|
+
"status": "no_key",
|
|
222
|
+
"message": self._setup_message,
|
|
223
|
+
"cached": False,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
file_hash = self._graph.file_hashes.get(node.file, "")
|
|
227
|
+
cache_key = _cache_key(self._provider, node, file_hash)
|
|
228
|
+
cached = self._read_cache(cache_key)
|
|
229
|
+
if cached is not None:
|
|
230
|
+
return {**cached, "cached": True}
|
|
231
|
+
|
|
232
|
+
prompt = _grounded_prompt(node, source, neighbors, lens)
|
|
233
|
+
try:
|
|
234
|
+
raw = self._provider.complete(prompt)
|
|
235
|
+
validated = _validate_explanation(raw, node, neighbors)
|
|
236
|
+
except (ProviderError, GroundingError) as error:
|
|
237
|
+
return {
|
|
238
|
+
"status": "error",
|
|
239
|
+
"message": str(error),
|
|
240
|
+
"cached": False,
|
|
241
|
+
"provider": self._provider.name,
|
|
242
|
+
"model": self._provider.model,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
result = {
|
|
246
|
+
"status": "ready",
|
|
247
|
+
"cached": False,
|
|
248
|
+
"provider": self._provider.name,
|
|
249
|
+
"model": self._provider.model,
|
|
250
|
+
**validated,
|
|
251
|
+
}
|
|
252
|
+
self._write_cache(cache_key, result)
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
def _read_cache(self, cache_key: str) -> dict[str, object] | None:
|
|
256
|
+
path = self._cache_root / f"{cache_key}.json"
|
|
257
|
+
try:
|
|
258
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
259
|
+
except (OSError, json.JSONDecodeError):
|
|
260
|
+
return None
|
|
261
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != _CACHE_SCHEMA:
|
|
262
|
+
return None
|
|
263
|
+
result = payload.get("result")
|
|
264
|
+
return result if isinstance(result, dict) and result.get("status") == "ready" else None
|
|
265
|
+
|
|
266
|
+
def _write_cache(self, cache_key: str, result: dict[str, object]) -> None:
|
|
267
|
+
try:
|
|
268
|
+
self._cache_root.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
destination = self._cache_root / f"{cache_key}.json"
|
|
270
|
+
temporary = destination.with_suffix(".tmp")
|
|
271
|
+
payload = {"schema_version": _CACHE_SCHEMA, "result": result}
|
|
272
|
+
temporary.write_text(
|
|
273
|
+
json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
|
274
|
+
encoding="utf-8",
|
|
275
|
+
)
|
|
276
|
+
temporary.replace(destination)
|
|
277
|
+
except OSError:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _read_config(path: Path) -> tuple[dict[str, str], str | None]:
|
|
282
|
+
try:
|
|
283
|
+
raw = path.read_text(encoding="utf-8")
|
|
284
|
+
except FileNotFoundError:
|
|
285
|
+
return {}, None
|
|
286
|
+
except OSError:
|
|
287
|
+
return {}, f"Codemble could not read {path}; environment keys still work."
|
|
288
|
+
try:
|
|
289
|
+
decoded = json.loads(raw) if raw.lstrip().startswith("{") else tomllib.loads(raw)
|
|
290
|
+
except (json.JSONDecodeError, tomllib.TOMLDecodeError):
|
|
291
|
+
return {}, f"{path} must contain a TOML table or JSON object."
|
|
292
|
+
if not isinstance(decoded, dict) or not all(
|
|
293
|
+
isinstance(key, str) and isinstance(value, str) for key, value in decoded.items()
|
|
294
|
+
):
|
|
295
|
+
return {}, f"{path} must map string settings to string values."
|
|
296
|
+
return decoded, None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _neighbor_id(edge: Edge, node_id: str) -> str | None:
|
|
300
|
+
if edge.src == node_id and not edge.external:
|
|
301
|
+
return edge.dst
|
|
302
|
+
if edge.dst == node_id:
|
|
303
|
+
return edge.src
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _cache_key(provider: NarrationProvider, node: Node, file_hash: str) -> str:
|
|
308
|
+
material = "\0".join(
|
|
309
|
+
(PROMPT_VERSION, provider.name, provider.model, node.id, file_hash)
|
|
310
|
+
).encode()
|
|
311
|
+
return hashlib.sha256(material).hexdigest()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _grounded_prompt(
|
|
315
|
+
node: Node,
|
|
316
|
+
source: dict[str, object],
|
|
317
|
+
neighbors: list[dict[str, object]],
|
|
318
|
+
lens: list[dict[str, object]],
|
|
319
|
+
) -> str:
|
|
320
|
+
source_lines = source.get("lines", [])
|
|
321
|
+
numbered_source = "\n".join(
|
|
322
|
+
f"{line['number']:04d}: {line['text']}"
|
|
323
|
+
for line in source_lines
|
|
324
|
+
if isinstance(line, dict) and isinstance(line.get("number"), int)
|
|
325
|
+
)
|
|
326
|
+
neighbor_evidence = "\n".join(
|
|
327
|
+
f"- {neighbor['node_id']} ({neighbor['relationship']}, "
|
|
328
|
+
f"{'certain' if neighbor['certain'] else 'possible'}) at {neighbor['citation']}"
|
|
329
|
+
for neighbor in neighbors
|
|
330
|
+
) or "- none"
|
|
331
|
+
lens_evidence = "\n".join(
|
|
332
|
+
f"- {note['concept']} at {note['citation']}: {note['snippet']}"
|
|
333
|
+
for note in lens
|
|
334
|
+
) or "- none"
|
|
335
|
+
return f"""You are the narration layer in Codemble, a code-learning tool.
|
|
336
|
+
|
|
337
|
+
HARD CORRECTNESS CONTRACT:
|
|
338
|
+
- Explain only the source and parser evidence below.
|
|
339
|
+
- Never invent a structure, identifier, behavior, dependency, or intent.
|
|
340
|
+
- If purpose is unclear from the code, say exactly that it is unclear from the code.
|
|
341
|
+
- Do not introduce identifier names in prose; the UI renders validated node IDs separately.
|
|
342
|
+
- Use only line numbers inside {node.file}:{node.lineno}-{node.end_lineno}.
|
|
343
|
+
- A relationship may name only one of the supplied neighbor node IDs.
|
|
344
|
+
- Approximate relationships must be described as possible, never certain.
|
|
345
|
+
- Return JSON only, with no Markdown fence.
|
|
346
|
+
|
|
347
|
+
Return this exact shape:
|
|
348
|
+
{{
|
|
349
|
+
"summary": "plain-language explanation",
|
|
350
|
+
"walkthrough": [{{"line": {node.lineno}, "explanation": "what that line does"}}],
|
|
351
|
+
"relationships": [{{"node_id": "allowed neighbor ID", "explanation": "relationship"}}]
|
|
352
|
+
}}
|
|
353
|
+
|
|
354
|
+
Selected node: {node.id} ({node.kind})
|
|
355
|
+
Citation: {node.file}:{node.lineno}
|
|
356
|
+
|
|
357
|
+
SOURCE:
|
|
358
|
+
{numbered_source}
|
|
359
|
+
|
|
360
|
+
PARSER-PROVEN NEIGHBORS:
|
|
361
|
+
{neighbor_evidence}
|
|
362
|
+
|
|
363
|
+
LANGUAGE-LENS ANNOTATIONS:
|
|
364
|
+
{lens_evidence}
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _validate_explanation(
|
|
369
|
+
raw: str,
|
|
370
|
+
node: Node,
|
|
371
|
+
neighbors: list[dict[str, object]],
|
|
372
|
+
) -> dict[str, object]:
|
|
373
|
+
candidate = raw.strip()
|
|
374
|
+
if candidate.startswith("```"):
|
|
375
|
+
candidate = candidate.removeprefix("```json").removeprefix("```")
|
|
376
|
+
candidate = candidate.removesuffix("```").strip()
|
|
377
|
+
try:
|
|
378
|
+
payload = json.loads(candidate)
|
|
379
|
+
except json.JSONDecodeError as error:
|
|
380
|
+
raise GroundingError("The provider response was not valid grounded JSON.") from error
|
|
381
|
+
if not isinstance(payload, dict):
|
|
382
|
+
raise GroundingError("The provider response did not contain a grounded object.")
|
|
383
|
+
|
|
384
|
+
summary = _bounded_text(payload.get("summary"), "summary")
|
|
385
|
+
raw_walkthrough = payload.get("walkthrough")
|
|
386
|
+
if not isinstance(raw_walkthrough, list) or not raw_walkthrough:
|
|
387
|
+
raise GroundingError("The provider response omitted the source walkthrough.")
|
|
388
|
+
walkthrough: list[dict[str, object]] = []
|
|
389
|
+
for item in raw_walkthrough[:8]:
|
|
390
|
+
if not isinstance(item, dict) or not isinstance(item.get("line"), int):
|
|
391
|
+
raise GroundingError("A walkthrough item did not cite a real source line.")
|
|
392
|
+
line = item["line"]
|
|
393
|
+
if line < node.lineno or line > node.end_lineno:
|
|
394
|
+
raise GroundingError("A walkthrough citation fell outside the selected source span.")
|
|
395
|
+
walkthrough.append(
|
|
396
|
+
{
|
|
397
|
+
"line": line,
|
|
398
|
+
"citation": f"{node.file}:{line}",
|
|
399
|
+
"text": _bounded_text(item.get("explanation"), "walkthrough explanation"),
|
|
400
|
+
}
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
neighbor_by_id = {str(item["node_id"]): item for item in neighbors}
|
|
404
|
+
raw_relationships = payload.get("relationships", [])
|
|
405
|
+
if not isinstance(raw_relationships, list):
|
|
406
|
+
raise GroundingError("The provider relationships were not a list.")
|
|
407
|
+
relationships: list[dict[str, object]] = []
|
|
408
|
+
for item in raw_relationships[:8]:
|
|
409
|
+
if not isinstance(item, dict) or not isinstance(item.get("node_id"), str):
|
|
410
|
+
raise GroundingError("A relationship omitted its parser-proven node ID.")
|
|
411
|
+
neighbor = neighbor_by_id.get(item["node_id"])
|
|
412
|
+
if neighbor is None:
|
|
413
|
+
raise GroundingError("The provider named a relationship outside the parser graph.")
|
|
414
|
+
relationships.append(
|
|
415
|
+
{
|
|
416
|
+
"node_id": item["node_id"],
|
|
417
|
+
"citation": neighbor["citation"],
|
|
418
|
+
"certain": neighbor["certain"],
|
|
419
|
+
"text": _bounded_text(item.get("explanation"), "relationship explanation"),
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
"summary": {"text": summary, "citation": f"{node.file}:{node.lineno}"},
|
|
425
|
+
"walkthrough": walkthrough,
|
|
426
|
+
"relationships": relationships,
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _bounded_text(value: object, label: str) -> str:
|
|
431
|
+
if not isinstance(value, str) or not value.strip():
|
|
432
|
+
raise GroundingError(f"The provider {label} was empty.")
|
|
433
|
+
text = value.strip()
|
|
434
|
+
if len(text) > 2400:
|
|
435
|
+
raise GroundingError(f"The provider {label} exceeded the grounded response limit.")
|
|
436
|
+
return text
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
__all__ = ["StudyService", "StudySourceError", "UnknownNodeError"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Local persistence: illumination state + concept star chart (~/.codemble/)."""
|
|
2
|
+
|
|
3
|
+
from codemble.progress.store import (
|
|
4
|
+
ProgressStore,
|
|
5
|
+
UnknownRegionError,
|
|
6
|
+
list_recent_projects,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = ["ProgressStore", "UnknownRegionError", "list_recent_projects"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""File-hash-scoped local progress for parser regions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from codemble.adapters.base import Graph
|
|
12
|
+
|
|
13
|
+
_SCHEMA_VERSION = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnknownRegionError(KeyError):
|
|
17
|
+
"""Raised when progress is requested for a region outside the graph."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProgressStore:
|
|
21
|
+
"""Persist understood regions without letting stale source stay lit."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, graph: Graph, root: Path | None = None) -> None:
|
|
24
|
+
self._graph = graph
|
|
25
|
+
data_root = os.environ.get("CODEMBLE_DATA_DIR")
|
|
26
|
+
self._root = root or (
|
|
27
|
+
(Path(data_root).expanduser() if data_root else Path.home() / ".codemble")
|
|
28
|
+
/ "progress"
|
|
29
|
+
)
|
|
30
|
+
project_key = hashlib.sha256(graph.project_root.encode()).hexdigest()[:20]
|
|
31
|
+
self.path = self._root / f"{project_key}.json"
|
|
32
|
+
self._signatures = _region_signatures(graph)
|
|
33
|
+
|
|
34
|
+
def understood_regions(self) -> frozenset[str]:
|
|
35
|
+
"""Return only persisted regions whose current file hashes still match."""
|
|
36
|
+
|
|
37
|
+
saved = self._read().get("regions", {})
|
|
38
|
+
if not isinstance(saved, dict):
|
|
39
|
+
return frozenset()
|
|
40
|
+
return frozenset(
|
|
41
|
+
region_id
|
|
42
|
+
for region_id, signature in self._signatures.items()
|
|
43
|
+
if isinstance(saved.get(region_id), dict)
|
|
44
|
+
and saved[region_id].get("signature") == signature
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def mark_understood(self, region_id: str) -> None:
|
|
48
|
+
"""Persist the current signature for one proven region."""
|
|
49
|
+
|
|
50
|
+
signature = self._signatures.get(region_id)
|
|
51
|
+
if signature is None:
|
|
52
|
+
raise UnknownRegionError(region_id)
|
|
53
|
+
payload = self._read()
|
|
54
|
+
saved = payload.get("regions")
|
|
55
|
+
regions = saved if isinstance(saved, dict) else {}
|
|
56
|
+
regions[region_id] = {"signature": signature}
|
|
57
|
+
self._write(
|
|
58
|
+
{
|
|
59
|
+
"schema_version": _SCHEMA_VERSION,
|
|
60
|
+
"project_root": self._graph.project_root,
|
|
61
|
+
"regions": dict(sorted(regions.items())),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def hydrated_graph(self) -> Graph:
|
|
66
|
+
"""Project valid progress onto immutable render data."""
|
|
67
|
+
|
|
68
|
+
understood = self.understood_regions()
|
|
69
|
+
nodes = tuple(
|
|
70
|
+
replace(node, understood=node.region in understood) for node in self._graph.nodes
|
|
71
|
+
)
|
|
72
|
+
regions = tuple(
|
|
73
|
+
replace(region, understood=region.id in understood)
|
|
74
|
+
for region in self._graph.regions
|
|
75
|
+
)
|
|
76
|
+
return replace(self._graph, nodes=nodes, regions=regions)
|
|
77
|
+
|
|
78
|
+
def _read(self) -> dict[str, object]:
|
|
79
|
+
try:
|
|
80
|
+
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
82
|
+
return self._empty_payload()
|
|
83
|
+
if (
|
|
84
|
+
not isinstance(payload, dict)
|
|
85
|
+
or payload.get("schema_version") != _SCHEMA_VERSION
|
|
86
|
+
or payload.get("project_root") != self._graph.project_root
|
|
87
|
+
):
|
|
88
|
+
return self._empty_payload()
|
|
89
|
+
return payload
|
|
90
|
+
|
|
91
|
+
def _write(self, payload: dict[str, object]) -> None:
|
|
92
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
temporary = self.path.with_suffix(f".{os.getpid()}.tmp")
|
|
94
|
+
try:
|
|
95
|
+
temporary.write_text(
|
|
96
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
97
|
+
encoding="utf-8",
|
|
98
|
+
)
|
|
99
|
+
temporary.replace(self.path)
|
|
100
|
+
finally:
|
|
101
|
+
temporary.unlink(missing_ok=True)
|
|
102
|
+
|
|
103
|
+
def _empty_payload(self) -> dict[str, object]:
|
|
104
|
+
return {
|
|
105
|
+
"schema_version": _SCHEMA_VERSION,
|
|
106
|
+
"project_root": self._graph.project_root,
|
|
107
|
+
"regions": {},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _region_signatures(graph: Graph) -> dict[str, str]:
|
|
112
|
+
files_by_region: dict[str, set[str]] = {}
|
|
113
|
+
for node in graph.nodes:
|
|
114
|
+
files_by_region.setdefault(node.region, set()).add(node.file)
|
|
115
|
+
signatures: dict[str, str] = {}
|
|
116
|
+
for region_id, files in files_by_region.items():
|
|
117
|
+
evidence = [
|
|
118
|
+
(file, graph.file_hashes.get(file, "missing")) for file in sorted(files)
|
|
119
|
+
]
|
|
120
|
+
signatures[region_id] = hashlib.sha256(
|
|
121
|
+
json.dumps(evidence, separators=(",", ":")).encode()
|
|
122
|
+
).hexdigest()
|
|
123
|
+
return signatures
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def list_recent_projects(limit: int = 8) -> list[dict[str, object]]:
|
|
127
|
+
"""Return recently explored projects whose paths still exist, newest first."""
|
|
128
|
+
|
|
129
|
+
data_root = os.environ.get("CODEMBLE_DATA_DIR")
|
|
130
|
+
progress_root = (
|
|
131
|
+
(Path(data_root).expanduser() if data_root else Path.home() / ".codemble")
|
|
132
|
+
/ "progress"
|
|
133
|
+
)
|
|
134
|
+
entries: list[tuple[float, dict[str, object]]] = []
|
|
135
|
+
try:
|
|
136
|
+
candidates = sorted(progress_root.glob("*.json"))
|
|
137
|
+
except OSError:
|
|
138
|
+
return []
|
|
139
|
+
for path in candidates:
|
|
140
|
+
try:
|
|
141
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
142
|
+
modified = path.stat().st_mtime
|
|
143
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
144
|
+
continue
|
|
145
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != _SCHEMA_VERSION:
|
|
146
|
+
continue
|
|
147
|
+
project_root = payload.get("project_root")
|
|
148
|
+
regions = payload.get("regions")
|
|
149
|
+
if not isinstance(project_root, str) or not isinstance(regions, dict):
|
|
150
|
+
continue
|
|
151
|
+
if not Path(project_root).is_dir():
|
|
152
|
+
continue
|
|
153
|
+
entries.append(
|
|
154
|
+
(modified, {"project_root": project_root, "understood_count": len(regions)})
|
|
155
|
+
)
|
|
156
|
+
entries.sort(key=lambda item: item[0], reverse=True)
|
|
157
|
+
return [entry for _, entry in entries[:limit]]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
__all__ = ["ProgressStore", "UnknownRegionError", "list_recent_projects"]
|