|
1
|
""" |
|
2
|
DocGenerator — markdown documentation generated from the navegador graph. |
|
3
|
|
|
4
|
Supports two modes: |
|
5
|
* **Template mode** (``provider=None``): uses graph data to fill a |
|
6
|
structured markdown template — zero external dependencies. |
|
7
|
* **LLM mode** (``provider=`` an :class:`~navegador.llm.LLMProvider`): |
|
8
|
delegates to :class:`~navegador.intelligence.nlp.NLPEngine` for richer, |
|
9
|
narrative documentation. |
|
10
|
|
|
11
|
Usage:: |
|
12
|
|
|
13
|
from navegador.graph import GraphStore |
|
14
|
from navegador.intelligence.docgen import DocGenerator |
|
15
|
|
|
16
|
store = GraphStore.sqlite(".navegador/graph.db") |
|
17
|
|
|
18
|
# Template mode (no LLM required) |
|
19
|
gen = DocGenerator(store) |
|
20
|
print(gen.generate_file_docs("navegador/graph/store.py")) |
|
21
|
print(gen.generate_module_docs("navegador.graph")) |
|
22
|
print(gen.generate_project_docs()) |
|
23
|
|
|
24
|
# LLM mode |
|
25
|
from navegador.llm import get_provider |
|
26
|
provider = get_provider("openai") |
|
27
|
gen = DocGenerator(store, provider=provider) |
|
28
|
print(gen.generate_file_docs("navegador/graph/store.py")) |
|
29
|
""" |
|
30
|
|
|
31
|
from __future__ import annotations |
|
32
|
|
|
33
|
from typing import TYPE_CHECKING |
|
34
|
|
|
35
|
if TYPE_CHECKING: |
|
36
|
from navegador.graph.store import GraphStore |
|
37
|
from navegador.llm import LLMProvider |
|
38
|
|
|
39
|
|
|
40
|
# ── Cypher helpers ──────────────────────────────────────────────────────────── |
|
41
|
|
|
42
|
_FILE_SYMBOLS = """ |
|
43
|
MATCH (f:File {path: $path})-[:CONTAINS]->(n) |
|
44
|
RETURN labels(n)[0] AS type, n.name AS name, |
|
45
|
coalesce(n.docstring, '') AS docstring, |
|
46
|
coalesce(n.signature, '') AS signature, |
|
47
|
n.line_start AS line |
|
48
|
ORDER BY n.line_start |
|
49
|
""" |
|
50
|
|
|
51
|
_FILE_BY_PATH = """ |
|
52
|
MATCH (f:File) |
|
53
|
WHERE f.path CONTAINS $path |
|
54
|
RETURN f.path AS path |
|
55
|
LIMIT 20 |
|
56
|
""" |
|
57
|
|
|
58
|
_MODULE_SYMBOLS = """ |
|
59
|
MATCH (n) |
|
60
|
WHERE (n:Function OR n:Class OR n:Method) |
|
61
|
AND n.file_path CONTAINS $module_path |
|
62
|
RETURN labels(n)[0] AS type, n.name AS name, |
|
63
|
coalesce(n.file_path, '') AS file_path, |
|
64
|
coalesce(n.docstring, '') AS docstring, |
|
65
|
coalesce(n.signature, '') AS signature |
|
66
|
ORDER BY n.file_path, n.name |
|
67
|
""" |
|
68
|
|
|
69
|
_ALL_SYMBOLS_SUMMARY = """ |
|
70
|
MATCH (n) |
|
71
|
WHERE n:Function OR n:Class OR n:Method |
|
72
|
RETURN labels(n)[0] AS type, count(n) AS count |
|
73
|
""" |
|
74
|
|
|
75
|
_TOP_SYMBOLS = """ |
|
76
|
MATCH (n) |
|
77
|
WHERE n:Function OR n:Class OR n:Method |
|
78
|
RETURN labels(n)[0] AS type, n.name AS name, |
|
79
|
coalesce(n.file_path, '') AS file_path, |
|
80
|
coalesce(n.docstring, '') AS docstring |
|
81
|
ORDER BY n.file_path, n.name |
|
82
|
LIMIT 200 |
|
83
|
""" |
|
84
|
|
|
85
|
_PROJECT_FILES = """ |
|
86
|
MATCH (f:File) |
|
87
|
RETURN f.path AS path |
|
88
|
ORDER BY f.path |
|
89
|
""" |
|
90
|
|
|
91
|
_STATS = """ |
|
92
|
MATCH (n) |
|
93
|
RETURN labels(n)[0] AS type, count(n) AS count |
|
94
|
ORDER BY count DESC |
|
95
|
""" |
|
96
|
|
|
97
|
|
|
98
|
class DocGenerator: |
|
99
|
""" |
|
100
|
Generate markdown documentation from graph context. |
|
101
|
|
|
102
|
Args: |
|
103
|
store: A :class:`~navegador.graph.GraphStore` instance. |
|
104
|
provider: Optional :class:`~navegador.llm.LLMProvider`. When |
|
105
|
``None`` (default) template-based generation is used. |
|
106
|
""" |
|
107
|
|
|
108
|
def __init__(self, store: "GraphStore", provider: "LLMProvider | None" = None) -> None: |
|
109
|
self._store = store |
|
110
|
self._provider = provider |
|
111
|
|
|
112
|
# ── Public API ──────────────────────────────────────────────────────────── |
|
113
|
|
|
114
|
def generate_file_docs(self, file_path: str) -> str: |
|
115
|
""" |
|
116
|
Generate markdown documentation for all symbols in a file. |
|
117
|
|
|
118
|
Args: |
|
119
|
file_path: Path to the source file (as stored in the graph). |
|
120
|
|
|
121
|
Returns: |
|
122
|
Markdown string describing the file's symbols. |
|
123
|
""" |
|
124
|
if self._provider: |
|
125
|
return self._llm_file_docs(file_path) |
|
126
|
return self._template_file_docs(file_path) |
|
127
|
|
|
128
|
def generate_module_docs(self, module_name: str) -> str: |
|
129
|
""" |
|
130
|
Generate markdown documentation for a Python module (by dotted name |
|
131
|
or partial path). |
|
132
|
|
|
133
|
Args: |
|
134
|
module_name: Dotted module name (``"navegador.graph"``) or partial |
|
135
|
file path substring. |
|
136
|
|
|
137
|
Returns: |
|
138
|
Markdown string describing all symbols in the module. |
|
139
|
""" |
|
140
|
# Convert dotted module name to a path fragment |
|
141
|
module_path = module_name.replace(".", "/") |
|
142
|
if self._provider: |
|
143
|
return self._llm_module_docs(module_name, module_path) |
|
144
|
return self._template_module_docs(module_name, module_path) |
|
145
|
|
|
146
|
def generate_project_docs(self) -> str: |
|
147
|
""" |
|
148
|
Generate a full project documentation overview. |
|
149
|
|
|
150
|
Returns: |
|
151
|
Markdown string with a project overview, file listing, and |
|
152
|
symbol summary. |
|
153
|
""" |
|
154
|
if self._provider: |
|
155
|
return self._llm_project_docs() |
|
156
|
return self._template_project_docs() |
|
157
|
|
|
158
|
# ── Template-based generation ───────────────────────────────────────── |
|
159
|
|
|
160
|
def _template_file_docs(self, file_path: str) -> str: |
|
161
|
result = self._store.query(_FILE_SYMBOLS, {"path": file_path}) |
|
162
|
rows = result.result_set or [] |
|
163
|
|
|
164
|
lines = [f"# File: `{file_path}`", ""] |
|
165
|
if not rows: |
|
166
|
lines.append("_No symbols found in the graph for this file._") |
|
167
|
return "\n".join(lines) |
|
168
|
|
|
169
|
for row in rows: |
|
170
|
sym_type, name, docstring, signature, line = (row[0], row[1], row[2], row[3], row[4]) |
|
171
|
lines.append(f"## {sym_type}: `{name}`") |
|
172
|
if line is not None: |
|
173
|
lines.append(f"_Line {line}_") |
|
174
|
if signature: |
|
175
|
lines += ["", f"```python\n{signature}\n```"] |
|
176
|
if docstring: |
|
177
|
lines += ["", docstring] |
|
178
|
lines.append("") |
|
179
|
|
|
180
|
return "\n".join(lines) |
|
181
|
|
|
182
|
def _template_module_docs(self, module_name: str, module_path: str) -> str: |
|
183
|
result = self._store.query(_MODULE_SYMBOLS, {"module_path": module_path}) |
|
184
|
rows = result.result_set or [] |
|
185
|
|
|
186
|
lines = [f"# Module: `{module_name}`", ""] |
|
187
|
if not rows: |
|
188
|
lines.append("_No symbols found in the graph for this module._") |
|
189
|
return "\n".join(lines) |
|
190
|
|
|
191
|
# Group by file |
|
192
|
files: dict[str, list[tuple]] = {} |
|
193
|
for row in rows: |
|
194
|
fp = row[2] or "" |
|
195
|
files.setdefault(fp, []).append(row) |
|
196
|
|
|
197
|
for fp, file_rows in sorted(files.items()): |
|
198
|
lines.append(f"## `{fp}`") |
|
199
|
lines.append("") |
|
200
|
for row in file_rows: |
|
201
|
sym_type, name, _, docstring, signature = (row[0], row[1], row[2], row[3], row[4]) |
|
202
|
lines.append(f"### {sym_type}: `{name}`") |
|
203
|
if signature: |
|
204
|
lines += ["", f"```python\n{signature}\n```"] |
|
205
|
if docstring: |
|
206
|
lines += ["", docstring] |
|
207
|
lines.append("") |
|
208
|
|
|
209
|
return "\n".join(lines) |
|
210
|
|
|
211
|
def _template_project_docs(self) -> str: |
|
212
|
# Stats |
|
213
|
stats_result = self._store.query(_STATS, {}) |
|
214
|
stats_rows = stats_result.result_set or [] |
|
215
|
|
|
216
|
# Files |
|
217
|
files_result = self._store.query(_PROJECT_FILES, {}) |
|
218
|
files = [row[0] for row in (files_result.result_set or []) if row[0]] |
|
219
|
|
|
220
|
# Symbols (capped) |
|
221
|
syms_result = self._store.query(_TOP_SYMBOLS, {}) |
|
222
|
sym_rows = syms_result.result_set or [] |
|
223
|
|
|
224
|
lines = ["# Project Documentation", ""] |
|
225
|
lines += ["## Overview", ""] |
|
226
|
|
|
227
|
if stats_rows: |
|
228
|
lines.append("| Node type | Count |") |
|
229
|
lines.append("|-----------|-------|") |
|
230
|
for row in stats_rows: |
|
231
|
if row[0]: |
|
232
|
lines.append(f"| {row[0]} | {row[1]} |") |
|
233
|
lines.append("") |
|
234
|
|
|
235
|
if files: |
|
236
|
lines += ["## Files", ""] |
|
237
|
for fp in files[:50]: |
|
238
|
lines.append(f"- `{fp}`") |
|
239
|
if len(files) > 50: |
|
240
|
lines.append(f"- _…and {len(files) - 50} more_") |
|
241
|
lines.append("") |
|
242
|
|
|
243
|
if sym_rows: |
|
244
|
lines += ["## Symbols", ""] |
|
245
|
for row in sym_rows: |
|
246
|
sym_type, name, fp, docstring = row[0], row[1], row[2], row[3] |
|
247
|
summary = docstring.split("\n")[0][:80] if docstring else "" |
|
248
|
loc = f" — `{fp}`" if fp else "" |
|
249
|
lines.append(f"- **[{sym_type}]** `{name}`{loc}") |
|
250
|
if summary: |
|
251
|
lines.append(f" > {summary}") |
|
252
|
lines.append("") |
|
253
|
|
|
254
|
return "\n".join(lines) |
|
255
|
|
|
256
|
# ── LLM-based generation ────────────────────────────────────────────── |
|
257
|
|
|
258
|
def _llm_file_docs(self, file_path: str) -> str: |
|
259
|
from navegador.intelligence.nlp import NLPEngine |
|
260
|
|
|
261
|
engine = NLPEngine(self._store, self._provider) # type: ignore[arg-type] |
|
262
|
# Use the NLPEngine generate_docs per symbol, then stitch together |
|
263
|
result = self._store.query(_FILE_SYMBOLS, {"path": file_path}) |
|
264
|
rows = result.result_set or [] |
|
265
|
|
|
266
|
lines = [f"# File: `{file_path}`", ""] |
|
267
|
if not rows: |
|
268
|
lines.append("_No symbols found in the graph for this file._") |
|
269
|
return "\n".join(lines) |
|
270
|
|
|
271
|
for row in rows[:10]: # cap to avoid excessive API calls |
|
272
|
name = row[1] |
|
273
|
lines.append(engine.generate_docs(name, file_path=file_path)) |
|
274
|
lines.append("") |
|
275
|
|
|
276
|
return "\n".join(lines) |
|
277
|
|
|
278
|
def _llm_module_docs(self, module_name: str, module_path: str) -> str: |
|
279
|
from navegador.intelligence.nlp import NLPEngine |
|
280
|
|
|
281
|
engine = NLPEngine(self._store, self._provider) # type: ignore[arg-type] |
|
282
|
|
|
283
|
result = self._store.query(_MODULE_SYMBOLS, {"module_path": module_path}) |
|
284
|
rows = result.result_set or [] |
|
285
|
|
|
286
|
lines = [f"# Module: `{module_name}`", ""] |
|
287
|
if not rows: |
|
288
|
lines.append("_No symbols found in the graph for this module._") |
|
289
|
return "\n".join(lines) |
|
290
|
|
|
291
|
for row in rows[:10]: |
|
292
|
name, fp = row[1], row[2] |
|
293
|
lines.append(engine.generate_docs(name, file_path=fp or "")) |
|
294
|
lines.append("") |
|
295
|
|
|
296
|
return "\n".join(lines) |
|
297
|
|
|
298
|
def _llm_project_docs(self) -> str: |
|
299
|
assert self._provider is not None |
|
300
|
# Build a compact summary then ask the LLM to produce an overview |
|
301
|
template_summary = self._template_project_docs() |
|
302
|
|
|
303
|
prompt = ( |
|
304
|
"You are a technical documentation writer. " |
|
305
|
"Based on the following auto-generated project summary, write a " |
|
306
|
"polished, human-readable project README in markdown.\n\n" |
|
307
|
f"{template_summary}" |
|
308
|
) |
|
309
|
return self._provider.complete(prompt) |
|
310
|
|