muhammad7456 commited on
Commit
944f820
Β·
0 Parent(s):

staging deployment completed

Browse files
.gitignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # secrets
13
+ .env
14
+
15
+ # directories
16
+ design/
17
+ .idea/
18
+
19
+ # files
20
+ *.toml
21
+ .python-version
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # UCode - Understand Monolith Codebases with Ease
2
+
call_graph.json ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "AI Practice.api.read_root": {
3
+ "id": "AI Practice.api.read_root",
4
+ "name": "read_root",
5
+ "class_name": "",
6
+ "module": "AI Practice",
7
+ "file": "AI Practice/api.py",
8
+ "calls": [
9
+ "app.get"
10
+ ],
11
+ "called_by": []
12
+ },
13
+ "AI Practice.api.get_todos": {
14
+ "id": "AI Practice.api.get_todos",
15
+ "name": "get_todos",
16
+ "class_name": "",
17
+ "module": "AI Practice",
18
+ "file": "AI Practice/api.py",
19
+ "calls": [
20
+ "app.get"
21
+ ],
22
+ "called_by": []
23
+ },
24
+ "AI Practice.api.get_todo": {
25
+ "id": "AI Practice.api.get_todo",
26
+ "name": "get_todo",
27
+ "class_name": "",
28
+ "module": "AI Practice",
29
+ "file": "AI Practice/api.py",
30
+ "calls": [
31
+ "app.get",
32
+ "HTTPException"
33
+ ],
34
+ "called_by": []
35
+ },
36
+ "AI Practice.api.create_todo": {
37
+ "id": "AI Practice.api.create_todo",
38
+ "name": "create_todo",
39
+ "class_name": "",
40
+ "module": "AI Practice",
41
+ "file": "AI Practice/api.py",
42
+ "calls": [
43
+ "app.post",
44
+ "todos.append",
45
+ "todo.dict"
46
+ ],
47
+ "called_by": []
48
+ },
49
+ "AI Practice.api.update_todo": {
50
+ "id": "AI Practice.api.update_todo",
51
+ "name": "update_todo",
52
+ "class_name": "",
53
+ "module": "AI Practice",
54
+ "file": "AI Practice/api.py",
55
+ "calls": [
56
+ "app.put",
57
+ "enumerate",
58
+ "HTTPException",
59
+ "updated_todo.dict"
60
+ ],
61
+ "called_by": []
62
+ },
63
+ "AI Practice.api.delete_todo": {
64
+ "id": "AI Practice.api.delete_todo",
65
+ "name": "delete_todo",
66
+ "class_name": "",
67
+ "module": "AI Practice",
68
+ "file": "AI Practice/api.py",
69
+ "calls": [
70
+ "app.delete",
71
+ "enumerate",
72
+ "HTTPException",
73
+ "todos.pop"
74
+ ],
75
+ "called_by": []
76
+ },
77
+ "Chat Bot.app.home": {
78
+ "id": "Chat Bot.app.home",
79
+ "name": "home",
80
+ "class_name": "",
81
+ "module": "Chat Bot",
82
+ "file": "Chat Bot/app.py",
83
+ "calls": [
84
+ "app.route",
85
+ "render_template"
86
+ ],
87
+ "called_by": []
88
+ },
89
+ "Chat Bot.app.chat": {
90
+ "id": "Chat Bot.app.chat",
91
+ "name": "chat",
92
+ "class_name": "",
93
+ "module": "Chat Bot",
94
+ "file": "Chat Bot/app.py",
95
+ "calls": [
96
+ "app.route",
97
+ "request.json.get",
98
+ "memory.append",
99
+ "client.chat.completions.create",
100
+ "jsonify",
101
+ "str"
102
+ ],
103
+ "called_by": []
104
+ },
105
+ "IMCSBOT.app.generate": {
106
+ "id": "IMCSBOT.app.generate",
107
+ "name": "generate",
108
+ "class_name": "",
109
+ "module": "IMCSBOT",
110
+ "file": "IMCSBOT/app.py",
111
+ "calls": [
112
+ "app.post",
113
+ "generator"
114
+ ],
115
+ "called_by": []
116
+ }
117
+ }
config/__init__.py ADDED
File without changes
config/config.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+ load_dotenv()
4
+
5
+ PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")
6
+ PINECONE_INDEX = "codebase-oracle"
7
+ EMBEDDING_DIM = 384
inference/__init__.py ADDED
File without changes
inference/inference.py ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py
3
+ ------------
4
+ Pure LLM inference layer for the Codebase Oracle system.
5
+ No HTTP server, no FastAPI β€” just context + query β†’ response.
6
+
7
+ Responsibilities:
8
+ 1. Accept query type + subtype + function/module name
9
+ 2. Retrieve relevant context from vector store + call graph
10
+ 3. Build correct prompt via prompts/ modules
11
+ 4. Call OpenRouter API
12
+ 5. Return clean markdown response string
13
+
14
+ This module is the single entry point that main.py (FastAPI) calls.
15
+
16
+ Depends on:
17
+ - retriever.py
18
+ - call_graph.py
19
+ - macro_prompts.py
20
+ - micro_prompts.py
21
+ - cross_module_prompts.py
22
+ - openai (OpenRouter is OpenAI-compatible)
23
+ - rich (logging only)
24
+
25
+ Install:
26
+ pip install openai rich
27
+ """
28
+
29
+ import os
30
+ from dataclasses import dataclass, field
31
+ from dotenv import load_dotenv
32
+ from openai import OpenAI
33
+ from rich.console import Console
34
+
35
+ load_dotenv() # loads OPENROUTER_API_KEY from .env file
36
+
37
+ from retrieve.retrieve import Retriever, QueryType
38
+ from store.call_graph import get_call_graph
39
+ from prompt.macro_prompt import (
40
+ get_macro_system_prompt,
41
+ build_macro_user_prompt,
42
+ )
43
+ from prompt.micro_prompt import (
44
+ get_micro_system_prompt,
45
+ build_micro_user_prompt,
46
+ build_micro_followup_prompt,
47
+ )
48
+ from prompt.cross_module import (
49
+ get_cross_module_system_prompt,
50
+ build_cross_module_user_prompt,
51
+ build_cross_module_followup_prompt,
52
+ build_call_graph_payload,
53
+ )
54
+
55
+ console = Console()
56
+
57
+ # ── Constants ─────────────────────────────────────────────────────────────────
58
+
59
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
60
+ DEFAULT_MODEL = "nvidia/nemotron-3-super-120b-a12b:free"
61
+ MAX_TOKENS = 4096
62
+ CONTEXT_MAX_CHARS = 6000 # max chars fed to LLM as retrieved context
63
+ N_RESULTS_DEFAULT = 5 # default number of chunks to retrieve
64
+
65
+
66
+ # ── Request / Response Models ─────────────────────────────────────────────────
67
+
68
+ @dataclass
69
+ class InferenceRequest:
70
+ """
71
+ Unified request model for all query types.
72
+
73
+ Fields:
74
+ query_type : "macro" | "micro" | "cross_module"
75
+ query : Developer's natural language question
76
+ subtype : macro subtype or "" for micro/cross_module
77
+ function_name : Target function/method name (micro + cross_module)
78
+ class_name : Target class name if method (optional)
79
+ module_name : Target module name (macro module_responsibility)
80
+ followup : True if this is a follow-up to a previous response
81
+ previous_response: Previous LLM response for follow-up context
82
+ """
83
+ query_type: str
84
+ query: str
85
+ subtype: str = ""
86
+ function_name: str = ""
87
+ class_name: str = ""
88
+ module_name: str = ""
89
+ followup: bool = False
90
+ previous_response: str = ""
91
+
92
+
93
+ @dataclass
94
+ class InferenceResponse:
95
+ """
96
+ Unified response model returned to FastAPI / caller.
97
+
98
+ Fields:
99
+ success : True if inference succeeded
100
+ content : Markdown response string
101
+ error : Error message if success is False
102
+ metadata : Optional dict with retrieval stats
103
+ """
104
+ success: bool
105
+ content: str = ""
106
+ error: str = ""
107
+ metadata: dict = field(default_factory=dict)
108
+
109
+
110
+ # ── OpenRouter Client ─────────────────────────────────────────────────────────
111
+
112
+ def _get_client() -> OpenAI:
113
+ """
114
+ Build and return an OpenAI-compatible client pointed at OpenRouter.
115
+ Reads OPENROUTER_API_KEY from .env file via load_dotenv.
116
+
117
+ Raises:
118
+ EnvironmentError: If OPENROUTER_API_KEY is not set.
119
+ """
120
+ api_key = os.getenv("OPENROUTER_API_KEY")
121
+ if not api_key:
122
+ raise EnvironmentError(
123
+ "OPENROUTER_API_KEY environment variable is not set. "
124
+ "Add OPENROUTER_API_KEY=your_key to your .env file."
125
+ )
126
+ return OpenAI(
127
+ base_url=OPENROUTER_BASE_URL,
128
+ api_key=api_key,
129
+ )
130
+
131
+
132
+ def _call_llm(system_prompt: str,
133
+ user_prompt: str,
134
+ model: str = DEFAULT_MODEL) -> str:
135
+ """
136
+ Make a single LLM call via OpenRouter and return the response text.
137
+
138
+ Args:
139
+ system_prompt : System prompt string.
140
+ user_prompt : User prompt string.
141
+ model : OpenRouter model identifier.
142
+
143
+ Returns:
144
+ Raw response text from LLM.
145
+
146
+ Raises:
147
+ Exception: On API errors, propagated to caller.
148
+ """
149
+ client = _get_client()
150
+
151
+ response = client.chat.completions.create(
152
+ model=model,
153
+ max_tokens=MAX_TOKENS,
154
+ messages=[
155
+ {"role": "system", "content": system_prompt},
156
+ {"role": "user", "content": user_prompt},
157
+ ],
158
+ )
159
+
160
+ return response.choices[0].message.content or ""
161
+
162
+
163
+ # ── Inference Engine ──────────────────────────────────────────────────────────
164
+
165
+ class InferenceEngine:
166
+ """
167
+ Core inference engine for the Codebase Oracle.
168
+ Retrieves context, builds prompts, calls LLM, returns response.
169
+
170
+ Single instance shared across all FastAPI requests.
171
+ """
172
+
173
+ def __init__(self, model: str = DEFAULT_MODEL):
174
+ self.model = model
175
+ self.retriever = Retriever()
176
+ console.print(
177
+ f"[green]βœ”[/green] InferenceEngine ready "
178
+ f"(model: [cyan]{model}[/cyan])\n"
179
+ )
180
+
181
+ # ── Public Entry Point ────────────────────────────────────────────────────
182
+
183
+ def infer(self, request: InferenceRequest) -> InferenceResponse:
184
+ """
185
+ Main entry point. Routes to the correct handler based on query_type.
186
+
187
+ Args:
188
+ request: InferenceRequest with all query parameters.
189
+
190
+ Returns:
191
+ InferenceResponse with markdown content or error.
192
+ """
193
+ try:
194
+ if request.query_type == QueryType.MACRO:
195
+ return self._handle_macro(request)
196
+
197
+ elif request.query_type == QueryType.MICRO:
198
+ return self._handle_micro(request)
199
+
200
+ elif request.query_type == QueryType.CROSS_MODULE:
201
+ return self._handle_cross_module(request)
202
+
203
+ else:
204
+ return InferenceResponse(
205
+ success=False,
206
+ error=f"Unknown query_type: '{request.query_type}'. "
207
+ f"Use 'macro', 'micro', or 'cross_module'."
208
+ )
209
+
210
+ except EnvironmentError as e:
211
+ return InferenceResponse(success=False, error=str(e))
212
+
213
+ except Exception as e:
214
+ console.print(f"[red]❌ Inference error: {e}[/red]")
215
+ return InferenceResponse(
216
+ success=False,
217
+ error=f"Inference failed: {str(e)}"
218
+ )
219
+
220
+ # ── Macro Handler ─────────────────────────────────────────────────────────
221
+
222
+ def _handle_macro(self, req: InferenceRequest) -> InferenceResponse:
223
+ """Handle macro-level queries (architecture, module, data flow)."""
224
+
225
+ if not req.subtype:
226
+ return InferenceResponse(
227
+ success=False,
228
+ error="Macro queries require a subtype: "
229
+ "'overall_architecture', 'module_responsibility', "
230
+ "or 'data_flow'."
231
+ )
232
+
233
+ # Retrieve class-level chunks β€” macro uses class collection
234
+ chunks = self.retriever.retrieve(
235
+ query=req.query,
236
+ query_type=QueryType.MACRO,
237
+ n_results=N_RESULTS_DEFAULT,
238
+ filters={"module": {"$eq": req.module_name}}
239
+ if req.module_name and req.subtype == "module_responsibility"
240
+ else None,
241
+ )
242
+
243
+ context = self.retriever.build_context(chunks, CONTEXT_MAX_CHARS)
244
+ system = get_macro_system_prompt(req.subtype)
245
+ user = build_macro_user_prompt(
246
+ subtype=req.subtype,
247
+ context=context,
248
+ query=req.query,
249
+ module_name=req.module_name,
250
+ )
251
+
252
+ console.print(
253
+ f"[cyan]β†’ Macro [{req.subtype}][/cyan] "
254
+ f"| {len(chunks)} chunks | {len(context)} chars"
255
+ )
256
+
257
+ content = _call_llm(system, user, self.model)
258
+
259
+ return InferenceResponse(
260
+ success=True,
261
+ content=content,
262
+ metadata={
263
+ "query_type": "macro",
264
+ "subtype": req.subtype,
265
+ "chunks_used": len(chunks),
266
+ "context_chars": len(context),
267
+ }
268
+ )
269
+
270
+ # ── Micro Handler ─────────────────────────────────────────────────────────
271
+
272
+ def _handle_micro(self, req: InferenceRequest) -> InferenceResponse:
273
+ """Handle micro-level queries (function definition, body, consideration)."""
274
+
275
+ if not req.function_name:
276
+ return InferenceResponse(
277
+ success=False,
278
+ error="Micro queries require a function_name."
279
+ )
280
+
281
+ # Follow-up path
282
+ if req.followup and req.previous_response:
283
+ complete_chunks = self.retriever.retrieve_complete_function(
284
+ req.function_name, req.class_name, n_results=1
285
+ )
286
+ context = self.retriever.build_context(complete_chunks, CONTEXT_MAX_CHARS)
287
+ system = get_micro_system_prompt(followup=True)
288
+ user = build_micro_followup_prompt(
289
+ previous_response=req.previous_response,
290
+ followup_query=req.query,
291
+ context=context,
292
+ )
293
+ else:
294
+ # Primary path β€” retrieve complete function + complete class context
295
+ complete_func_chunks = self.retriever.retrieve_complete_function(
296
+ req.function_name, req.class_name, n_results=1
297
+ )
298
+ complete_class_chunks = (
299
+ self.retriever.retrieve_complete_class(req.class_name, n_results=1)
300
+ if req.class_name else []
301
+ )
302
+ all_chunks = complete_func_chunks + complete_class_chunks
303
+ context = self.retriever.build_context(all_chunks, CONTEXT_MAX_CHARS)
304
+ system = get_micro_system_prompt(followup=False)
305
+ user = build_micro_user_prompt(
306
+ context=context,
307
+ query=req.query,
308
+ function_name=req.function_name,
309
+ class_name=req.class_name,
310
+ )
311
+
312
+ console.print(
313
+ f"[cyan]β†’ Micro[/cyan] "
314
+ f"| fn: {req.function_name} "
315
+ f"| {len(context)} chars"
316
+ )
317
+
318
+ content = _call_llm(system, user, self.model)
319
+
320
+ return InferenceResponse(
321
+ success=True,
322
+ content=content,
323
+ metadata={
324
+ "query_type": "micro",
325
+ "function_name": req.function_name,
326
+ "class_name": req.class_name,
327
+ "context_chars": len(context),
328
+ "followup": req.followup,
329
+ }
330
+ )
331
+
332
+ # ── Cross-Module Handler ──────────────────────────────────────────────────
333
+
334
+ def _handle_cross_module(self, req: InferenceRequest) -> InferenceResponse:
335
+ """Handle cross-module queries (dependency + impact analysis)."""
336
+
337
+ if not req.function_name:
338
+ return InferenceResponse(
339
+ success=False,
340
+ error="Cross-module queries require a function_name."
341
+ )
342
+
343
+ # Primary context β€” the target function
344
+ primary_chunks = self.retriever.retrieve_by_function(
345
+ req.function_name, req.class_name, n_results=2
346
+ )
347
+ primary_context = self.retriever.build_context(
348
+ primary_chunks, CONTEXT_MAX_CHARS // 2
349
+ )
350
+
351
+ # Dependency context β€” callers and callees
352
+ dep_chunks = self.retriever.retrieve_dependencies(req.function_name)
353
+ dep_context = self.retriever.build_context(
354
+ dep_chunks, CONTEXT_MAX_CHARS // 2
355
+ )
356
+
357
+ # Call graph data
358
+ try:
359
+ graph = get_call_graph()
360
+ calls = graph.get_calls(req.function_name, req.class_name)
361
+ called_by = graph.get_called_by(req.function_name, req.class_name)
362
+ impact = graph.get_impact(req.function_name, req.class_name, depth=2)
363
+ except FileNotFoundError:
364
+ calls, called_by, impact = [], [], {}
365
+ console.print(
366
+ "[yellow]⚠ call_graph.json not found β€” "
367
+ "proceeding without graph data.[/yellow]"
368
+ )
369
+
370
+ call_graph_payload = build_call_graph_payload(
371
+ function_name=req.function_name,
372
+ calls=calls,
373
+ called_by=called_by,
374
+ impact=impact,
375
+ )
376
+
377
+ # Follow-up path
378
+ if req.followup and req.previous_response:
379
+ system = get_cross_module_system_prompt(followup=True)
380
+ user = build_cross_module_followup_prompt(
381
+ previous_response=req.previous_response,
382
+ followup_query=req.query,
383
+ primary_context=primary_context,
384
+ dependency_context=dep_context,
385
+ call_graph_data=call_graph_payload,
386
+ )
387
+ else:
388
+ system = get_cross_module_system_prompt(followup=False)
389
+ user = build_cross_module_user_prompt(
390
+ primary_context=primary_context,
391
+ dependency_context=dep_context,
392
+ call_graph_data=call_graph_payload,
393
+ query=req.query,
394
+ function_name=req.function_name,
395
+ class_name=req.class_name,
396
+ )
397
+
398
+ console.print(
399
+ f"[cyan]β†’ Cross-Module[/cyan] "
400
+ f"| fn: {req.function_name} "
401
+ f"| deps: {len(dep_chunks)} "
402
+ f"| graph nodes: {len(calls) + len(called_by)}"
403
+ )
404
+
405
+ content = _call_llm(system, user, self.model)
406
+
407
+ return InferenceResponse(
408
+ success=True,
409
+ content=content,
410
+ metadata={
411
+ "query_type": "cross_module",
412
+ "function_name": req.function_name,
413
+ "calls": len(calls),
414
+ "called_by": len(called_by),
415
+ "dep_chunks": len(dep_chunks),
416
+ "followup": req.followup,
417
+ }
418
+ )
419
+
420
+
421
+ # ── Singleton Access ──────────────────────────────────────────────────────────
422
+
423
+ _engine_instance: InferenceEngine | None = None
424
+
425
+
426
+ def get_engine(model: str = DEFAULT_MODEL) -> InferenceEngine:
427
+ """
428
+ Return a singleton InferenceEngine instance.
429
+ Creates it on first call, reuses on subsequent calls.
430
+
431
+ Args:
432
+ model: OpenRouter model identifier.
433
+
434
+ Returns:
435
+ Shared InferenceEngine instance.
436
+ """
437
+ global _engine_instance
438
+ if _engine_instance is None:
439
+ _engine_instance = InferenceEngine(model)
440
+ return _engine_instance
441
+
442
+
443
+ # ── Entry Point (manual test) ─────────────────────────────────────────────────
444
+
445
+ if __name__ == "__main__":
446
+ import sys
447
+ from rich.markdown import Markdown
448
+
449
+ console.rule("[bold cyan]Inference Engine β€” Manual Test[/bold cyan]")
450
+
451
+ engine = get_engine()
452
+
453
+ # Test micro query
454
+ console.rule("[cyan]Test β€” Micro Query[/cyan]")
455
+ req = InferenceRequest(
456
+ query_type="micro",
457
+ query="What does this function do and how do I use it?",
458
+ function_name=sys.argv[1] if len(sys.argv) > 1 else "parse_file",
459
+ class_name="",
460
+ )
461
+
462
+ resp = engine.infer(req)
463
+
464
+ if resp.success:
465
+ console.print(Markdown(resp.content))
466
+ console.print(f"\n[dim]Metadata: {resp.metadata}[/dim]")
467
+ else:
468
+ console.print(f"[red]❌ Error: {resp.error}[/red]")
ingest/__init__.py ADDED
File without changes
ingest/embed.py ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ embedder.py
3
+ -----------
4
+ Converts parsed codebase (FileInfo objects) into hybrid chunks and
5
+ stores them in ChromaDB across two collections:
6
+
7
+ - class_chunks : one chunk per class (for macro / cross-module queries)
8
+ - function_chunks : one chunk per function/method (for micro queries)
9
+
10
+ Each chunk carries rich metadata so the retriever can filter precisely.
11
+
12
+ Depends on:
13
+ - ast_parser.parse_codebase() β†’ list[FileInfo]
14
+ - chromadb
15
+ - sentence-transformers (local embedding, no API needed)
16
+
17
+ Install:
18
+ pip install chromadb sentence-transformers rich
19
+ """
20
+
21
+ import json
22
+ import hashlib
23
+ from pathlib import Path
24
+
25
+ from pinecone import Pinecone, ServerlessSpec
26
+ from sentence_transformers import SentenceTransformer
27
+ from rich.console import Console
28
+ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
29
+ from rich.panel import Panel
30
+ from rich.table import Table
31
+ from rich import box
32
+ from config.config import PINECONE_API_KEY, PINECONE_INDEX, EMBEDDING_DIM
33
+ from ingest.parse_ast import parse_codebase, FileInfo, ClassInfo, FunctionInfo
34
+
35
+ console = Console()
36
+
37
+ # ── Constants ─────────────────────────────────────────────────────────────────
38
+
39
+ EMBEDDING_MODEL = "all-MiniLM-L6-v2" # fast, lightweight, good quality
40
+ CLASS_COLLECTION = "class_chunks"
41
+ FUNCTION_COLLECTION = "function_chunks"
42
+ COMPLETE_COLLECTION = "complete_chunks"
43
+
44
+
45
+ # ── Embedding Model ───────────────────────────────────────────────────────────
46
+
47
+ def load_embedding_model() -> SentenceTransformer:
48
+ """Load the sentence transformer embedding model."""
49
+ with console.status("[bold cyan]Loading embedding model...[/bold cyan]"):
50
+ model = SentenceTransformer(EMBEDDING_MODEL)
51
+ console.print(f"[green]βœ”[/green] Embedding model loaded: [cyan]{EMBEDDING_MODEL}[/cyan]")
52
+ return model
53
+
54
+
55
+ # ── ChromaDB Client ───────────────────────────────────────────────────────────
56
+
57
+ def get_pinecone_index():
58
+ """Return a Pinecone index, creating it if it does not exist."""
59
+ pc = Pinecone(api_key=PINECONE_API_KEY)
60
+ existing = [i.name for i in pc.list_indexes()]
61
+ if PINECONE_INDEX not in existing:
62
+ pc.create_index(
63
+ name=PINECONE_INDEX,
64
+ dimension=EMBEDDING_DIM,
65
+ metric="cosine",
66
+ spec=ServerlessSpec(cloud="aws", region="us-east-1"),
67
+ )
68
+ return pc.Index(PINECONE_INDEX)
69
+
70
+
71
+ # ── Chunk Builders ────────────────────────────────────────────────────────────
72
+
73
+ def _make_id(text: str) -> str:
74
+ """Generate a stable unique ID from chunk text."""
75
+ return hashlib.md5(text.encode()).hexdigest()
76
+
77
+
78
+ def build_class_chunk(cls: ClassInfo, file_info: FileInfo) -> dict:
79
+ """
80
+ Build a class-level chunk document.
81
+ Contains: class name, bases, docstring, all method signatures.
82
+ """
83
+ method_signatures = []
84
+ for m in cls.methods:
85
+ params = ", ".join(
86
+ f"{p.name}: {p.annotation}" if p.annotation else p.name
87
+ for p in m.parameters
88
+ )
89
+ ret = f" -> {m.return_type}" if m.return_type else ""
90
+ method_signatures.append(f" def {m.name}({params}){ret}")
91
+
92
+ methods_block = "\n".join(method_signatures) if method_signatures else " # no methods"
93
+ bases_str = ", ".join(cls.bases) if cls.bases else "object"
94
+ docstring = cls.docstring or "No docstring provided."
95
+
96
+ text = (
97
+ f"Class: {cls.name}\n"
98
+ f"Inherits: {bases_str}\n"
99
+ f"Module: {file_info.module}\n"
100
+ f"File: {file_info.relative}\n"
101
+ f"Docstring: {docstring}\n"
102
+ f"Methods:\n{methods_block}"
103
+ )
104
+
105
+ metadata = {
106
+ "type": "class",
107
+ "name": cls.name,
108
+ "module": file_info.module,
109
+ "file": file_info.relative,
110
+ "bases": json.dumps(cls.bases),
111
+ "methods": json.dumps([m.name for m in cls.methods]),
112
+ "lineno": cls.lineno,
113
+ }
114
+
115
+ return {"id": _make_id(text), "text": text, "metadata": metadata}
116
+
117
+
118
+ def build_function_chunk(func: FunctionInfo,
119
+ file_info: FileInfo,
120
+ class_name: str | None = None,
121
+ class_docstring: str | None = None) -> dict:
122
+ """
123
+ Build a function/method-level chunk document.
124
+ Carries class context as metadata so micro queries stay grounded.
125
+ """
126
+ params = ", ".join(
127
+ f"{p.name}: {p.annotation}" if p.annotation else p.name
128
+ for p in func.parameters
129
+ )
130
+ ret = f" -> {func.return_type}" if func.return_type else ""
131
+ signature = f"def {func.name}({params}){ret}"
132
+
133
+ docstring = func.docstring or "No docstring provided."
134
+ calls_str = ", ".join(func.calls[:15]) if func.calls else "none"
135
+ class_ctx = (
136
+ f"Class: {class_name}\nClass purpose: {class_docstring or 'N/A'}\n"
137
+ if class_name else "Scope: top-level function\n"
138
+ )
139
+
140
+ text = (
141
+ f"{class_ctx}"
142
+ f"Function: {func.name}\n"
143
+ f"Module: {file_info.module}\n"
144
+ f"File: {file_info.relative}\n"
145
+ f"Signature: {signature}\n"
146
+ f"Docstring: {docstring}\n"
147
+ f"Calls: {calls_str}"
148
+ )
149
+
150
+ metadata = {
151
+ "type": "function",
152
+ "name": func.name,
153
+ "module": file_info.module,
154
+ "file": file_info.relative,
155
+ "class_name": class_name or "",
156
+ "return_type": func.return_type or "",
157
+ "parameters": json.dumps([p.name for p in func.parameters]),
158
+ "calls": json.dumps(func.calls[:15]),
159
+ "is_method": str(func.is_method),
160
+ "lineno": func.lineno,
161
+ }
162
+
163
+ return {"id": _make_id(text), "text": text, "metadata": metadata}
164
+
165
+ def build_module_chunk(file_info: FileInfo) -> dict:
166
+ """
167
+ Build a module-level chunk for files that contain no classes or functions.
168
+ Captures imports and docstring as the indexable content.
169
+ """
170
+ imports_str = ", ".join(file_info.imports) if file_info.imports else "none"
171
+ docstring = file_info.docstring or "No module docstring."
172
+
173
+ text = (
174
+ f"Module: {file_info.module}\n"
175
+ f"File: {file_info.relative}\n"
176
+ f"Docstring: {docstring}\n"
177
+ f"Imports: {imports_str}\n"
178
+ f"Note: This file contains only module-level statements."
179
+ )
180
+
181
+ metadata = {
182
+ "type": "module",
183
+ "name": Path(file_info.relative).stem,
184
+ "module": file_info.module,
185
+ "file": file_info.relative,
186
+ "class_name": "",
187
+ "return_type": "",
188
+ "parameters": "[]",
189
+ "calls": "[]",
190
+ "is_method": "False",
191
+ "lineno": 0,
192
+ }
193
+
194
+ return {"id": _make_id(text), "text": text, "metadata": metadata}
195
+
196
+ def build_complete_function_chunk(func: FunctionInfo,
197
+ file_info: FileInfo,
198
+ class_name: str | None = None,
199
+ class_docstring: str | None = None) -> dict:
200
+ """
201
+ Build a complete function chunk including full source code.
202
+ Used for edge case analysis and usage example generation.
203
+ """
204
+ params = ", ".join(
205
+ f"{p.name}: {p.annotation}" if p.annotation else p.name
206
+ for p in func.parameters
207
+ )
208
+ ret = f" -> {func.return_type}" if func.return_type else ""
209
+ signature = f"def {func.name}({params}){ret}"
210
+
211
+ docstring = func.docstring or "No docstring provided."
212
+ calls_str = ", ".join(func.calls[:15]) if func.calls else "none"
213
+ class_ctx = (
214
+ f"Class: {class_name}\nClass purpose: {class_docstring or 'N/A'}\n"
215
+ if class_name else "Scope: top-level function\n"
216
+ )
217
+ source_block = func.source if func.source else "Source not available."
218
+
219
+ text = (
220
+ f"{class_ctx}"
221
+ f"Function: {func.name}\n"
222
+ f"Module: {file_info.module}\n"
223
+ f"File: {file_info.relative}\n"
224
+ f"Signature: {signature}\n"
225
+ f"Docstring: {docstring}\n"
226
+ f"Calls: {calls_str}\n"
227
+ f"Source Code:\n{source_block}"
228
+ )
229
+
230
+ metadata = {
231
+ "type": "complete_function",
232
+ "name": func.name,
233
+ "module": file_info.module,
234
+ "file": file_info.relative,
235
+ "class_name": class_name or "",
236
+ "return_type": func.return_type or "",
237
+ "parameters": json.dumps([p.name for p in func.parameters]),
238
+ "calls": json.dumps(func.calls[:15]),
239
+ "is_method": str(func.is_method),
240
+ "lineno": func.lineno,
241
+ }
242
+
243
+ return {"id": _make_id(text), "text": text, "metadata": metadata}
244
+
245
+
246
+ def build_complete_class_chunk(cls: ClassInfo, file_info: FileInfo) -> dict:
247
+ """
248
+ Build a complete class chunk including full source code.
249
+ Used for class-level deep queries.
250
+ """
251
+ bases_str = ", ".join(cls.bases) if cls.bases else "object"
252
+ docstring = cls.docstring or "No docstring provided."
253
+ source_block = cls.source if cls.source else "Source not available."
254
+
255
+ text = (
256
+ f"Class: {cls.name}\n"
257
+ f"Inherits: {bases_str}\n"
258
+ f"Module: {file_info.module}\n"
259
+ f"File: {file_info.relative}\n"
260
+ f"Docstring: {docstring}\n"
261
+ f"Source Code:\n{source_block}"
262
+ )
263
+
264
+ metadata = {
265
+ "type": "complete_class",
266
+ "name": cls.name,
267
+ "module": file_info.module,
268
+ "file": file_info.relative,
269
+ "bases": json.dumps(cls.bases),
270
+ "methods": json.dumps([m.name for m in cls.methods]),
271
+ "lineno": cls.lineno,
272
+ }
273
+
274
+ return {"id": _make_id(text), "text": text, "metadata": metadata}
275
+
276
+
277
+ def build_file_chunk(file_info: FileInfo) -> dict:
278
+ """
279
+ Build a file-level chunk containing the entire source of a file.
280
+ Used for file-wide queries.
281
+ """
282
+ try:
283
+ source_block = Path(file_info.path).read_text(encoding="utf-8", errors="ignore")
284
+ except Exception:
285
+ source_block = "Source not available."
286
+
287
+ docstring = file_info.docstring or "No module docstring."
288
+ imports_str = ", ".join(file_info.imports) if file_info.imports else "none"
289
+
290
+ text = (
291
+ f"File: {file_info.relative}\n"
292
+ f"Module: {file_info.module}\n"
293
+ f"Docstring: {docstring}\n"
294
+ f"Imports: {imports_str}\n"
295
+ f"Source Code:\n{source_block}"
296
+ )
297
+
298
+ metadata = {
299
+ "type": "file",
300
+ "name": Path(file_info.relative).stem,
301
+ "module": file_info.module,
302
+ "file": file_info.relative,
303
+ "class_name": "",
304
+ "return_type": "",
305
+ "parameters": "[]",
306
+ "calls": "[]",
307
+ "is_method": "False",
308
+ "lineno": 0,
309
+ }
310
+
311
+ return {"id": _make_id(text), "text": text, "metadata": metadata}
312
+
313
+
314
+ # ── Embedding & Upserting ─────────────────────────────────────────────────────
315
+
316
+ def _upsert_batch(index, chunks: list[dict], model: SentenceTransformer, namespace: str) -> None:
317
+ """Embed and upsert a list of chunks into a Pinecone namespace."""
318
+ if not chunks:
319
+ return
320
+
321
+ texts = [c["text"] for c in chunks]
322
+ ids = [c["id"] for c in chunks]
323
+ metadatas = [c["metadata"] for c in chunks]
324
+
325
+ embeddings = model.encode(texts, show_progress_bar=False).tolist()
326
+
327
+ vectors = [
328
+ {"id": vid, "values": vec, "metadata": {**meta, "text": txt}}
329
+ for vid, vec, meta, txt in zip(ids, embeddings, metadatas, texts)
330
+ ]
331
+
332
+ index.upsert(vectors=vectors, namespace=namespace)
333
+
334
+
335
+ # ── Main Embed Pipeline ───────────────────────────────────────────────────────
336
+
337
+ def embed_codebase(root_path: str) -> None:
338
+ """
339
+ Full pipeline:
340
+ 1. Parse codebase via ast_parser
341
+ 2. Build hybrid chunks (class + function level)
342
+ 3. Embed with sentence-transformers
343
+ 4. Store in ChromaDB (two collections)
344
+
345
+ Args:
346
+ root_path: Absolute path to the monolithic codebase root.
347
+ """
348
+ console.rule("[bold cyan]Codebase Oracle β€” Embedder[/bold cyan]")
349
+
350
+ # Step 1 β€” Parse
351
+ console.print(f"\n[bold]πŸ“‚ Root:[/bold] {root_path}\n")
352
+ parsed_files: list[FileInfo] = parse_codebase(root_path)
353
+
354
+ if not parsed_files:
355
+ console.print("[yellow]⚠ No Python files parsed. Exiting.[/yellow]")
356
+ return
357
+
358
+ # Step 2 β€” Build chunks
359
+ class_chunks: list[dict] = []
360
+ function_chunks: list[dict] = []
361
+
362
+ for file_info in parsed_files:
363
+ # Class-level chunks
364
+ for cls in file_info.classes:
365
+ class_chunks.append(build_class_chunk(cls, file_info))
366
+
367
+ # Method-level chunks (carry class context)
368
+ for method in cls.methods:
369
+ function_chunks.append(build_function_chunk(
370
+ method, file_info,
371
+ class_name=cls.name,
372
+ class_docstring=cls.docstring,
373
+ ))
374
+
375
+ # Top-level function chunks
376
+ for func in file_info.functions:
377
+ function_chunks.append(build_function_chunk(func, file_info))
378
+
379
+ # Module-level chunk for files with no classes and no functions
380
+ if not file_info.classes and not file_info.functions:
381
+ function_chunks.append(build_module_chunk(file_info))
382
+
383
+ complete_chunks: list[dict] = []
384
+
385
+ for file_info in parsed_files:
386
+ complete_chunks.append(build_file_chunk(file_info))
387
+ for cls in file_info.classes:
388
+ complete_chunks.append(build_complete_class_chunk(cls, file_info))
389
+ for method in cls.methods:
390
+ complete_chunks.append(build_complete_function_chunk(
391
+ method, file_info,
392
+ class_name=cls.name,
393
+ class_docstring=cls.docstring,
394
+ ))
395
+ for func in file_info.functions:
396
+ complete_chunks.append(build_complete_function_chunk(func, file_info))
397
+
398
+ console.print(
399
+ f"[green]βœ”[/green] Chunks built: "
400
+ f"[magenta]{len(class_chunks)}[/magenta] class chunks Β· "
401
+ f"[cyan]{len(function_chunks)}[/cyan] function chunks Β· "
402
+ f"[yellow]{len(complete_chunks)}[/yellow] complete chunks\n"
403
+ )
404
+
405
+ # Step 3 β€” Load model
406
+ model = load_embedding_model()
407
+
408
+ # Step 4 β€” Pinecone
409
+ index = get_pinecone_index()
410
+
411
+ with Progress(
412
+ SpinnerColumn(),
413
+ TextColumn("[progress.description]{task.description}"),
414
+ BarColumn(),
415
+ TextColumn("{task.completed}/{task.total}"),
416
+ console=console,
417
+ ) as progress:
418
+
419
+ # Embed class chunks in batches of 32
420
+ BATCH = 32
421
+ task1 = progress.add_task(
422
+ "[magenta]Embedding class chunks...", total=len(class_chunks)
423
+ )
424
+ for i in range(0, len(class_chunks), BATCH):
425
+ batch = class_chunks[i:i + BATCH]
426
+ _upsert_batch(index, batch, model, CLASS_COLLECTION)
427
+ progress.advance(task1, len(batch))
428
+
429
+ task2 = progress.add_task(
430
+ "[cyan]Embedding function chunks...", total=len(function_chunks)
431
+ )
432
+ for i in range(0, len(function_chunks), BATCH):
433
+ batch = function_chunks[i:i + BATCH]
434
+ _upsert_batch(index, batch, model, FUNCTION_COLLECTION)
435
+ progress.advance(task2, len(batch))
436
+
437
+ task3 = progress.add_task(
438
+ "[yellow]Embedding complete chunks...", total=len(complete_chunks)
439
+ )
440
+ for i in range(0, len(complete_chunks), BATCH):
441
+ batch = complete_chunks[i:i + BATCH]
442
+ _upsert_batch(index, batch, model, COMPLETE_COLLECTION)
443
+ progress.advance(task3, len(batch))
444
+
445
+ # Step 5 β€” Summary
446
+ _render_embed_summary(root_path, class_chunks, function_chunks, complete_chunks)
447
+
448
+ def _render_embed_summary(root_path: str,
449
+ class_chunks: list[dict],
450
+ function_chunks: list[dict],
451
+ complete_chunks: list[dict]) -> None:
452
+ """Render a rich summary panel after embedding."""
453
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
454
+ table.add_column(style="dim")
455
+ table.add_column(style="bold white")
456
+
457
+ table.add_row("Codebase", root_path)
458
+ table.add_row("Embedding model", EMBEDDING_MODEL)
459
+ table.add_row("Class chunks", str(len(class_chunks)))
460
+ table.add_row("Function chunks", str(len(function_chunks)))
461
+ table.add_row("Complete chunks", str(len(complete_chunks)))
462
+ table.add_row("Total chunks", str(len(class_chunks) + len(function_chunks) + len(complete_chunks)))
463
+ table.add_row("Collections", f"{CLASS_COLLECTION}, {FUNCTION_COLLECTION}, {COMPLETE_COLLECTION}")
464
+ table.add_row("Status", "[bold green]βœ” Indexing complete[/bold green]")
465
+
466
+ console.print(Panel(table, title="[bold cyan]Embedding Summary[/bold cyan]",
467
+ border_style="cyan"))
468
+ console.print("\n[bold green]βœ” Codebase indexed. Ready for queries.[/bold green]\n")
469
+
470
+
471
+ # ── Query Helper (for retriever.py later) ────────────────────────────────────
472
+
473
+ def query_chunks(query: str,
474
+ collection_name: str,
475
+ model: SentenceTransformer,
476
+ n_results: int = 5,
477
+ filters: dict | None = None) -> list[dict]:
478
+ """
479
+ Query a Pinecone namespace and return top-n matching chunks.
480
+
481
+ Args:
482
+ query: Natural language query string.
483
+ collection_name: Namespace β€” CLASS_COLLECTION, FUNCTION_COLLECTION, or COMPLETE_COLLECTION.
484
+ model: Loaded SentenceTransformer model.
485
+ n_results: Number of results to return.
486
+ filters: Optional Pinecone metadata filters.
487
+
488
+ Returns:
489
+ List of dicts with keys: text, metadata, distance.
490
+ """
491
+ index = get_pinecone_index()
492
+ embedding = model.encode([query]).tolist()[0]
493
+
494
+ kwargs: dict = {
495
+ "vector": embedding,
496
+ "top_k": n_results,
497
+ "namespace": collection_name,
498
+ "include_metadata": True,
499
+ }
500
+ if filters:
501
+ kwargs["filter"] = filters
502
+
503
+ results = index.query(**kwargs)
504
+
505
+ output = []
506
+ for match in results["matches"]:
507
+ meta = dict(match["metadata"])
508
+ text = meta.pop("text", "")
509
+ output.append({
510
+ "text": text,
511
+ "metadata": meta,
512
+ "distance": 1 - match["score"],
513
+ })
514
+
515
+ return output
516
+
517
+
518
+ # ── Entry Point ───────────────────────────────────────────────────────────────
519
+
520
+ if __name__ == "__main__":
521
+ import sys
522
+
523
+ path = sys.argv[1] if len(sys.argv) > 1 else "."
524
+
525
+ try:
526
+ embed_codebase(path)
527
+ except (FileNotFoundError, NotADirectoryError) as e:
528
+ console.print(f"[red]❌ {e}[/red]")
529
+ sys.exit(1)
ingest/parse_ast.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ast_parser.py
3
+ -------------
4
+ Parses Python source files using the built-in `ast` module to extract
5
+ structured metadata: classes, functions, parameters, return types,
6
+ docstrings, and call relationships (for cross-module analysis).
7
+
8
+ Uses `rich` for all terminal output.
9
+ Depends on file_walker.walk_codebase() for file discovery.
10
+ """
11
+
12
+ import ast
13
+ import sys
14
+ from pathlib import Path
15
+ from dataclasses import dataclass, field
16
+
17
+ from rich.console import Console
18
+ from rich.tree import Tree
19
+ from rich.table import Table
20
+ from rich.panel import Panel
21
+ from rich.text import Text
22
+ from rich import box
23
+
24
+ # Import from file_walker (must be in same directory or on PYTHONPATH)
25
+ from ingest.walk_files import walk_codebase, group_by_module
26
+
27
+ console = Console()
28
+
29
+
30
+ # ── Data Models ───────────────────────────────────────────────────────────────
31
+
32
+ @dataclass
33
+ class ParameterInfo:
34
+ name: str
35
+ annotation: str | None = None
36
+ default: str | None = None
37
+
38
+
39
+ @dataclass
40
+ class FunctionInfo:
41
+ name: str
42
+ parameters: list[ParameterInfo] = field(default_factory=list)
43
+ return_type: str | None = None
44
+ docstring: str | None = None
45
+ calls: list[str] = field(default_factory=list) # functions this calls
46
+ lineno: int = 0
47
+ is_method: bool = False
48
+ source: str | None = None
49
+
50
+
51
+ @dataclass
52
+ class ClassInfo:
53
+ name: str
54
+ bases: list[str] = field(default_factory=list)
55
+ docstring: str | None = None
56
+ methods: list[FunctionInfo] = field(default_factory=list)
57
+ lineno: int = 0
58
+ source: str | None = None
59
+
60
+
61
+ @dataclass
62
+ class FileInfo:
63
+ path: str
64
+ relative: str
65
+ module: str
66
+ classes: list[ClassInfo] = field(default_factory=list)
67
+ functions: list[FunctionInfo] = field(default_factory=list) # top-level
68
+ imports: list[str] = field(default_factory=list)
69
+ docstring: str | None = None
70
+
71
+
72
+ # ── AST Helpers ───────────────────────────────────────────────────────────────
73
+
74
+ def _annotation_to_str(node) -> str | None:
75
+ """Convert an AST annotation node to a readable string."""
76
+ if node is None:
77
+ return None
78
+ try:
79
+ return ast.unparse(node)
80
+ except Exception:
81
+ return None
82
+
83
+
84
+ def _extract_calls(func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
85
+ """Extract all function/method call names from a function body."""
86
+ calls = []
87
+ for node in ast.walk(func_node):
88
+ if isinstance(node, ast.Call):
89
+ if isinstance(node.func, ast.Name):
90
+ calls.append(node.func.id)
91
+ elif isinstance(node.func, ast.Attribute):
92
+ calls.append(f"{ast.unparse(node.func.value)}.{node.func.attr}")
93
+ return list(dict.fromkeys(calls)) # deduplicate, preserve order
94
+
95
+ def _parse_function(node: ast.FunctionDef | ast.AsyncFunctionDef,
96
+ is_method: bool = False,
97
+ raw_source: str | None = None) -> FunctionInfo:
98
+ """Parse a function or method AST node into a FunctionInfo."""
99
+ params = []
100
+
101
+ args = node.args
102
+ all_args = args.posonlyargs + args.args + args.kwonlyargs
103
+ defaults_offset = len(all_args) - len(args.defaults)
104
+
105
+ for i, arg in enumerate(all_args):
106
+ if is_method and arg.arg == "self":
107
+ continue
108
+ default_val = None
109
+ default_idx = i - defaults_offset
110
+ if default_idx >= 0 and default_idx < len(args.defaults):
111
+ try:
112
+ default_val = ast.unparse(args.defaults[default_idx])
113
+ except Exception:
114
+ default_val = "..."
115
+
116
+ params.append(ParameterInfo(
117
+ name=arg.arg,
118
+ annotation=_annotation_to_str(arg.annotation),
119
+ default=default_val,
120
+ ))
121
+
122
+ # *args
123
+ if args.vararg:
124
+ params.append(ParameterInfo(
125
+ name=f"*{args.vararg.arg}",
126
+ annotation=_annotation_to_str(args.vararg.annotation),
127
+ ))
128
+
129
+ # **kwargs
130
+ if args.kwarg:
131
+ params.append(ParameterInfo(
132
+ name=f"**{args.kwarg.arg}",
133
+ annotation=_annotation_to_str(args.kwarg.annotation),
134
+ ))
135
+
136
+ source_segment = None
137
+ if raw_source is not None:
138
+ source_segment = ast.get_source_segment(raw_source, node)
139
+
140
+ return FunctionInfo(
141
+ name=node.name,
142
+ parameters=params,
143
+ return_type=_annotation_to_str(node.returns),
144
+ docstring=ast.get_docstring(node),
145
+ calls=_extract_calls(node),
146
+ lineno=node.lineno,
147
+ is_method=is_method,
148
+ source=source_segment,
149
+ )
150
+
151
+ def _parse_class(node: ast.ClassDef, raw_source: str | None = None) -> ClassInfo:
152
+ """Parse a class AST node into a ClassInfo."""
153
+ bases = []
154
+ for base in node.bases:
155
+ try:
156
+ bases.append(ast.unparse(base))
157
+ except Exception:
158
+ pass
159
+
160
+ methods = []
161
+ for item in node.body:
162
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
163
+ methods.append(_parse_function(item, is_method=True, raw_source=raw_source))
164
+
165
+ class_source = None
166
+ if raw_source is not None:
167
+ class_source = ast.get_source_segment(raw_source, node)
168
+
169
+ return ClassInfo(
170
+ name=node.name,
171
+ bases=bases,
172
+ docstring=ast.get_docstring(node),
173
+ methods=methods,
174
+ lineno=node.lineno,
175
+ source=class_source,
176
+ )
177
+
178
+
179
+ def parse_file(file_meta: dict) -> FileInfo | None:
180
+ """
181
+ Parse a single Python source file and return a FileInfo.
182
+ Returns None if the file cannot be parsed (syntax errors, encoding issues).
183
+
184
+ Args:
185
+ file_meta: A dict from walk_codebase() with keys: path, relative, module.
186
+ """
187
+ if file_meta["extension"] != ".py":
188
+ return None # AST parser only supports Python for now
189
+
190
+ try:
191
+ source = Path(file_meta["path"]).read_text(encoding="utf-8", errors="ignore")
192
+ tree = ast.parse(source)
193
+ except SyntaxError as e:
194
+ console.print(f"[yellow]⚠ Syntax error in {file_meta['relative']}: {e}[/yellow]")
195
+ return None
196
+ except Exception as e:
197
+ console.print(f"[yellow]⚠ Could not parse {file_meta['relative']}: {e}[/yellow]")
198
+ return None
199
+
200
+ info = FileInfo(
201
+ path=file_meta["path"],
202
+ relative=file_meta["relative"],
203
+ module=file_meta["module"],
204
+ docstring=ast.get_docstring(tree),
205
+ )
206
+
207
+ # Extract imports
208
+ for node in ast.walk(tree):
209
+ if isinstance(node, ast.Import):
210
+ for alias in node.names:
211
+ info.imports.append(alias.name)
212
+ elif isinstance(node, ast.ImportFrom):
213
+ mod = node.module or ""
214
+ for alias in node.names:
215
+ info.imports.append(f"{mod}.{alias.name}")
216
+
217
+ # Extract top-level classes and functions
218
+ for node in tree.body:
219
+ if isinstance(node, ast.ClassDef):
220
+ info.classes.append(_parse_class(node, raw_source=source))
221
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
222
+ info.functions.append(_parse_function(node, is_method=False, raw_source=source))
223
+
224
+ return info
225
+
226
+
227
+ def parse_codebase(root_path: str) -> list[FileInfo]:
228
+ """
229
+ Walk and parse an entire codebase, returning FileInfo for every
230
+ parseable Python file.
231
+
232
+ Args:
233
+ root_path: Absolute path to the codebase root.
234
+
235
+ Returns:
236
+ List of FileInfo objects (one per parsed file).
237
+ """
238
+ files = walk_codebase(root_path)
239
+ py_files = [f for f in files if f["extension"] == ".py"]
240
+
241
+ results = []
242
+ with console.status(f"[bold cyan]Parsing {len(py_files)} Python files...[/bold cyan]"):
243
+ for f in py_files:
244
+ info = parse_file(f)
245
+ if info:
246
+ results.append(info)
247
+
248
+ return results
249
+
250
+
251
+ # ── Rich Renderers ────────────────────────────────────────────────────────────
252
+
253
+ def render_file_tree(parsed_files: list[FileInfo]) -> None:
254
+ """Render a rich tree of the parsed codebase structure."""
255
+ root_tree = Tree(
256
+ "[bold bright_cyan]πŸ“¦ Codebase Structure[/bold bright_cyan]",
257
+ guide_style="dim cyan",
258
+ )
259
+
260
+ # Group by module
261
+ modules: dict[str, list[FileInfo]] = {}
262
+ for f in parsed_files:
263
+ modules.setdefault(f.module, []).append(f)
264
+
265
+ for module_name, module_files in sorted(modules.items()):
266
+ module_branch = root_tree.add(
267
+ f"[bold yellow]πŸ“ {module_name}[/bold yellow]"
268
+ )
269
+ for file_info in module_files:
270
+ file_label = f"[green]{Path(file_info.relative).name}[/green]"
271
+ file_branch = module_branch.add(file_label)
272
+
273
+ for cls in file_info.classes:
274
+ cls_branch = file_branch.add(
275
+ f"[bold magenta]πŸ”· {cls.name}[/bold magenta]"
276
+ )
277
+ for method in cls.methods:
278
+ cls_branch.add(f"[cyan] Ζ’ {method.name}()[/cyan]")
279
+
280
+ for func in file_info.functions:
281
+ file_branch.add(f"[blue] Ζ’ {func.name}()[/blue]")
282
+
283
+ console.print(root_tree)
284
+
285
+
286
+ def render_function(func: FunctionInfo, class_name: str | None = None) -> None:
287
+ """Render a detailed view of a single function/method."""
288
+ title = f"{'Method' if func.is_method else 'Function'}: "
289
+ title += f"{class_name}.{func.name}" if class_name else func.name
290
+
291
+ # Signature table
292
+ param_table = Table(
293
+ "Parameter", "Type", "Default",
294
+ box=box.SIMPLE_HEAD,
295
+ style="dim",
296
+ header_style="bold cyan",
297
+ show_edge=False,
298
+ )
299
+ for p in func.parameters:
300
+ param_table.add_row(
301
+ p.name,
302
+ p.annotation or "[dim]any[/dim]",
303
+ p.default or "[dim]β€”[/dim]",
304
+ )
305
+
306
+ return_str = func.return_type or "[dim]None / untyped[/dim]"
307
+ doc_str = func.docstring or "[dim]No docstring.[/dim]"
308
+ calls_str = ", ".join(func.calls[:10]) if func.calls else "[dim]none[/dim]"
309
+
310
+ content = Text()
311
+ content.append("πŸ“„ Docstring\n", style="bold")
312
+ content.append(f"{doc_str}\n\n")
313
+ content.append("↩ Return type\n", style="bold")
314
+ content.append(f"{return_str}\n\n")
315
+ content.append("πŸ“ž Calls\n", style="bold")
316
+ content.append(calls_str)
317
+
318
+ console.print(Panel(content, title=f"[bold white]{title}[/bold white]",
319
+ border_style="cyan"))
320
+ if func.parameters:
321
+ console.print(param_table)
322
+ else:
323
+ console.print(" [dim]No parameters.[/dim]\n")
324
+
325
+
326
+ def render_class(cls: ClassInfo) -> None:
327
+ """Render a detailed view of a class and all its methods."""
328
+ bases_str = ", ".join(cls.bases) if cls.bases else "object"
329
+ doc_str = cls.docstring or "[dim]No docstring.[/dim]"
330
+
331
+ header = Text()
332
+ header.append(f"class {cls.name}", style="bold magenta")
333
+ header.append(f"({bases_str})", style="dim")
334
+
335
+ console.print(Panel(
336
+ f"[bold]Docstring:[/bold]\n{doc_str}",
337
+ title=str(header),
338
+ border_style="magenta",
339
+ ))
340
+
341
+ for method in cls.methods:
342
+ render_function(method, class_name=cls.name)
343
+
344
+
345
+ def render_summary(parsed_files: list[FileInfo]) -> None:
346
+ """Render a high-level summary table of the parsed codebase."""
347
+ table = Table(
348
+ "Module", "File", "Classes", "Functions", "Imports",
349
+ box=box.ROUNDED,
350
+ header_style="bold bright_cyan",
351
+ border_style="cyan",
352
+ show_lines=True,
353
+ )
354
+
355
+ for f in parsed_files:
356
+ table.add_row(
357
+ f.module,
358
+ Path(f.relative).name,
359
+ str(len(f.classes)),
360
+ str(len(f.functions)),
361
+ str(len(f.imports)),
362
+ )
363
+
364
+ total_classes = sum(len(f.classes) for f in parsed_files)
365
+ total_functions = sum(len(f.functions) for f in parsed_files)
366
+ total_methods = sum(len(c.methods) for f in parsed_files for c in f.classes)
367
+
368
+ console.print(table)
369
+ console.print(
370
+ f"\n[bold]Totals:[/bold] "
371
+ f"[cyan]{len(parsed_files)}[/cyan] files Β· "
372
+ f"[magenta]{total_classes}[/magenta] classes Β· "
373
+ f"[blue]{total_functions}[/blue] top-level functions Β· "
374
+ f"[green]{total_methods}[/green] methods\n"
375
+ )
376
+
377
+
378
+ # ── Entry Point ───────────────────────────────────────────────────────────────
379
+
380
+ if __name__ == "__main__":
381
+ path = sys.argv[1] if len(sys.argv) > 1 else "."
382
+
383
+ console.print(f"\n[bold cyan]πŸ” Parsing codebase:[/bold cyan] {path}\n")
384
+
385
+ try:
386
+ parsed = parse_codebase(path)
387
+ except (FileNotFoundError, NotADirectoryError) as e:
388
+ console.print(f"[red]❌ Error: {e}[/red]")
389
+ sys.exit(1)
390
+
391
+ if not parsed:
392
+ console.print("[yellow]No Python files found or parsed.[/yellow]")
393
+ sys.exit(0)
394
+
395
+ console.rule("[bold cyan]Summary[/bold cyan]")
396
+ render_summary(parsed)
397
+
398
+ console.rule("[bold cyan]Codebase Tree[/bold cyan]")
399
+ render_file_tree(parsed)
400
+
401
+ # Demo: render first class found
402
+ for f in parsed:
403
+ if f.classes:
404
+ console.rule(f"[bold cyan]Sample Class β€” {f.classes[0].name}[/bold cyan]")
405
+ render_class(f.classes[0])
406
+ break
407
+
408
+ # Demo: render first top-level function found
409
+ for f in parsed:
410
+ if f.functions:
411
+ console.rule(f"[bold cyan]Sample Function β€” {f.functions[0].name}[/bold cyan]")
412
+ render_function(f.functions[0])
413
+ break
ingest/walk_files.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ file_walker.py
3
+ --------------
4
+ Recursively walks a monolithic codebase directory and collects
5
+ all relevant source code files, filtered by supported extensions.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ # Supported source file extensions
12
+ SUPPORTED_EXTENSIONS = {
13
+ ".py", # Python
14
+ ".java", # Java
15
+ ".js", # JavaScript
16
+ ".ts", # TypeScript
17
+ ".cpp", # C++
18
+ ".c", # C
19
+ ".cs", # C#
20
+ ".go", # Go
21
+ ".rb", # Ruby
22
+ ".php", # PHP
23
+ ".rs", # Rust
24
+ ".kt", # Kotlin
25
+ ".swift", # Swift
26
+ }
27
+
28
+ # Directories to always skip
29
+ EXCLUDED_DIRS = {
30
+ ".devcontainer"
31
+ "chroma_db",
32
+ ".github",
33
+ ".git",
34
+ "__pycache__",
35
+ ".git",
36
+ ".svn",
37
+ "node_modules",
38
+ "venv",
39
+ ".venv",
40
+ "env",
41
+ ".env",
42
+ "dist",
43
+ "build",
44
+ ".idea",
45
+ ".vscode",
46
+ "migrations",
47
+ ".mypy_cache",
48
+ ".pytest_cache",
49
+ }
50
+
51
+
52
+ def walk_codebase(root_path: str) -> list[dict]:
53
+ """
54
+ Recursively walk the codebase from root_path and return
55
+ a list of file metadata dicts for all supported source files.
56
+
57
+ Args:
58
+ root_path: Absolute path to the root of the codebase.
59
+
60
+ Returns:
61
+ List of dicts, each containing:
62
+ - path : absolute file path (str)
63
+ - relative : path relative to root (str)
64
+ - extension : file extension (str)
65
+ - size_bytes : file size in bytes (int)
66
+ - module : top-level module/package name (str)
67
+ """
68
+ root = Path(root_path).resolve()
69
+
70
+ if not root.exists():
71
+ raise FileNotFoundError(f"Path does not exist: {root}")
72
+
73
+ if not root.is_dir():
74
+ raise NotADirectoryError(f"Path is not a directory: {root}")
75
+
76
+ collected = []
77
+
78
+ for dirpath, dirnames, filenames in os.walk(root):
79
+ # Prune excluded directories in-place so os.walk skips them
80
+ dirnames[:] = [
81
+ d for d in dirnames
82
+ if d not in EXCLUDED_DIRS and not d.startswith(".")
83
+ ]
84
+
85
+ for filename in filenames:
86
+ ext = Path(filename).suffix.lower()
87
+
88
+ if ext not in SUPPORTED_EXTENSIONS:
89
+ continue
90
+
91
+ abs_path = Path(dirpath) / filename
92
+ relative = abs_path.relative_to(root)
93
+
94
+ # Derive top-level module name (first directory under root)
95
+ parts = relative.parts
96
+ module = parts[0] if len(parts) > 1 else "<root>"
97
+
98
+ collected.append({
99
+ "path": str(abs_path),
100
+ "relative": str(relative),
101
+ "extension": ext,
102
+ "size_bytes": abs_path.stat().st_size,
103
+ "module": module,
104
+ })
105
+
106
+ # Sort by relative path for deterministic ordering
107
+ collected.sort(key=lambda f: f["relative"])
108
+
109
+ return collected
110
+
111
+
112
+ def group_by_module(files: list[dict]) -> dict[str, list[dict]]:
113
+ """
114
+ Group a flat list of file dicts by their top-level module name.
115
+
116
+ Args:
117
+ files: Output of walk_codebase().
118
+
119
+ Returns:
120
+ Dict mapping module name β†’ list of file dicts.
121
+ """
122
+ grouped: dict[str, list[dict]] = {}
123
+
124
+ for f in files:
125
+ module = f["module"]
126
+ grouped.setdefault(module, []).append(f)
127
+
128
+ return grouped
129
+
130
+
131
+ def summarize(files: list[dict]) -> dict:
132
+ """
133
+ Return a quick summary of the walked codebase.
134
+
135
+ Args:
136
+ files: Output of walk_codebase().
137
+
138
+ Returns:
139
+ Dict with total_files, total_size_kb, modules, extensions breakdown.
140
+ """
141
+ from collections import Counter
142
+
143
+ ext_counts = Counter(f["extension"] for f in files)
144
+ module_counts = Counter(f["module"] for f in files)
145
+ total_size = sum(f["size_bytes"] for f in files)
146
+
147
+ return {
148
+ "total_files": len(files),
149
+ "total_size_kb": round(total_size / 1024, 2),
150
+ "modules": dict(module_counts),
151
+ "extensions": dict(ext_counts),
152
+ }
153
+
154
+
155
+ # ── Quick test when run directly ─────────────────────────────────────────────
156
+ if __name__ == "__main__":
157
+ import sys
158
+ import json
159
+
160
+ path = sys.argv[1] if len(sys.argv) > 1 else "."
161
+
162
+ print(f"\nπŸ“‚ Walking codebase: {path}\n")
163
+
164
+ try:
165
+ files = walk_codebase(path)
166
+ except (FileNotFoundError, NotADirectoryError) as e:
167
+ print(f"❌ Error: {e}")
168
+ sys.exit(1)
169
+
170
+ summary = summarize(files)
171
+
172
+ print(f"βœ… Found {summary['total_files']} source files "
173
+ f"({summary['total_size_kb']} KB total)\n")
174
+
175
+ print("πŸ“¦ By module:")
176
+ for module, count in summary["modules"].items():
177
+ print(f" {module:<30} {count} files")
178
+
179
+ print("\nπŸ”€ By extension:")
180
+ for ext, count in summary["extensions"].items():
181
+ print(f" {ext:<10} {count} files")
182
+
183
+ print("\nπŸ“„ All files:")
184
+ for f in files:
185
+ print(f" {f['relative']}")
main.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ main.py
3
+ -------
4
+ FastAPI backend for the Codebase Oracle system.
5
+ This is the HTTP layer β€” thin wrapper around inference.py.
6
+
7
+ Endpoints:
8
+ POST /index β€” ingest + embed a codebase from given path
9
+ POST /query β€” run a query (macro / micro / cross_module)
10
+ GET /status β€” check if a codebase is indexed and ready
11
+ GET /tree β€” return parsed codebase tree for UI sidebar
12
+ GET /health β€” simple health check
13
+
14
+ Run:
15
+ uvicorn main:app --reload --port 8000
16
+
17
+ Depends on:
18
+ - inference.py
19
+ - embedder.py
20
+ - call_graph.py
21
+ - ast_parser.py
22
+ - vector_store.py
23
+ - fastapi, uvicorn, pydantic, python-dotenv
24
+ """
25
+
26
+ import os
27
+ from contextlib import asynccontextmanager
28
+ from dotenv import load_dotenv
29
+
30
+ import tempfile
31
+ import zipfile
32
+ import shutil
33
+ from fastapi import FastAPI, HTTPException, UploadFile, File
34
+ from fastapi.middleware.cors import CORSMiddleware
35
+ from fastapi.staticfiles import StaticFiles
36
+ from fastapi.responses import FileResponse
37
+ from pydantic import BaseModel, Field
38
+ from rich.console import Console
39
+
40
+ from inference.inference import get_engine, InferenceRequest
41
+ from ingest.embed import embed_codebase
42
+ from store.call_graph import build_and_save, get_call_graph, CALL_GRAPH_PATH
43
+ from ingest.parse_ast import parse_codebase
44
+ from store.vector_store import get_vector_store
45
+
46
+ load_dotenv()
47
+
48
+ console = Console()
49
+
50
+ # ── App Lifespan ──────────────────────────────────────────────────────────────
51
+
52
+ @asynccontextmanager
53
+ async def lifespan(app: FastAPI):
54
+ """Initialize shared resources on startup."""
55
+ console.rule("[bold cyan]Codebase Oracle β€” Starting[/bold cyan]")
56
+ # Pre-warm the inference engine (loads embedding model once)
57
+ get_engine()
58
+ console.print("[green]βœ”[/green] Server ready.\n")
59
+ yield
60
+ console.print("[dim]Server shutting down.[/dim]")
61
+
62
+
63
+ # ── App ───────────────────────────────────────────────────────────────────────
64
+
65
+ app = FastAPI(
66
+ title="Codebase Oracle",
67
+ description="AI-powered monolithic codebase comprehension system.",
68
+ version="1.0.0",
69
+ lifespan=lifespan,
70
+ )
71
+
72
+ # Allow UI (served from same origin or localhost dev)
73
+ app.add_middleware(
74
+ CORSMiddleware,
75
+ allow_origins=["*"],
76
+ allow_methods=["*"],
77
+ allow_headers=["*"],
78
+ )
79
+
80
+ # Serve UI static files
81
+ UI_DIR = os.path.join(os.path.dirname(__file__), "ui")
82
+ if os.path.exists(UI_DIR):
83
+ app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui")
84
+ app.mount("/static", StaticFiles(directory=os.path.join(UI_DIR, "static")), name="static")
85
+
86
+
87
+ # ── Pydantic Request / Response Models ────────────────────────────────────────
88
+
89
+ class IndexRequest(BaseModel):
90
+ """Request body for POST /index"""
91
+ root_path: str = Field(
92
+ ...,
93
+ description="Absolute path to the monolithic codebase root directory.",
94
+ example="/home/user/projects/my-django-app"
95
+ )
96
+
97
+
98
+ class QueryRequest(BaseModel):
99
+ """Request body for POST /query"""
100
+ query_type: str = Field(
101
+ ...,
102
+ description="One of: 'macro', 'micro', 'cross_module'",
103
+ example="micro"
104
+ )
105
+ query: str = Field(
106
+ ...,
107
+ description="Natural language developer query.",
108
+ example="What does process_payment do and how do I use it?"
109
+ )
110
+ subtype: str = Field(
111
+ default="",
112
+ description="Macro subtype: 'overall_architecture' | 'module_responsibility' | 'data_flow'",
113
+ example="overall_architecture"
114
+ )
115
+ function_name: str = Field(
116
+ default="",
117
+ description="Target function/method name for micro and cross_module queries.",
118
+ example="process_payment"
119
+ )
120
+ class_name: str = Field(
121
+ default="",
122
+ description="Target class name if function is a method.",
123
+ example="PaymentProcessor"
124
+ )
125
+ module_name: str = Field(
126
+ default="",
127
+ description="Target module name for macro module_responsibility queries.",
128
+ example="payments"
129
+ )
130
+ followup: bool = Field(
131
+ default=False,
132
+ description="True if this is a follow-up to a previous response."
133
+ )
134
+ previous_response: str = Field(
135
+ default="",
136
+ description="Previous LLM response for follow-up context."
137
+ )
138
+
139
+
140
+ class IndexResponse(BaseModel):
141
+ success: bool
142
+ message: str
143
+ class_chunks: int = 0
144
+ function_chunks: int = 0
145
+ total_chunks: int = 0
146
+ graph_nodes: int = 0
147
+ graph_edges: int = 0
148
+
149
+
150
+ class QueryResponse(BaseModel):
151
+ success: bool
152
+ content: str
153
+ error: str = ""
154
+ metadata: dict = {}
155
+
156
+
157
+ class StatusResponse(BaseModel):
158
+ indexed: bool
159
+ class_chunks: int
160
+ function_chunks: int
161
+ total_chunks: int
162
+ graph_loaded: bool
163
+ graph_nodes: int
164
+
165
+
166
+ class TreeNode(BaseModel):
167
+ name: str
168
+ type: str # "module" | "file" | "class" | "function"
169
+ children: list["TreeNode"] = []
170
+
171
+ TreeNode.model_rebuild()
172
+
173
+
174
+ class TreeResponse(BaseModel):
175
+ success: bool
176
+ tree: list[TreeNode] = []
177
+ error: str = ""
178
+
179
+
180
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
181
+
182
+ @app.post("/upload-index", response_model=IndexResponse)
183
+ async def upload_index(file: UploadFile = File(...)):
184
+ """
185
+ Accept a ZIP file, extract it to a temp directory, and index it.
186
+ Allows deployment without requiring local filesystem access.
187
+ """
188
+ if not file.filename.endswith(".zip"):
189
+ raise HTTPException(status_code=400, detail="Only .zip files are accepted.")
190
+
191
+ tmp_dir = tempfile.mkdtemp()
192
+
193
+ try:
194
+ zip_path = os.path.join(tmp_dir, file.filename)
195
+ with open(zip_path, "wb") as f:
196
+ shutil.copyfileobj(file.file, f)
197
+
198
+ with zipfile.ZipFile(zip_path, "r") as zf:
199
+ zf.extractall(tmp_dir)
200
+
201
+ os.remove(zip_path)
202
+
203
+ # Find the extracted root β€” skip __MACOSX and similar artifacts
204
+ candidates = [
205
+ os.path.join(tmp_dir, d)
206
+ for d in os.listdir(tmp_dir)
207
+ if os.path.isdir(os.path.join(tmp_dir, d)) and not d.startswith("__")
208
+ ]
209
+ root = candidates[0] if candidates else tmp_dir
210
+
211
+ console.rule(f"[bold cyan]Indexing ZIP: {file.filename}[/bold cyan]")
212
+
213
+ embed_codebase(root)
214
+
215
+ graph = build_and_save(root)
216
+ graph_stats = graph.stats()
217
+
218
+ store = get_vector_store()
219
+ vstats = store.stats()
220
+
221
+ console.print("[bold green]βœ” ZIP Indexing complete.[/bold green]\n")
222
+
223
+ return IndexResponse(
224
+ success=True,
225
+ message=f"ZIP indexed successfully: {file.filename}",
226
+ class_chunks=vstats["class_chunks"],
227
+ function_chunks=vstats["function_chunks"],
228
+ total_chunks=vstats["total"],
229
+ graph_nodes=graph_stats["total_nodes"],
230
+ graph_edges=graph_stats["total_edges"],
231
+ )
232
+
233
+ except zipfile.BadZipFile:
234
+ raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file.")
235
+
236
+ except Exception as e:
237
+ console.print(f"[red]❌ ZIP indexing failed: {e}[/red]")
238
+ raise HTTPException(status_code=500, detail=f"ZIP indexing failed: {str(e)}")
239
+
240
+ finally:
241
+ shutil.rmtree(tmp_dir, ignore_errors=True)
242
+
243
+ @app.get("/health")
244
+ async def health():
245
+ """Simple health check."""
246
+ return {"status": "ok", "service": "Codebase Oracle"}
247
+
248
+
249
+ @app.get("/", response_class=FileResponse)
250
+ async def serve_ui():
251
+ """Serve the UI index.html at root."""
252
+ ui_path = os.path.join(UI_DIR, "index.html")
253
+ if not os.path.exists(ui_path):
254
+ raise HTTPException(
255
+ status_code=404,
256
+ detail="UI not found. Place index.html in the ui/ directory."
257
+ )
258
+ return FileResponse(ui_path)
259
+
260
+
261
+ @app.post("/index", response_model=IndexResponse)
262
+ async def index_codebase(req: IndexRequest):
263
+ """
264
+ Ingest, parse, embed, and index a monolithic codebase.
265
+ Builds both ChromaDB vector index and call_graph.json.
266
+
267
+ This is the first endpoint to call before any queries.
268
+ """
269
+ root = req.root_path.strip()
270
+
271
+ if not os.path.exists(root):
272
+ raise HTTPException(
273
+ status_code=400,
274
+ detail=f"Path does not exist: {root}"
275
+ )
276
+
277
+ if not os.path.isdir(root):
278
+ raise HTTPException(
279
+ status_code=400,
280
+ detail=f"Path is not a directory: {root}"
281
+ )
282
+
283
+ try:
284
+ console.rule(f"[bold cyan]Indexing: {root}[/bold cyan]")
285
+
286
+ # Step 1 β€” Embed codebase into ChromaDB
287
+ embed_codebase(root)
288
+
289
+ # Step 2 β€” Build and save call graph
290
+ graph = build_and_save(root)
291
+ graph_stats = graph.stats()
292
+
293
+ # Step 3 β€” Fetch vector store stats
294
+ store = get_vector_store()
295
+ vstats = store.stats()
296
+
297
+ console.print("[bold green]βœ” Indexing complete.[/bold green]\n")
298
+
299
+ return IndexResponse(
300
+ success=True,
301
+ message=f"Codebase indexed successfully: {root}",
302
+ class_chunks=vstats["class_chunks"],
303
+ function_chunks=vstats["function_chunks"],
304
+ total_chunks=vstats["total"],
305
+ graph_nodes=graph_stats["total_nodes"],
306
+ graph_edges=graph_stats["total_edges"],
307
+ )
308
+
309
+ except Exception as e:
310
+ console.print(f"[red]❌ Indexing failed: {e}[/red]")
311
+ raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}")
312
+
313
+
314
+ @app.post("/query", response_model=QueryResponse)
315
+ async def query(req: QueryRequest):
316
+ """
317
+ Run a macro, micro, or cross-module query against the indexed codebase.
318
+ Returns a markdown-formatted response string.
319
+ """
320
+ store = get_vector_store()
321
+ if not store.is_indexed():
322
+ raise HTTPException(
323
+ status_code=400,
324
+ detail="Codebase is not indexed yet. Call POST /index first."
325
+ )
326
+
327
+ engine = get_engine()
328
+
329
+ inference_req = InferenceRequest(
330
+ query_type=req.query_type,
331
+ query=req.query,
332
+ subtype=req.subtype,
333
+ function_name=req.function_name,
334
+ class_name=req.class_name,
335
+ module_name=req.module_name,
336
+ followup=req.followup,
337
+ previous_response=req.previous_response,
338
+ )
339
+
340
+ resp = engine.infer(inference_req)
341
+
342
+ return QueryResponse(
343
+ success=resp.success,
344
+ content=resp.content,
345
+ error=resp.error,
346
+ metadata=resp.metadata,
347
+ )
348
+
349
+
350
+ @app.get("/status", response_model=StatusResponse)
351
+ async def status():
352
+ """
353
+ Check whether the codebase is indexed and the system is ready for queries.
354
+ """
355
+ store = get_vector_store()
356
+ vstats = store.stats()
357
+
358
+ graph_loaded = False
359
+ graph_nodes = 0
360
+
361
+ if os.path.exists(CALL_GRAPH_PATH):
362
+ try:
363
+ graph = get_call_graph()
364
+ graph_loaded = graph.is_loaded()
365
+ graph_nodes = graph.stats()["total_nodes"]
366
+ except Exception:
367
+ pass
368
+
369
+ return StatusResponse(
370
+ indexed=store.is_indexed(),
371
+ class_chunks=vstats["class_chunks"],
372
+ function_chunks=vstats["function_chunks"],
373
+ total_chunks=vstats["total"],
374
+ graph_loaded=graph_loaded,
375
+ graph_nodes=graph_nodes,
376
+ )
377
+
378
+
379
+ @app.get("/tree", response_model=TreeResponse)
380
+ async def get_tree():
381
+ """
382
+ Return the parsed codebase structure as a nested tree.
383
+ Used by the UI sidebar to render the codebase explorer.
384
+ """
385
+ store = get_vector_store()
386
+ if not store.is_indexed():
387
+ return TreeResponse(
388
+ success=False,
389
+ error="Codebase not indexed yet. Call POST /index first."
390
+ )
391
+
392
+ try:
393
+ # Fetch both class and function chunks to reconstruct tree
394
+ class_results = store.get_all("class_chunks", limit=500)
395
+ func_results = store.get_all("function_chunks", limit=500)
396
+
397
+ # Group by module β†’ file β†’ classes/functions
398
+ modules: dict[str, dict[str, dict[str, set]]] = {}
399
+
400
+ # --- classes ---
401
+ for chunk in class_results:
402
+ mod = chunk.module
403
+ file = chunk.file
404
+ modules.setdefault(mod, {}).setdefault(file, {"classes": set(), "functions": set()})
405
+ modules[mod][file]["classes"].add(chunk.name)
406
+
407
+ # --- functions (top-level only) ---
408
+ for chunk in func_results:
409
+ if not chunk.class_name:
410
+ mod = chunk.module
411
+ file = chunk.file
412
+ modules.setdefault(mod, {}).setdefault(file, {"classes": set(), "functions": set()})
413
+ modules[mod][file]["functions"].add(chunk.name)
414
+
415
+ # Also fetch function chunks for top-level functions
416
+ func_results = store.get_all("function_chunks", limit=500)
417
+ func_by_file: dict[str, list[str]] = {}
418
+ for chunk in func_results:
419
+ if not chunk.class_name: # top-level only
420
+ func_by_file.setdefault(chunk.file, []).append(chunk.name)
421
+
422
+ # Build tree structure
423
+
424
+ all_files = set()
425
+ for files in modules.values():
426
+ for file_path in files:
427
+ all_files.add(file_path)
428
+
429
+ # Derive root directory name from common first path component
430
+ first_parts = [f.split("/")[0] for f in all_files if "/" in f]
431
+ root_name = first_parts[0] if first_parts else "codebase"
432
+
433
+ root_node = TreeNode(name=root_name, type="module")
434
+
435
+ for module_name, files in sorted(modules.items()):
436
+ module_node = TreeNode(name=module_name, type="module")
437
+
438
+ for file_path, content in sorted(files.items()):
439
+ file_node = TreeNode(
440
+ name=os.path.basename(file_path),
441
+ type="file"
442
+ )
443
+
444
+ for cls_name in sorted(content["classes"]):
445
+ file_node.children.append(
446
+ TreeNode(name=cls_name, type="class")
447
+ )
448
+
449
+ for fn_name in sorted(content["functions"]):
450
+ file_node.children.append(
451
+ TreeNode(name=fn_name, type="function")
452
+ )
453
+
454
+ module_node.children.append(file_node)
455
+
456
+ root_node.children.append(module_node)
457
+
458
+ return TreeResponse(success=True, tree=[root_node])
459
+
460
+ except Exception as e:
461
+ console.print(f"[red]❌ Tree build failed: {e}[/red]")
462
+ return TreeResponse(success=False, error=str(e))
463
+
464
+
465
+ # ── Entry Point ───────────────────────────────────────────────────────────────
466
+
467
+ if __name__ == "__main__":
468
+ import uvicorn
469
+ uvicorn.run(
470
+ "main:app",
471
+ port=8000,
472
+ reload=True,
473
+ )
prompt/__init__.py ADDED
File without changes
prompt/cross_module.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cross_module_prompts.py
3
+ -----------------------
4
+ System prompts and prompt builders for cross-module codebase queries.
5
+
6
+ Cross-module queries reason across multiple modules simultaneously.
7
+ Two subtypes handled by ONE system prompt with internal classification:
8
+
9
+ 1. dependency_analysis β€” what does function X depend on, what depends on it
10
+ 2. impact_analysis β€” if function X changes, what breaks and where
11
+
12
+ The LLM receives:
13
+ - Primary function context (from vector_store / retriever)
14
+ - Dependency context (from call_graph β€” callers and callees)
15
+ - Call graph metadata (structured dependency relationships)
16
+
17
+ Depends on:
18
+ - rich (for __main__ preview only)
19
+ """
20
+
21
+ # ── Shared Rules ──────────────────────────────────────────────────────────────
22
+
23
+ _SHARED_RULES = """
24
+ STRICT FORMATTING RULES:
25
+ - Respond ONLY in valid markdown.
26
+ - Use headers (##, ###) to separate sections clearly.
27
+ - Use triple backtick code blocks for flow diagrams and signatures.
28
+ - Use markdown tables for dependency and impact listings.
29
+ - Use bullet points for risk assessments and recommendations.
30
+ - Do NOT include conversational filler, preamble, or apologies.
31
+ - Do NOT say "Based on the context provided" or similar phrases.
32
+ - Be precise and technical. Assume the reader is a senior developer.
33
+ - Explicitly state cross-module boundaries using module names.
34
+ - If a dependency cannot be resolved from context, list it as unresolved.
35
+ - Always include a ## Limitations section for missing context.
36
+ """
37
+
38
+ # ── Subtype Classification (embedded in system prompt) ────────────────────────
39
+
40
+ _SUBTYPE_DEFINITIONS = """
41
+ QUERY CLASSIFICATION β€” internally classify every query as ONE of:
42
+
43
+ TYPE D β€” Dependency Analysis
44
+ Triggered when developer asks:
45
+ - "What does X depend on?"
46
+ - "What calls X?"
47
+ - "What does X call?"
48
+ - "What are the dependencies of X?"
49
+ - "Who uses X?"
50
+ - "What modules interact with X?"
51
+ - "Show me the call chain for X"
52
+
53
+ TYPE E β€” Impact Analysis
54
+ Triggered when developer asks:
55
+ - "What breaks if X changes?"
56
+ - "How does changing X affect Y?"
57
+ - "What is the impact of modifying X?"
58
+ - "If I change X, what else do I need to update?"
59
+ - "What are the side effects of changing X across modules?"
60
+ - "How far does X's influence reach?"
61
+
62
+ If the query mentions both dependency and impact, classify as TYPE E
63
+ since impact analysis subsumes dependency analysis.
64
+ If ambiguous, default to TYPE D.
65
+ """
66
+
67
+ # ── Cross-Module System Prompt ────────────────────────────────────────────────
68
+
69
+ CROSS_MODULE_SYSTEM = f"""
70
+ You are an expert software architect performing cross-module dependency
71
+ and impact analysis on a monolithic Python codebase.
72
+
73
+ Your task is to reason across multiple modules simultaneously,
74
+ tracing function dependencies and assessing the blast radius of changes.
75
+
76
+ You are strictly not allowed to do any task outside of this scope no matter the context.
77
+ If a function or code is not available in codebase, just return "The function/class might
78
+ not be indexed for explanation. Please check if it's available in codebase."
79
+
80
+ You will receive:
81
+ 1. PRIMARY CONTEXT β€” the target function/class retrieved from the vector store
82
+ 2. DEPENDENCY CONTEXT β€” functions this target calls and functions that call it
83
+ 3. CALL GRAPH DATA β€” structured JSON of the dependency relationships
84
+
85
+ Use ALL three sources together. Prioritize CALL GRAPH DATA for structure,
86
+ PRIMARY and DEPENDENCY CONTEXT for semantic understanding.
87
+
88
+ {_SHARED_RULES}
89
+
90
+ {_SUBTYPE_DEFINITIONS}
91
+
92
+ ─────────────────────────────────────────────────────────────
93
+ RESPONSE SCHEMAS β€” use the schema matching your classified type:
94
+ ─────────────────────────────────────────────────────────────
95
+
96
+ ═══ TYPE D β€” Dependency Analysis ═══
97
+
98
+ ## Dependency Analysis: `<function_name>`
99
+ **Module:** `<module>` | **File:** `<file>` | **Class:** `<class or top-level>`
100
+
101
+ ## Summary
102
+ 2-3 sentences describing the function's role in the broader system
103
+ and its connectivity to other modules.
104
+
105
+ ## Outgoing Dependencies (What it calls)
106
+ | Function | Class | Module | File | Relationship |
107
+ |----------|-------|--------|------|-------------|
108
+ | ... | ... | ... | ... | direct call |
109
+
110
+ ## Incoming Dependencies (What calls it)
111
+ | Function | Class | Module | File | Relationship |
112
+ |----------|-------|--------|------|-------------|
113
+ | ... | ... | ... | ... | direct call |
114
+
115
+ ## Call Chain
116
+ ```
117
+ [caller_2] β†’ [caller_1] β†’ [<function_name>] β†’ [callee_1] β†’ [callee_2]
118
+ ```
119
+
120
+ ## Cross-Module Boundaries
121
+ Bullet points identifying which module boundaries this function crosses,
122
+ and what data or control passes across those boundaries.
123
+
124
+ ## Unresolved Dependencies
125
+ List any calls that could not be traced to a known function in the index.
126
+
127
+ ## Limitations
128
+ Any gaps in the analysis due to missing context.
129
+
130
+ ═══ TYPE E β€” Impact Analysis ═══
131
+
132
+ ## Impact Analysis: `<function_name>`
133
+ **Module:** `<module>` | **File:** `<file>` | **Class:** `<class or top-level>`
134
+
135
+ ## Proposed Change
136
+ Restate what the developer wants to change, clearly and precisely.
137
+ If no specific change is mentioned, analyze general modification impact.
138
+
139
+ ## Direct Impact
140
+ What changes immediately inside this function and its direct callers.
141
+
142
+ ## Ripple Effects
143
+ | Level | Affected Function | Class | Module | Impact Description | Risk |
144
+ |-------|-------------------|-------|--------|--------------------|------|
145
+ | 1 | ... | ... | ... | ... | Low/Medium/High |
146
+ | 2 | ... | ... | ... | ... | Low/Medium/High |
147
+
148
+ ## Cross-Module Impact Map
149
+ ```
150
+ [<function_name>] (changed)
151
+ ↓
152
+ [Level 1: direct callers] β€” <module_a>, <module_b>
153
+ ↓
154
+ [Level 2: indirect callers] β€” <module_c>
155
+ ```
156
+
157
+ ## Risk Assessment
158
+ - **Overall risk:** Low / Medium / High
159
+ - **Reason:** <why this risk level>
160
+ - **Most vulnerable function:** <which function is most at risk and why>
161
+
162
+ ## Recommendations
163
+ - **Before changing:** <what to check or prepare>
164
+ - **After changing:** <what to test or update>
165
+ - **Safe change strategy:** <how to minimize blast radius>
166
+
167
+ ## Unresolved Dependencies
168
+ List any functions in the impact chain that could not be fully traced.
169
+
170
+ ## Limitations
171
+ Any gaps in the analysis due to missing context.
172
+ """.strip()
173
+
174
+
175
+ # ── User Prompt Builder ───────────────────────────────────────────────────────
176
+
177
+ def build_cross_module_user_prompt(primary_context: str,
178
+ dependency_context: str,
179
+ call_graph_data: dict,
180
+ query: str,
181
+ function_name: str,
182
+ class_name: str = "") -> str:
183
+ """
184
+ Build the user prompt for a cross-module query.
185
+
186
+ Args:
187
+ primary_context: Retrieved context for the target function.
188
+ dependency_context: Retrieved context for its dependencies.
189
+ call_graph_data: Dict from CallGraph containing calls/called_by.
190
+ query: The developer's natural language query.
191
+ function_name: Name of the target function.
192
+ class_name: Optional class name if it's a method.
193
+
194
+ Returns:
195
+ Formatted user prompt string.
196
+ """
197
+ import json
198
+
199
+ scope = (
200
+ f"Method `{class_name}.{function_name}`"
201
+ if class_name
202
+ else f"Function `{function_name}`"
203
+ )
204
+
205
+ # Serialize call graph data cleanly
206
+ call_graph_str = json.dumps(call_graph_data, indent=2)
207
+
208
+ return f"""
209
+ SUBJECT: {scope}
210
+
211
+ ─── PRIMARY CONTEXT ───────────────────────────────────────────
212
+ {primary_context}
213
+
214
+ ─── DEPENDENCY CONTEXT ────────────────────────────────────────
215
+ {dependency_context if dependency_context.strip() else "No dependency context retrieved."}
216
+
217
+ ─── CALL GRAPH DATA ───────────────────────────────────────────
218
+ {call_graph_str}
219
+
220
+ ─── DEVELOPER QUERY ───────────────────────────────────────────
221
+ {query}
222
+
223
+ Classify this query as Type D or Type E internally.
224
+ Then respond using the exact schema for the classified type.
225
+ Do not mention the type classification in your response.
226
+ Trace ALL cross-module boundaries explicitly using module names.
227
+ """.strip()
228
+
229
+
230
+ # ── Follow-up Prompt Builder ──────────────────────────────────────────────────
231
+
232
+ CROSS_MODULE_FOLLOWUP_SYSTEM = """
233
+ You are an expert software architect continuing a cross-module dependency
234
+ or impact analysis discussion about a monolithic Python codebase.
235
+
236
+ The developer has a follow-up question after your previous analysis.
237
+ Use the same context, call graph, and your previous response to answer.
238
+
239
+ STRICT FORMATTING RULES:
240
+ - Respond ONLY in valid markdown.
241
+ - Be concise β€” the developer already has your full analysis.
242
+ - Focus only on what was asked in the follow-up.
243
+ - Do NOT repeat your previous full analysis.
244
+ - Always name modules explicitly when referencing cross-module relationships.
245
+ """.strip()
246
+
247
+
248
+ def build_cross_module_followup_prompt(previous_response: str,
249
+ followup_query: str,
250
+ primary_context: str,
251
+ dependency_context: str,
252
+ call_graph_data: dict) -> str:
253
+ """
254
+ Build a follow-up user prompt for a cross-module conversation.
255
+
256
+ Args:
257
+ previous_response: The LLM's previous cross-module response.
258
+ followup_query: The developer's follow-up question.
259
+ primary_context: Original primary context.
260
+ dependency_context: Original dependency context.
261
+ call_graph_data: Original call graph data.
262
+
263
+ Returns:
264
+ Formatted follow-up user prompt string.
265
+ """
266
+ import json
267
+
268
+ return f"""
269
+ ─── PRIMARY CONTEXT ───────────────────────────────────────────
270
+ {primary_context}
271
+
272
+ ─── DEPENDENCY CONTEXT ────────────────────────────────────────
273
+ {dependency_context if dependency_context.strip() else "No dependency context retrieved."}
274
+
275
+ ─── CALL GRAPH DATA ───────────────────────────────────────────
276
+ {json.dumps(call_graph_data, indent=2)}
277
+
278
+ ─── YOUR PREVIOUS ANALYSIS ────────────────────────────────────
279
+ {previous_response}
280
+
281
+ ─── DEVELOPER FOLLOW-UP ───────────────────────────────────────
282
+ {followup_query}
283
+
284
+ Answer the follow-up using the context and your previous analysis.
285
+ Name all modules explicitly when referencing cross-module relationships.
286
+ """.strip()
287
+
288
+
289
+ # ── Call Graph Data Builder ───────────────────────────────────────────────────
290
+
291
+ def build_call_graph_payload(function_name: str,
292
+ calls: list,
293
+ called_by: list,
294
+ impact: dict) -> dict:
295
+ """
296
+ Build a clean structured dict from CallGraph data to inject
297
+ into the cross-module prompt.
298
+
299
+ Args:
300
+ function_name: Target function name.
301
+ calls: List of CallNode objects (outgoing).
302
+ called_by: List of CallNode objects (incoming).
303
+ impact: Dict of impact levels from CallGraph.get_impact().
304
+
305
+ Returns:
306
+ Clean dict safe for JSON serialization.
307
+ """
308
+ def node_to_dict(node) -> dict:
309
+ return {
310
+ "name": node.name,
311
+ "class_name": node.class_name,
312
+ "module": node.module,
313
+ "file": node.file,
314
+ }
315
+
316
+ impact_serialized = {}
317
+ for level, nodes in impact.items():
318
+ impact_serialized[level] = [node_to_dict(n) for n in nodes]
319
+
320
+ return {
321
+ "target_function": function_name,
322
+ "calls": [node_to_dict(n) for n in calls],
323
+ "called_by": [node_to_dict(n) for n in called_by],
324
+ "impact_chain": impact_serialized,
325
+ }
326
+
327
+
328
+ # ── Prompt Accessor ───────────────────────────────────────────────────────────
329
+
330
+ def get_cross_module_system_prompt(followup: bool = False) -> str:
331
+ """
332
+ Return the cross-module system prompt.
333
+
334
+ Args:
335
+ followup: If True, returns the follow-up system prompt.
336
+
337
+ Returns:
338
+ System prompt string.
339
+ """
340
+ return CROSS_MODULE_FOLLOWUP_SYSTEM if followup else CROSS_MODULE_SYSTEM
341
+
342
+
343
+ # ── Entry Point (preview prompts) ─────────────────────────────────────────────
344
+
345
+ if __name__ == "__main__":
346
+ from rich.console import Console
347
+ from rich.panel import Panel
348
+ from rich.rule import Rule
349
+
350
+ console = Console()
351
+
352
+ sample_primary = """
353
+ --- Chunk 1 (function: extract_function_types) ---
354
+ Scope: top-level function
355
+ Function: extract_function_types
356
+ Module: refurb
357
+ File: refurb/loader.py
358
+ Signature: def extract_function_types(func: Any) -> Generator[type[Node], None, None]
359
+ Docstring: No docstring provided.
360
+ Calls: list, callable, TypeError, signature, isinstance
361
+ """.strip()
362
+
363
+ sample_dependency = """
364
+ --- Chunk 1 (function: load_checks) ---
365
+ Scope: top-level function
366
+ Function: load_checks
367
+ Module: refurb
368
+ File: refurb/main.py
369
+ Signature: def load_checks(path: str) -> list[Check]
370
+ Calls: extract_function_types, import_module
371
+
372
+ --- Chunk 2 (function: run) ---
373
+ Class: Router
374
+ Module: refurb
375
+ File: refurb/main.py
376
+ Signature: def run(args: list[str]) -> int
377
+ Calls: load_checks, parse_args
378
+ """.strip()
379
+
380
+ sample_call_graph = build_call_graph_payload(
381
+ function_name="extract_function_types",
382
+ calls=[],
383
+ called_by=[],
384
+ impact={},
385
+ )
386
+
387
+ queries = [
388
+ ("What does extract_function_types depend on and what depends on it?",
389
+ "Type D β€” Dependency Analysis"),
390
+ ("What breaks if I change the return type of extract_function_types?",
391
+ "Type E β€” Impact Analysis"),
392
+ ]
393
+
394
+ console.print(Rule("[bold cyan]Cross-Module β€” System Prompt[/bold cyan]"))
395
+ console.print(Panel(
396
+ CROSS_MODULE_SYSTEM,
397
+ border_style="cyan",
398
+ padding=(1, 2),
399
+ ))
400
+
401
+ for query, label in queries:
402
+ console.print(Rule(f"[bold magenta]User Prompt β€” {label}[/bold magenta]"))
403
+ user_prompt = build_cross_module_user_prompt(
404
+ primary_context=sample_primary,
405
+ dependency_context=sample_dependency,
406
+ call_graph_data=sample_call_graph,
407
+ query=query,
408
+ function_name="extract_function_types",
409
+ )
410
+ console.print(Panel(
411
+ user_prompt,
412
+ border_style="magenta",
413
+ padding=(1, 2),
414
+ ))
415
+
416
+ console.print(Rule("[bold yellow]Follow-up Prompt Preview[/bold yellow]"))
417
+ followup = build_cross_module_followup_prompt(
418
+ previous_response="## Dependency Analysis: `extract_function_types`\n...",
419
+ followup_query="Which of these callers is most critical to the system?",
420
+ primary_context=sample_primary,
421
+ dependency_context=sample_dependency,
422
+ call_graph_data=sample_call_graph,
423
+ )
424
+ console.print(Panel(followup, border_style="yellow", padding=(1, 2)))
prompt/macro_prompt.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ macro_prompts.py
3
+ ----------------
4
+ System prompts and prompt builders for macro-level codebase queries.
5
+
6
+ Three macro subtypes:
7
+ 1. overall_architecture β€” codebase tree + purpose summary
8
+ 2. module_responsibility β€” module structure + class descriptions
9
+ 3. data_flow β€” how data moves across modules
10
+
11
+ All responses are structured markdown, rendered via rich in terminal.
12
+ """
13
+
14
+ # ── Shared Instructions ───────────────────────────────────────────────────────
15
+
16
+ _SHARED_RULES = """
17
+ STRICT FORMATTING RULES:
18
+ - Respond ONLY in valid markdown.
19
+ - Use headers (##, ###) to separate sections clearly.
20
+ - Use triple backtick code blocks for any tree structures.
21
+ - Use markdown tables where tabular data is appropriate.
22
+ - Use bullet points for lists of descriptions.
23
+ - Do NOT include any conversational filler, preamble, or apologies.
24
+ - Do NOT say "Based on the context provided" or similar phrases.
25
+ - Be precise and technical. Assume the reader is a developer.
26
+ - If information is missing or unclear from context, say so explicitly under a ## Limitations section.
27
+ """
28
+
29
+ # ── 1. Overall Architecture ───────────────────────────────────────────────────
30
+
31
+ OVERALL_ARCHITECTURE_SYSTEM = f"""
32
+ You are an expert software architect analyzing a monolithic Python codebase.
33
+ Your task is to provide a clear, structured overview of the entire codebase
34
+ architecture based on the context provided.
35
+
36
+ You are strictly not allowed to do
37
+ any task outside of this scope no matter the context. If a function or code is not
38
+ available in codebase, just return "The function/class might not be indexed for explanation.
39
+ Please check if it's available in codebase."
40
+
41
+ {_SHARED_RULES}
42
+
43
+ RESPONSE STRUCTURE β€” follow this EXACTLY:
44
+
45
+ ## Codebase: <name>
46
+
47
+ ## Purpose
48
+ A concise paragraph (3-5 sentences) explaining what this codebase does,
49
+ what problem it solves, and what its primary output or function is.
50
+
51
+ ## Architecture Overview
52
+ ```
53
+ <Render a text-based tree of the top-level modules and their submodules>
54
+ ```
55
+
56
+ ## Module Summary
57
+ | Module | Responsibility |
58
+ |--------|---------------|
59
+ | ... | ... |
60
+
61
+ ## Key Design Patterns
62
+ Bullet points identifying any notable patterns (MVC, plugin system,
63
+ visitor pattern, pipeline, etc.) visible from the structure.
64
+
65
+ ## Limitations
66
+ Any gaps in the analysis due to missing context.
67
+ """.strip()
68
+
69
+
70
+ def build_overall_architecture_prompt(context: str, query: str) -> str:
71
+ """
72
+ Build the user prompt for an overall architecture query.
73
+
74
+ Args:
75
+ context: Assembled chunk context from retriever.build_context().
76
+ query: The developer's original natural language query.
77
+
78
+ Returns:
79
+ Formatted user prompt string.
80
+ """
81
+ return f"""
82
+ CODEBASE CONTEXT:
83
+ {context}
84
+
85
+ DEVELOPER QUERY:
86
+ {query}
87
+
88
+ Analyze the codebase context above and produce the architecture overview
89
+ following your response structure exactly.
90
+ """.strip()
91
+
92
+
93
+ # ── 2. Module Responsibility ──────────────────────────────────────────────────
94
+
95
+ MODULE_RESPONSIBILITY_SYSTEM = f"""
96
+ You are an expert software architect analyzing a specific module
97
+ within a monolithic Python codebase.
98
+ Your task is to explain what a given module does, its internal structure,
99
+ and the responsibility of each class within it.
100
+
101
+ {_SHARED_RULES}
102
+
103
+ RESPONSE STRUCTURE β€” follow this EXACTLY:
104
+
105
+ ## Module: <module_name>
106
+
107
+ ## Purpose
108
+ A concise paragraph (2-4 sentences) explaining what this module is
109
+ responsible for in the broader codebase.
110
+
111
+ ## Internal Structure
112
+ ```
113
+ <module_name>/
114
+ β”œβ”€β”€ <file.py>
115
+ β”‚ β”œβ”€β”€ ClassName
116
+ β”‚ β”‚ β”œβ”€β”€ method_one()
117
+ β”‚ β”‚ └── method_two()
118
+ β”‚ └── another_function()
119
+ └── ...
120
+ ```
121
+
122
+ ## Class Responsibilities
123
+ | Class | Responsibility |
124
+ |-------|---------------|
125
+ | ... | ... |
126
+
127
+ ## Dependencies
128
+ Bullet points listing what external modules or packages this module
129
+ imports or depends on, based on the context.
130
+
131
+ ## Limitations
132
+ Any gaps in the analysis due to missing context.
133
+ """.strip()
134
+
135
+
136
+ def build_module_responsibility_prompt(context: str,
137
+ query: str,
138
+ module_name: str) -> str:
139
+ """
140
+ Build the user prompt for a module responsibility query.
141
+
142
+ Args:
143
+ context: Assembled chunk context from retriever.build_context().
144
+ query: The developer's original natural language query.
145
+ module_name: Name of the module being queried.
146
+
147
+ Returns:
148
+ Formatted user prompt string.
149
+ """
150
+ return f"""
151
+ MODULE: {module_name}
152
+
153
+ CODEBASE CONTEXT:
154
+ {context}
155
+
156
+ DEVELOPER QUERY:
157
+ {query}
158
+
159
+ Analyze the module context above and explain the module's responsibility
160
+ and internal structure following your response structure exactly.
161
+ """.strip()
162
+
163
+
164
+ # ── 3. Data Flow ──────────────────────────────────────────────────────────────
165
+
166
+ DATA_FLOW_SYSTEM = f"""
167
+ You are an expert software architect analyzing data flow across modules
168
+ in a monolithic Python codebase.
169
+ Your task is to trace and explain how data enters, transforms, and moves
170
+ through the system based on the context provided.
171
+
172
+ {_SHARED_RULES}
173
+
174
+ RESPONSE STRUCTURE β€” follow this EXACTLY:
175
+
176
+ ## Data Flow: <topic or entry point>
177
+
178
+ ## Overview
179
+ A concise paragraph (3-5 sentences) summarizing the high-level data
180
+ flow from input to output.
181
+
182
+ ## Flow Diagram
183
+ ```
184
+ [Input / Entry Point]
185
+ ↓
186
+ [Module A] β†’ <what happens here>
187
+ ↓
188
+ [Module B] β†’ <what transforms here>
189
+ ↓
190
+ [Output / Result]
191
+ ```
192
+
193
+ ## Step-by-Step Breakdown
194
+ ### Step 1: <Entry Point>
195
+ Description of what initiates the flow and what data looks like at this stage.
196
+
197
+ ### Step 2: <Module / Function>
198
+ Description of transformation or processing at this stage.
199
+
200
+ ### Step N: <Final Output>
201
+ Description of what the data looks like at the end.
202
+
203
+ ## Data Structures Involved
204
+ | Structure | Module | Role in Flow |
205
+ |-----------|--------|-------------|
206
+ | ... | ... | ... |
207
+
208
+ ## Limitations
209
+ Any gaps in the analysis due to missing context or ambiguous flow.
210
+ """.strip()
211
+
212
+
213
+ def build_data_flow_prompt(context: str, query: str) -> str:
214
+ """
215
+ Build the user prompt for a data flow query.
216
+
217
+ Args:
218
+ context: Assembled chunk context from retriever.build_context().
219
+ query: The developer's original natural language query.
220
+
221
+ Returns:
222
+ Formatted user prompt string.
223
+ """
224
+ return f"""
225
+ CODEBASE CONTEXT:
226
+ {context}
227
+
228
+ DEVELOPER QUERY:
229
+ {query}
230
+
231
+ Trace and explain the data flow described in the query using the
232
+ codebase context above. Follow your response structure exactly.
233
+ """.strip()
234
+
235
+
236
+ # ── Prompt Router ─────────────────────────────────────────────────────────────
237
+
238
+ MACRO_SUBTYPES = {
239
+ "overall_architecture": "Overall Architecture",
240
+ "module_responsibility": "Module Responsibility",
241
+ "data_flow": "Data Flow",
242
+ }
243
+
244
+
245
+ def get_macro_system_prompt(subtype: str) -> str:
246
+ """
247
+ Return the correct system prompt for a given macro subtype.
248
+
249
+ Args:
250
+ subtype: One of 'overall_architecture', 'module_responsibility',
251
+ 'data_flow'.
252
+
253
+ Returns:
254
+ System prompt string.
255
+
256
+ Raises:
257
+ ValueError: If subtype is not recognized.
258
+ """
259
+ mapping = {
260
+ "overall_architecture": OVERALL_ARCHITECTURE_SYSTEM,
261
+ "module_responsibility": MODULE_RESPONSIBILITY_SYSTEM,
262
+ "data_flow": DATA_FLOW_SYSTEM,
263
+ }
264
+
265
+ if subtype not in mapping:
266
+ raise ValueError(
267
+ f"Unknown macro subtype: '{subtype}'. "
268
+ f"Choose from: {list(mapping.keys())}"
269
+ )
270
+
271
+ return mapping[subtype]
272
+
273
+
274
+ def build_macro_user_prompt(subtype: str,
275
+ context: str,
276
+ query: str,
277
+ module_name: str = "") -> str:
278
+ """
279
+ Build the correct user prompt for a given macro subtype.
280
+
281
+ Args:
282
+ subtype: One of the three macro subtypes.
283
+ context: Retrieved context string.
284
+ query: Developer's natural language query.
285
+ module_name: Required only for 'module_responsibility' subtype.
286
+
287
+ Returns:
288
+ Formatted user prompt string.
289
+ """
290
+ if subtype == "overall_architecture":
291
+ return build_overall_architecture_prompt(context, query)
292
+
293
+ elif subtype == "module_responsibility":
294
+ if not module_name:
295
+ raise ValueError(
296
+ "'module_name' is required for 'module_responsibility' subtype."
297
+ )
298
+ return build_module_responsibility_prompt(context, query, module_name)
299
+
300
+ elif subtype == "data_flow":
301
+ return build_data_flow_prompt(context, query)
302
+
303
+ raise ValueError(
304
+ f"Unknown macro subtype: '{subtype}'. "
305
+ f"Choose from: {list(MACRO_SUBTYPES.keys())}"
306
+ )
307
+
308
+
309
+ # ── Entry Point (preview prompts) ─────────────────────────────────────────────
310
+
311
+ if __name__ == "__main__":
312
+ from rich.console import Console
313
+ from rich.panel import Panel
314
+ from rich.rule import Rule
315
+
316
+ console = Console()
317
+
318
+ sample_context = """
319
+ --- Chunk 1 (class: Router) ---
320
+ Class: Router
321
+ Module: refurb
322
+ File: refurb/main.py
323
+ Docstring: Main entry point router for the refurb linting tool.
324
+ Methods:
325
+ def run(args: list[str]) -> int
326
+ def load_checks(path: str) -> list[Check]
327
+
328
+ --- Chunk 2 (class: Loader) ---
329
+ Class: Loader
330
+ Module: refurb
331
+ File: refurb/loader.py
332
+ Docstring: Loads and validates check functions from plugin modules.
333
+ Methods:
334
+ def extract_function_types(func: Any) -> Generator
335
+ def load_module(path: str) -> ModuleType
336
+ """.strip()
337
+
338
+ for subtype, label in MACRO_SUBTYPES.items():
339
+ console.print(Rule(f"[bold cyan]{label} β€” System Prompt[/bold cyan]"))
340
+ console.print(Panel(
341
+ get_macro_system_prompt(subtype),
342
+ border_style="cyan",
343
+ padding=(1, 2),
344
+ ))
345
+
346
+ console.print(Rule(f"[bold magenta]{label} β€” User Prompt Preview[/bold magenta]"))
347
+ user_prompt = build_macro_user_prompt(
348
+ subtype=subtype,
349
+ context=sample_context,
350
+ query=f"Explain the {label.lower()} of this codebase",
351
+ module_name="refurb" if subtype == "module_responsibility" else "",
352
+ )
353
+ console.print(Panel(
354
+ user_prompt,
355
+ border_style="magenta",
356
+ padding=(1, 2),
357
+ ))
prompt/micro_prompt.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ micro_prompts.py
3
+ ----------------
4
+ System prompts and prompt builders for micro-level codebase queries.
5
+
6
+ Micro queries operate at function/method level. The LLM classifies
7
+ the query internally into one of three subtypes:
8
+
9
+ 1. function_definition β€” signature, purpose, parameters, usage example
10
+ 2. function_body β€” internal logic, data flow, warnings
11
+ 3. function_consideration β€” impact of proposed changes
12
+
13
+ Unlike macro prompts, there is ONE system prompt for micro queries.
14
+ The LLM classifies the subtype from the developer's natural language
15
+ query and responds in the appropriate schema automatically.
16
+
17
+ Depends on:
18
+ - rich (for __main__ preview only)
19
+ """
20
+
21
+ # ── Shared Rules ──────────────────────────────────────────────────────────────
22
+
23
+ _SHARED_RULES = """
24
+ STRICT FORMATTING RULES:
25
+ - Respond ONLY in valid markdown.
26
+ - Use headers (##, ###) to separate sections clearly.
27
+ - Use triple backtick code blocks for ALL code snippets and signatures.
28
+ - Use markdown tables for parameter listings.
29
+ - Use bullet points for warnings, considerations, and impact lists.
30
+ - Do NOT include conversational filler, preamble, or apologies.
31
+ - Do NOT say "Based on the context provided" or similar phrases.
32
+ - Be precise and technical. Assume the reader is a developer.
33
+ - If information is unclear from context, say so under a ## Limitations section.
34
+ """
35
+
36
+ # ── Subtype Descriptions (used inside system prompt) ─────────────────────────
37
+
38
+ _SUBTYPE_DEFINITIONS = """
39
+ QUERY CLASSIFICATION β€” internally classify every incoming query as ONE of:
40
+
41
+ TYPE A β€” Function Definition
42
+ Triggered when developer asks:
43
+ - "What does X do?"
44
+ - "Explain X function"
45
+ - "What are the parameters of X?"
46
+ - "How do I use X?"
47
+ - "What does X return?"
48
+
49
+ TYPE B β€” Function Body
50
+ Triggered when developer asks:
51
+ - "How does X work internally?"
52
+ - "Walk me through X"
53
+ - "What is the logic inside X?"
54
+ - "Explain the flow inside X"
55
+ - "What happens step by step in X?"
56
+
57
+ TYPE C β€” Function Consideration
58
+ Triggered when developer asks:
59
+ - "What if I change X to Y?"
60
+ - "What happens if I modify this parameter?"
61
+ - "Is it safe to change X?"
62
+ - "What breaks if I alter X?"
63
+ - "What are the side effects of changing X?"
64
+
65
+ If the query is ambiguous, default to TYPE A.
66
+ """
67
+
68
+ # ── Micro System Prompt ───────────────────────────────────────────────────────
69
+
70
+ MICRO_SYSTEM = f"""
71
+ You are an expert Python software engineer analyzing individual functions
72
+ and methods within a monolithic codebase.
73
+
74
+ Your task is to answer developer questions about specific functions or methods
75
+ at the deepest level of detail possible, based on the context provided.
76
+
77
+ You are strictly not allowed to do any task outside of this scope no matter the context.
78
+ If a function or code is not available in codebase, just return "The function/class might
79
+ not be indexed for explanation. Please check if it's available in codebase."
80
+
81
+ {_SHARED_RULES}
82
+
83
+ {_SUBTYPE_DEFINITIONS}
84
+
85
+ ─────────────────────────────────────────────────────────────
86
+ RESPONSE SCHEMAS β€” use the schema matching your classified type:
87
+ ─────────────────────────────────────────────────────────────
88
+
89
+ ═══ TYPE A β€” Function Definition ═══
90
+
91
+ ## Function: `<function_name>`
92
+ **Class:** `<ClassName>` | **Module:** `<module>` | **File:** `<file>`
93
+
94
+ ## Signature
95
+ ```python
96
+ def <function_name>(<params>) -> <return_type>:
97
+ ```
98
+
99
+ ## Purpose
100
+ Concise paragraph (2-4 sentences) explaining what this function does
101
+ and why it exists.
102
+
103
+ ## Parameters
104
+ | Parameter | Type | Default | Description |
105
+ |-----------|------|---------|-------------|
106
+ | ... | ... | ... | ... |
107
+
108
+ ## Return Value
109
+ What the function returns, its type, and what it represents.
110
+
111
+ ## Usage Example
112
+ ```python
113
+ <concrete, realistic code snippet showing how to call this function>
114
+ ```
115
+
116
+ ## Limitations
117
+ Any gaps or unclear aspects from the context.
118
+
119
+ ═══ TYPE B β€” Function Body ═══
120
+
121
+ ## Function: `<function_name>` β€” Internal Analysis
122
+ **Class:** `<ClassName>` | **Module:** `<module>` | **File:** `<file>`
123
+
124
+ ## Internal Flow
125
+ Step-by-step breakdown of what happens inside the function:
126
+
127
+ ### Step 1: <label>
128
+ Description of this step.
129
+
130
+ ### Step N: <label>
131
+ Description of this step.
132
+
133
+ ## Data Flow
134
+ ```
135
+ input β†’ <transformation> β†’ <intermediate> β†’ <transformation> β†’ output
136
+ ```
137
+
138
+ ## Warnings & Edge Cases
139
+ - <potential issue or missing validation>
140
+ - <edge case not handled>
141
+ - <what happens with unexpected input>
142
+
143
+ ## Limitations
144
+ Any gaps or unclear aspects from the context.
145
+
146
+ ═══ TYPE C β€” Function Consideration ═══
147
+
148
+ ## Impact Analysis: `<function_name>`
149
+
150
+ ## Proposed Change
151
+ Restate the developer's proposed change clearly and precisely.
152
+
153
+ ## Direct Effects
154
+ What changes immediately inside this function if the modification is made.
155
+
156
+ ## Ripple Effects
157
+ | Affected Function | Module | Impact | Risk |
158
+ |-------------------|--------|--------|------|
159
+ | ... | ... | ... | Low/Medium/High |
160
+
161
+ ## Recommendation
162
+ - **Safe to change:** <yes/no/conditional β€” explain>
163
+ - **Suggested approach:** <how to make the change safely>
164
+ - **Tests to update:** <what would need re-testing>
165
+
166
+ ## Limitations
167
+ Any gaps or unclear aspects from the context.
168
+ """.strip()
169
+
170
+
171
+ # ── User Prompt Builder ───────────────────────────────────────────────────────
172
+
173
+ def build_micro_user_prompt(context: str,
174
+ query: str,
175
+ function_name: str,
176
+ class_name: str = "") -> str:
177
+ """
178
+ Build the user prompt for a micro-level query.
179
+
180
+ The system prompt handles subtype classification internally,
181
+ so the user prompt just needs to supply context + query clearly.
182
+
183
+ Args:
184
+ context: Assembled chunk context from retriever.build_context().
185
+ query: The developer's natural language query.
186
+ function_name: Name of the function/method being queried.
187
+ class_name: Optional class name if it's a method.
188
+
189
+ Returns:
190
+ Formatted user prompt string.
191
+ """
192
+ scope = (
193
+ f"Method `{class_name}.{function_name}`"
194
+ if class_name
195
+ else f"Function `{function_name}`"
196
+ )
197
+
198
+ return f"""
199
+ FUNCTION CONTEXT:
200
+ {context}
201
+
202
+ SUBJECT: {scope}
203
+
204
+ DEVELOPER QUERY:
205
+ {query}
206
+
207
+ Classify this query into Type A, B, or C internally.
208
+ Then respond using the exact schema for the classified type.
209
+ Do not mention the type classification in your response.
210
+ """.strip()
211
+
212
+
213
+ # ── Follow-up Prompt Builder ──────────────────────────────────────────────────
214
+
215
+ MICRO_FOLLOWUP_SYSTEM = """
216
+ You are an expert Python software engineer continuing a technical discussion
217
+ about a specific function or method in a monolithic codebase.
218
+
219
+ The developer has a follow-up question after your previous analysis.
220
+ Use the same context and your previous response to answer precisely.
221
+
222
+ STRICT FORMATTING RULES:
223
+ - Respond ONLY in valid markdown.
224
+ - Be concise β€” the developer already has your full analysis.
225
+ - Focus only on what was asked in the follow-up.
226
+ - Do NOT repeat your previous full analysis.
227
+ """.strip()
228
+
229
+
230
+ def build_micro_followup_prompt(previous_response: str,
231
+ followup_query: str,
232
+ context: str) -> str:
233
+ """
234
+ Build a follow-up user prompt continuing a micro-level conversation.
235
+
236
+ Args:
237
+ previous_response: The LLM's previous micro response.
238
+ followup_query: The developer's follow-up question.
239
+ context: Original retrieved context (still relevant).
240
+
241
+ Returns:
242
+ Formatted follow-up user prompt string.
243
+ """
244
+ return f"""
245
+ ORIGINAL CONTEXT:
246
+ {context}
247
+
248
+ YOUR PREVIOUS ANALYSIS:
249
+ {previous_response}
250
+
251
+ DEVELOPER FOLLOW-UP:
252
+ {followup_query}
253
+
254
+ Answer the follow-up precisely using the context and your previous analysis.
255
+ """.strip()
256
+
257
+
258
+ # ── Prompt Accessor ───────────────────────────────────────────────────────────
259
+
260
+ def get_micro_system_prompt(followup: bool = False) -> str:
261
+ """
262
+ Return the micro system prompt.
263
+
264
+ Args:
265
+ followup: If True, returns the follow-up system prompt.
266
+
267
+ Returns:
268
+ System prompt string.
269
+ """
270
+ return MICRO_FOLLOWUP_SYSTEM if followup else MICRO_SYSTEM
271
+
272
+
273
+ # ── Entry Point (preview prompts) ─────────────────────────────────────────────
274
+
275
+ if __name__ == "__main__":
276
+ from rich.console import Console
277
+ from rich.panel import Panel
278
+ from rich.rule import Rule
279
+
280
+ console = Console()
281
+
282
+ sample_context = """
283
+ --- Chunk 1 (function: extract_function_types) ---
284
+ Scope: top-level function
285
+ Function: extract_function_types
286
+ Module: refurb
287
+ File: refurb/loader.py
288
+ Signature: def extract_function_types(func: Any) -> Generator[type[Node], None, None]
289
+ Docstring: No docstring provided.
290
+ Calls: list, callable, TypeError, signature(func).parameters.values,
291
+ len, type_error_with_line_info, isinstance, signature
292
+ """.strip()
293
+
294
+ queries = [
295
+ ("What does extract_function_types do and how do I use it?",
296
+ "Type A β€” Definition"),
297
+ ("Walk me through the internal logic of extract_function_types",
298
+ "Type B β€” Body"),
299
+ ("What happens if I change the return type from Generator to list?",
300
+ "Type C β€” Consideration"),
301
+ ]
302
+
303
+ console.print(Rule("[bold cyan]Micro β€” System Prompt[/bold cyan]"))
304
+ console.print(Panel(
305
+ MICRO_SYSTEM,
306
+ border_style="cyan",
307
+ padding=(1, 2),
308
+ ))
309
+
310
+ for query, label in queries:
311
+ console.print(Rule(f"[bold magenta]User Prompt Preview β€” {label}[/bold magenta]"))
312
+ user_prompt = build_micro_user_prompt(
313
+ context=sample_context,
314
+ query=query,
315
+ function_name="extract_function_types",
316
+ class_name="",
317
+ )
318
+ console.print(Panel(
319
+ user_prompt,
320
+ border_style="magenta",
321
+ padding=(1, 2),
322
+ ))
323
+
324
+ console.print(Rule("[bold yellow]Follow-up Prompt Preview[/bold yellow]"))
325
+ followup = build_micro_followup_prompt(
326
+ previous_response="## Function: `extract_function_types`\n...",
327
+ followup_query="Can this function handle async functions as input?",
328
+ context=sample_context,
329
+ )
330
+ console.print(Panel(followup, border_style="yellow", padding=(1, 2)))
render/__init__.py ADDED
File without changes
render/render.py ADDED
File without changes
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ networkx
4
+ python-dotenv
5
+ openai
6
+ rich
7
+ sentence-transformers
8
+ pinecone
9
+ python-multipart
retrieve/__init__.py ADDED
File without changes
retrieve/retrieve.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ retriever.py
3
+ ------------
4
+ Retrieves relevant chunks from ChromaDB based on query type.
5
+
6
+ Query type determines which collection to search:
7
+ - Macro / Cross-Module β†’ class_chunks
8
+ - Micro β†’ function_chunks
9
+
10
+ Provides filtered retrieval by module, class, or function name
11
+ for precise context fetching.
12
+
13
+ Depends on:
14
+ - embedder.py (query_chunks, get_chroma_client, load_embedding_model)
15
+ - rich (terminal output)
16
+ """
17
+
18
+ from dataclasses import dataclass
19
+ from rich.console import Console
20
+ from rich.table import Table
21
+ from rich.panel import Panel
22
+ from rich.text import Text
23
+ from rich import box
24
+
25
+ from ingest.embed import (
26
+ query_chunks,
27
+ load_embedding_model,
28
+ CLASS_COLLECTION,
29
+ FUNCTION_COLLECTION,
30
+ COMPLETE_COLLECTION
31
+ )
32
+
33
+ console = Console()
34
+
35
+
36
+ # ── Query Types ───────────────────────────────────────────────────────────────
37
+
38
+ class QueryType:
39
+ MACRO = "macro"
40
+ MICRO = "micro"
41
+ CROSS_MODULE = "cross_module"
42
+
43
+
44
+ # ── Result Model ──────────────────────────────────────────────────────────────
45
+
46
+ @dataclass
47
+ class RetrievedChunk:
48
+ text: str
49
+ metadata: dict
50
+ distance: float
51
+ collection: str
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ return self.metadata.get("name", "unknown")
56
+
57
+ @property
58
+ def module(self) -> str:
59
+ return self.metadata.get("module", "unknown")
60
+
61
+ @property
62
+ def file(self) -> str:
63
+ return self.metadata.get("file", "unknown")
64
+
65
+ @property
66
+ def class_name(self) -> str:
67
+ return self.metadata.get("class_name", "")
68
+
69
+ @property
70
+ def chunk_type(self) -> str:
71
+ return self.metadata.get("type", "unknown")
72
+
73
+ @property
74
+ def relevance_score(self) -> float:
75
+ """Convert distance to 0-1 relevance score (lower distance = higher relevance)."""
76
+ return round(1 / (1 + self.distance), 4)
77
+
78
+
79
+ # ── Core Retriever ────────────────────────────────────────────────────────────
80
+
81
+ class Retriever:
82
+ """
83
+ Unified retriever for the Codebase Oracle system.
84
+ Maintains a single embedding model and ChromaDB client across queries.
85
+ """
86
+
87
+ def __init__(self):
88
+ self.model = load_embedding_model()
89
+ console.print("[green]βœ”[/green] Retriever ready.\n")
90
+
91
+ # ── Public API ────────────────────────────────────────────────────────────
92
+
93
+ def retrieve(self,
94
+ query: str,
95
+ query_type: str,
96
+ n_results: int = 5,
97
+ filters: dict | None = None) -> list[RetrievedChunk]:
98
+ """
99
+ Main retrieval entry point. Routes to the correct collection
100
+ based on query_type.
101
+
102
+ Args:
103
+ query: Natural language query string.
104
+ query_type: One of QueryType.MACRO / MICRO / CROSS_MODULE.
105
+ n_results: Number of chunks to retrieve.
106
+ filters: Optional metadata filters (e.g. filter by module).
107
+
108
+ Returns:
109
+ List of RetrievedChunk objects sorted by relevance.
110
+ """
111
+ collection = self._route_collection(query_type)
112
+
113
+ raw = query_chunks(
114
+ query=query,
115
+ collection_name=collection,
116
+ model=self.model,
117
+ n_results=n_results,
118
+ filters=filters,
119
+ )
120
+
121
+ chunks = [
122
+ RetrievedChunk(
123
+ text=r["text"],
124
+ metadata=r["metadata"],
125
+ distance=r["distance"],
126
+ collection=collection,
127
+ )
128
+ for r in raw
129
+ ]
130
+
131
+ return chunks
132
+
133
+ def retrieve_by_class(self,
134
+ class_name: str,
135
+ n_results: int = 1) -> list[RetrievedChunk]:
136
+ """
137
+ Retrieve class-level chunk by exact class name.
138
+ Used by micro agent to ground function queries with class context.
139
+
140
+ Args:
141
+ class_name: Exact class name string.
142
+ n_results: Usually 1 β€” we want the specific class.
143
+
144
+ Returns:
145
+ List of RetrievedChunk from class_chunks collection.
146
+ """
147
+ return self.retrieve(
148
+ query=f"class {class_name}",
149
+ query_type=QueryType.MACRO,
150
+ n_results=n_results,
151
+ filters={"name": {"$eq": class_name}},
152
+ )
153
+
154
+ def retrieve_by_function(self,
155
+ function_name: str,
156
+ class_name: str | None = None,
157
+ n_results: int = 3) -> list[RetrievedChunk]:
158
+ """
159
+ Retrieve function-level chunks by function name.
160
+ Optionally filter by class name for method disambiguation.
161
+
162
+ Args:
163
+ function_name: Exact function/method name.
164
+ class_name: Optional class name to narrow results.
165
+ n_results: Number of results.
166
+
167
+ Returns:
168
+ List of RetrievedChunk from function_chunks collection.
169
+ """
170
+ filters = {"name": {"$eq": function_name}}
171
+ if class_name:
172
+ filters = {
173
+ "$and": [
174
+ {"name": {"$eq": function_name}},
175
+ {"class_name": {"$eq": class_name}},
176
+ ]
177
+ }
178
+
179
+ return self.retrieve(
180
+ query=f"function {function_name}",
181
+ query_type=QueryType.MICRO,
182
+ n_results=n_results,
183
+ filters=filters,
184
+ )
185
+
186
+ def retrieve_by_module(self,
187
+ module_name: str,
188
+ query: str,
189
+ query_type: str = QueryType.MACRO,
190
+ n_results: int = 5) -> list[RetrievedChunk]:
191
+ """
192
+ Retrieve chunks scoped to a specific module.
193
+
194
+ Args:
195
+ module_name: Top-level module/package name.
196
+ query: Natural language query within that module.
197
+ query_type: MACRO or MICRO.
198
+ n_results: Number of results.
199
+
200
+ Returns:
201
+ List of RetrievedChunk filtered to the given module.
202
+ """
203
+ return self.retrieve(
204
+ query=query,
205
+ query_type=query_type,
206
+ n_results=n_results,
207
+ filters={"module": {"$eq": module_name}},
208
+ )
209
+
210
+ def retrieve_dependencies(self, function_name: str) -> list[RetrievedChunk]:
211
+ """
212
+ Retrieve all functions that the given function calls.
213
+ Used by cross-module agent for dependency/impact analysis.
214
+
215
+ Args:
216
+ function_name: Name of the function to trace dependencies for.
217
+
218
+ Returns:
219
+ List of RetrievedChunk for each called function found in index.
220
+ """
221
+ # First get the function itself to extract its call list
222
+ source_chunks = self.retrieve_by_function(function_name, n_results=1)
223
+
224
+ if not source_chunks:
225
+ console.print(f"[yellow]⚠ Function '{function_name}' not found in index.[/yellow]")
226
+ return []
227
+
228
+ import json
229
+ calls = json.loads(source_chunks[0].metadata.get("calls", "[]"))
230
+
231
+ if not calls:
232
+ return []
233
+
234
+ # Retrieve each called function from the index
235
+ dep_chunks = []
236
+ seen = set()
237
+ for call in calls:
238
+ # Strip object prefix if present (e.g. "self.calculate" β†’ "calculate")
239
+ name = call.split(".")[-1]
240
+ if name in seen:
241
+ continue
242
+ seen.add(name)
243
+
244
+ results = self.retrieve_by_function(name, n_results=1)
245
+ dep_chunks.extend(results)
246
+
247
+ return dep_chunks
248
+
249
+ def build_context(self, chunks: list[RetrievedChunk],
250
+ max_chars: int = 6000) -> str:
251
+ """
252
+ Concatenate retrieved chunks into a single context string
253
+ for the LLM prompt. Truncates at max_chars to stay within
254
+ context window limits.
255
+
256
+ Args:
257
+ chunks: Retrieved chunks to concatenate.
258
+ max_chars: Maximum total character length of context.
259
+
260
+ Returns:
261
+ A single string ready to inject into an LLM prompt.
262
+ """
263
+ parts = []
264
+ total = 0
265
+
266
+ for i, chunk in enumerate(chunks, 1):
267
+ section = (
268
+ f"--- Chunk {i} ({chunk.chunk_type}: {chunk.name}) ---\n"
269
+ f"{chunk.text}\n"
270
+ )
271
+ if total + len(section) > max_chars:
272
+ parts.append("\n[Context truncated β€” token limit reached]")
273
+ break
274
+ parts.append(section)
275
+ total += len(section)
276
+
277
+ return "\n".join(parts)
278
+
279
+ def retrieve_complete_function(self,
280
+ function_name: str,
281
+ class_name: str | None = None,
282
+ n_results: int = 1) -> list[RetrievedChunk]:
283
+ """
284
+ Retrieve complete function chunk including full source code.
285
+ Used for micro queries requiring edge case or usage analysis.
286
+ """
287
+ filters = {
288
+ "$and": [
289
+ {"type": {"$eq": "complete_function"}},
290
+ {"name": {"$eq": function_name}},
291
+ ]
292
+ }
293
+ if class_name:
294
+ filters = {
295
+ "$and": [
296
+ {"type": {"$eq": "complete_function"}},
297
+ {"name": {"$eq": function_name}},
298
+ {"class_name": {"$eq": class_name}},
299
+ ]
300
+ }
301
+
302
+ raw = query_chunks(
303
+ query=f"function {function_name}",
304
+ collection_name=COMPLETE_COLLECTION,
305
+ model=self.model,
306
+ n_results=n_results,
307
+ filters=filters,
308
+ )
309
+
310
+ return [
311
+ RetrievedChunk(
312
+ text=r["text"],
313
+ metadata=r["metadata"],
314
+ distance=r["distance"],
315
+ collection=COMPLETE_COLLECTION,
316
+ )
317
+ for r in raw
318
+ ]
319
+
320
+ def retrieve_complete_class(self,
321
+ class_name: str,
322
+ n_results: int = 1) -> list[RetrievedChunk]:
323
+ """
324
+ Retrieve complete class chunk including full source code.
325
+ Used for class-level deep queries.
326
+ """
327
+ filters = {
328
+ "$and": [
329
+ {"type": {"$eq": "complete_class"}},
330
+ {"name": {"$eq": class_name}},
331
+ ]
332
+ }
333
+
334
+ raw = query_chunks(
335
+ query=f"class {class_name}",
336
+ collection_name=COMPLETE_COLLECTION,
337
+ model=self.model,
338
+ n_results=n_results,
339
+ filters=filters,
340
+ )
341
+
342
+ return [
343
+ RetrievedChunk(
344
+ text=r["text"],
345
+ metadata=r["metadata"],
346
+ distance=r["distance"],
347
+ collection=COMPLETE_COLLECTION,
348
+ )
349
+ for r in raw
350
+ ]
351
+
352
+ def retrieve_file(self,
353
+ file_name: str,
354
+ n_results: int = 1) -> list[RetrievedChunk]:
355
+ """
356
+ Retrieve complete file chunk.
357
+ Used for file-wide queries.
358
+ """
359
+ filters = {
360
+ "$and": [
361
+ {"type": {"$eq": "file"}},
362
+ {"name": {"$eq": file_name}},
363
+ ]
364
+ }
365
+
366
+ raw = query_chunks(
367
+ query=f"file {file_name}",
368
+ collection_name=COMPLETE_COLLECTION,
369
+ model=self.model,
370
+ n_results=n_results,
371
+ filters=filters,
372
+ )
373
+
374
+ return [
375
+ RetrievedChunk(
376
+ text=r["text"],
377
+ metadata=r["metadata"],
378
+ distance=r["distance"],
379
+ collection=COMPLETE_COLLECTION,
380
+ )
381
+ for r in raw
382
+ ]
383
+ # ── Internal ──────────────────────────────────────────────────────────────
384
+
385
+ def _route_collection(self, query_type: str) -> str:
386
+ """Map query type to the appropriate ChromaDB collection."""
387
+ if query_type == QueryType.MICRO:
388
+ return FUNCTION_COLLECTION
389
+ return CLASS_COLLECTION # MACRO and CROSS_MODULE both use class chunks
390
+
391
+
392
+ # ── Rich Renderers ────────────────────────────────────────────────────────────
393
+
394
+ def render_chunks(chunks: list[RetrievedChunk], title: str = "Retrieved Chunks") -> None:
395
+ """Render retrieved chunks as a rich table."""
396
+ if not chunks:
397
+ console.print("[yellow]No chunks retrieved.[/yellow]")
398
+ return
399
+
400
+ table = Table(
401
+ "Rank", "Type", "Name", "Class", "Module", "File", "Relevance",
402
+ box=box.ROUNDED,
403
+ header_style="bold bright_cyan",
404
+ border_style="cyan",
405
+ show_lines=True,
406
+ title=title,
407
+ )
408
+
409
+ for i, chunk in enumerate(chunks, 1):
410
+ score = chunk.relevance_score
411
+ score_style = (
412
+ "bold green" if score > 0.7
413
+ else "yellow" if score > 0.4
414
+ else "red"
415
+ )
416
+ table.add_row(
417
+ str(i),
418
+ chunk.chunk_type,
419
+ f"[bold]{chunk.name}[/bold]",
420
+ chunk.class_name or "[dim]β€”[/dim]",
421
+ chunk.module,
422
+ chunk.file,
423
+ f"[{score_style}]{score}[/{score_style}]",
424
+ )
425
+
426
+ console.print(table)
427
+
428
+
429
+ def render_chunk_detail(chunk: RetrievedChunk) -> None:
430
+ """Render the full text of a single chunk in a panel."""
431
+ header = Text()
432
+ header.append(chunk.chunk_type.upper(), style="bold cyan")
433
+ header.append(f" {chunk.name}", style="bold white")
434
+ if chunk.class_name:
435
+ header.append(f" in {chunk.class_name}", style="dim")
436
+
437
+ console.print(Panel(
438
+ chunk.text,
439
+ title=str(header),
440
+ border_style="cyan",
441
+ padding=(1, 2),
442
+ ))
443
+
444
+
445
+ def render_context(context: str) -> None:
446
+ """Render the assembled LLM context string."""
447
+ console.print(Panel(
448
+ context,
449
+ title="[bold cyan]Assembled LLM Context[/bold cyan]",
450
+ border_style="dim cyan",
451
+ padding=(1, 2),
452
+ ))
453
+
454
+
455
+ # ── Entry Point (manual test) ─────────────────────────────────────────────────
456
+
457
+ if __name__ == "__main__":
458
+ import sys
459
+
460
+ console.rule("[bold cyan]Retriever β€” Manual Test[/bold cyan]")
461
+
462
+ retriever = Retriever()
463
+
464
+ # Test 1 β€” Macro query
465
+ console.rule("[cyan]Test 1 β€” Macro Query[/cyan]")
466
+ query = "what classes handle file parsing and walking"
467
+ chunks = retriever.retrieve(query, QueryType.MACRO, n_results=3)
468
+ render_chunks(chunks, title=f'Macro: "{query}"')
469
+
470
+ # Test 2 β€” Micro query
471
+ console.rule("[cyan]Test 2 β€” Micro Query[/cyan]")
472
+ query = "function that parses a single file and extracts classes"
473
+ chunks = retriever.retrieve(query, QueryType.MICRO, n_results=3)
474
+ render_chunks(chunks, title=f'Micro: "{query}"')
475
+ if chunks:
476
+ render_chunk_detail(chunks[0])
477
+
478
+ # Test 3 β€” Cross module dependencies
479
+ console.rule("[cyan]Test 3 β€” Dependency Retrieval[/cyan]")
480
+ func_name = sys.argv[1] if len(sys.argv) > 1 else "parse_file"
481
+ dep_chunks = retriever.retrieve_dependencies(func_name)
482
+ render_chunks(dep_chunks, title=f'Dependencies of: {func_name}')
483
+
484
+ # Test 4 β€” Build context string
485
+ console.rule("[cyan]Test 4 β€” Context Assembly[/cyan]")
486
+ all_chunks = retriever.retrieve("class structure and responsibilities",
487
+ QueryType.MACRO, n_results=4)
488
+ context = retriever.build_context(all_chunks)
489
+ console.print(f"[green]βœ”[/green] Context assembled: [cyan]{len(context)}[/cyan] chars")
store/__init__.py ADDED
File without changes
store/call_graph.json ADDED
File without changes
store/call_graph.py ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ call_graph.py
3
+ -------------
4
+ Builds a function-level call graph from parsed codebase (FileInfo objects)
5
+ and persists it as call_graph.json for cross-module dependency analysis.
6
+
7
+ Graph structure (stored in JSON):
8
+ {
9
+ "module.FileName.ClassName.function_name": {
10
+ "id": "module.FileName.ClassName.function_name",
11
+ "name": "function_name",
12
+ "class_name": "ClassName",
13
+ "module": "module",
14
+ "file": "relative/path/to/file.py",
15
+ "calls": ["other_func", "AnotherClass.method"],
16
+ "called_by": ["parent_func", "caller_func"]
17
+ },
18
+ ...
19
+ }
20
+
21
+ Depends on:
22
+ - ast_parser.parse_codebase() β†’ list[FileInfo]
23
+ - rich
24
+ """
25
+
26
+ import json
27
+ from pathlib import Path
28
+ from dataclasses import dataclass, field
29
+ from collections import defaultdict
30
+
31
+ from rich.console import Console
32
+ from rich.tree import Tree
33
+ from rich.table import Table
34
+ from rich.panel import Panel
35
+ from rich.text import Text
36
+ from rich import box
37
+
38
+ from ingest.parse_ast import parse_codebase, FileInfo
39
+
40
+ console = Console()
41
+
42
+ # ── Constants ─────────────────────────────────────────────────────────────────
43
+
44
+ CALL_GRAPH_PATH = "./call_graph.json"
45
+
46
+
47
+ # ── Node Model ────────────────────────────────────────────────────────────────
48
+
49
+ @dataclass
50
+ class CallNode:
51
+ """Represents a single function/method in the call graph."""
52
+ id: str # unique fully-qualified ID
53
+ name: str # function name
54
+ class_name: str # class name or "" for top-level
55
+ module: str # top-level module/package
56
+ file: str # relative file path
57
+ calls: list[str] = field(default_factory=list) # what it calls
58
+ called_by: list[str] = field(default_factory=list) # what calls it
59
+
60
+ def to_dict(self) -> dict:
61
+ return {
62
+ "id": self.id,
63
+ "name": self.name,
64
+ "class_name": self.class_name,
65
+ "module": self.module,
66
+ "file": self.file,
67
+ "calls": self.calls,
68
+ "called_by": self.called_by,
69
+ }
70
+
71
+ @staticmethod
72
+ def from_dict(data: dict) -> "CallNode":
73
+ return CallNode(
74
+ id=data["id"],
75
+ name=data["name"],
76
+ class_name=data.get("class_name", ""),
77
+ module=data["module"],
78
+ file=data["file"],
79
+ calls=data.get("calls", []),
80
+ called_by=data.get("called_by", []),
81
+ )
82
+
83
+
84
+ # ── CallGraph ─────────────────────────────────────────────────────────────────
85
+
86
+ class CallGraph:
87
+ """
88
+ Builds, stores, queries, and persists the function call graph
89
+ for a monolithic codebase.
90
+ """
91
+
92
+ def __init__(self):
93
+ # node_id β†’ CallNode
94
+ self._graph: dict[str, CallNode] = {}
95
+ # name β†’ list of node_ids (for fuzzy lookup by function name alone)
96
+ self._name_index: dict[str, list[str]] = defaultdict(list)
97
+
98
+ # ── Build ─────────────────────────────────────────────────────────────────
99
+
100
+ def build(self, parsed_files: list[FileInfo]) -> None:
101
+ """
102
+ Build the call graph from a list of parsed FileInfo objects.
103
+ Two passes:
104
+ 1. Register all functions as nodes
105
+ 2. Resolve call relationships and populate called_by
106
+
107
+ Args:
108
+ parsed_files: Output of ast_parser.parse_codebase().
109
+ """
110
+ self._graph.clear()
111
+ self._name_index.clear()
112
+
113
+ # Pass 1 β€” register all nodes
114
+ for file_info in parsed_files:
115
+ # Top-level functions
116
+ for func in file_info.functions:
117
+ node = CallNode(
118
+ id=self._make_id(file_info, func.name, class_name=""),
119
+ name=func.name,
120
+ class_name="",
121
+ module=file_info.module,
122
+ file=file_info.relative,
123
+ calls=func.calls,
124
+ )
125
+ self._register(node)
126
+
127
+ # Class methods
128
+ for cls in file_info.classes:
129
+ for method in cls.methods:
130
+ node = CallNode(
131
+ id=self._make_id(file_info, method.name, class_name=cls.name),
132
+ name=method.name,
133
+ class_name=cls.name,
134
+ module=file_info.module,
135
+ file=file_info.relative,
136
+ calls=method.calls,
137
+ )
138
+ self._register(node)
139
+
140
+ # Pass 2 β€” resolve called_by relationships
141
+ for node in self._graph.values():
142
+ for raw_call in node.calls:
143
+ # Strip object prefix: "self.calculate" β†’ "calculate"
144
+ callee_name = raw_call.split(".")[-1]
145
+ candidate_ids = self._name_index.get(callee_name, [])
146
+ for callee_id in candidate_ids:
147
+ callee = self._graph.get(callee_id)
148
+ if callee and node.id not in callee.called_by:
149
+ callee.called_by.append(node.id)
150
+
151
+ total_nodes = len(self._graph)
152
+ total_edges = sum(len(n.calls) for n in self._graph.values())
153
+ console.print(
154
+ f"[green]βœ”[/green] Call graph built: "
155
+ f"[cyan]{total_nodes}[/cyan] nodes Β· "
156
+ f"[magenta]{total_edges}[/magenta] edges\n"
157
+ )
158
+
159
+ def _make_id(self, file_info: FileInfo,
160
+ func_name: str,
161
+ class_name: str) -> str:
162
+ """Generate a unique fully-qualified node ID."""
163
+ stem = Path(file_info.relative).stem # filename without extension
164
+ parts = [file_info.module, stem]
165
+ if class_name:
166
+ parts.append(class_name)
167
+ parts.append(func_name)
168
+ return ".".join(parts)
169
+
170
+ def _register(self, node: CallNode) -> None:
171
+ """Register a node in the graph and name index."""
172
+ self._graph[node.id] = node
173
+ self._name_index[node.name].append(node.id)
174
+
175
+ # ── Persist ───────────────────────────────────────────────────────────────
176
+
177
+ def save(self, path: str = CALL_GRAPH_PATH) -> None:
178
+ """
179
+ Persist the call graph to a JSON file.
180
+
181
+ Args:
182
+ path: File path to write call_graph.json.
183
+ """
184
+ data = {node_id: node.to_dict() for node_id, node in self._graph.items()}
185
+
186
+ with open(path, "w", encoding="utf-8") as f:
187
+ json.dump(data, f, indent=2)
188
+
189
+ size_kb = round(Path(path).stat().st_size / 1024, 2)
190
+ console.print(
191
+ f"[green]βœ”[/green] Call graph saved to "
192
+ f"[cyan]{path}[/cyan] ({size_kb} KB)\n"
193
+ )
194
+
195
+ def load(self, path: str = CALL_GRAPH_PATH) -> None:
196
+ """
197
+ Load a persisted call graph from JSON.
198
+
199
+ Args:
200
+ path: File path to read call_graph.json from.
201
+
202
+ Raises:
203
+ FileNotFoundError: If the file does not exist.
204
+ """
205
+ graph_path = Path(path)
206
+ if not graph_path.exists():
207
+ raise FileNotFoundError(
208
+ f"Call graph not found at '{path}'. "
209
+ "Run build_and_save() first."
210
+ )
211
+
212
+ with open(path, "r", encoding="utf-8") as f:
213
+ data = json.load(f)
214
+
215
+ self._graph.clear()
216
+ self._name_index.clear()
217
+
218
+ for node_id, node_data in data.items():
219
+ node = CallNode.from_dict(node_data)
220
+ self._graph[node_id] = node
221
+ self._name_index[node.name].append(node_id)
222
+
223
+ console.print(
224
+ f"[green]βœ”[/green] Call graph loaded: "
225
+ f"[cyan]{len(self._graph)}[/cyan] nodes from "
226
+ f"[cyan]{path}[/cyan]\n"
227
+ )
228
+
229
+ # ── Query API ─────────────────────────────────────────────────────────────
230
+
231
+ def get_node(self, function_name: str,
232
+ class_name: str | None = None) -> list[CallNode]:
233
+ """
234
+ Look up nodes by function name, optionally filtered by class.
235
+
236
+ Args:
237
+ function_name: Name of the function/method to look up.
238
+ class_name: Optional class name to narrow results.
239
+
240
+ Returns:
241
+ List of matching CallNode objects.
242
+ """
243
+ candidate_ids = self._name_index.get(function_name, [])
244
+ nodes = [self._graph[nid] for nid in candidate_ids if nid in self._graph]
245
+
246
+ if class_name:
247
+ nodes = [n for n in nodes if n.class_name == class_name]
248
+
249
+ return nodes
250
+
251
+ def get_calls(self, function_name: str,
252
+ class_name: str | None = None) -> list[CallNode]:
253
+ """
254
+ Get all functions that the given function calls (outgoing edges).
255
+
256
+ Args:
257
+ function_name: Source function name.
258
+ class_name: Optional class scope.
259
+
260
+ Returns:
261
+ List of CallNode objects this function calls.
262
+ """
263
+ source_nodes = self.get_node(function_name, class_name)
264
+ if not source_nodes:
265
+ return []
266
+
267
+ results = []
268
+ seen = set()
269
+ for source in source_nodes:
270
+ for raw_call in source.calls:
271
+ callee_name = raw_call.split(".")[-1]
272
+ for callee_node in self.get_node(callee_name):
273
+ if callee_node.id not in seen:
274
+ seen.add(callee_node.id)
275
+ results.append(callee_node)
276
+
277
+ return results
278
+
279
+ def get_called_by(self, function_name: str,
280
+ class_name: str | None = None) -> list[CallNode]:
281
+ """
282
+ Get all functions that call the given function (incoming edges).
283
+
284
+ Args:
285
+ function_name: Target function name.
286
+ class_name: Optional class scope.
287
+
288
+ Returns:
289
+ List of CallNode objects that call this function.
290
+ """
291
+ target_nodes = self.get_node(function_name, class_name)
292
+ if not target_nodes:
293
+ return []
294
+
295
+ results = []
296
+ seen = set()
297
+ for target in target_nodes:
298
+ for caller_id in target.called_by:
299
+ if caller_id not in seen and caller_id in self._graph:
300
+ seen.add(caller_id)
301
+ results.append(self._graph[caller_id])
302
+
303
+ return results
304
+
305
+ def get_impact(self, function_name: str,
306
+ class_name: str | None = None,
307
+ depth: int = 2) -> dict[str, list[CallNode]]:
308
+ """
309
+ Trace the impact of changing a function β€” who calls it,
310
+ who calls those callers, up to `depth` levels.
311
+
312
+ Args:
313
+ function_name: Function to analyze.
314
+ class_name: Optional class scope.
315
+ depth: How many levels up to trace (default 2).
316
+
317
+ Returns:
318
+ Dict mapping depth level string β†’ list of affected CallNodes.
319
+ """
320
+ impact: dict[str, list[CallNode]] = {}
321
+ current_level = self.get_node(function_name, class_name)
322
+ visited = {n.id for n in current_level}
323
+
324
+ for level in range(1, depth + 1):
325
+ next_level = []
326
+ for node in current_level:
327
+ for caller_id in node.called_by:
328
+ if caller_id not in visited and caller_id in self._graph:
329
+ visited.add(caller_id)
330
+ next_level.append(self._graph[caller_id])
331
+ if not next_level:
332
+ break
333
+ impact[f"level_{level}"] = next_level
334
+ current_level = next_level
335
+
336
+ return impact
337
+
338
+ def stats(self) -> dict:
339
+ """Return summary statistics for the call graph."""
340
+ total_edges = sum(len(n.calls) for n in self._graph.values())
341
+ modules = list({n.module for n in self._graph.values()})
342
+ isolated = [n for n in self._graph.values()
343
+ if not n.calls and not n.called_by]
344
+ return {
345
+ "total_nodes": len(self._graph),
346
+ "total_edges": total_edges,
347
+ "modules": modules,
348
+ "isolated_nodes": len(isolated),
349
+ }
350
+
351
+ def is_loaded(self) -> bool:
352
+ """Return True if graph has been built or loaded."""
353
+ return len(self._graph) > 0
354
+
355
+ # ── Rich Renderers ────────────────────────────────────────────────────────
356
+
357
+ def render_stats(self) -> None:
358
+ """Render call graph statistics as a rich panel."""
359
+ s = self.stats()
360
+
361
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
362
+ table.add_column(style="dim")
363
+ table.add_column(style="bold white")
364
+
365
+ table.add_row("Total nodes", str(s["total_nodes"]))
366
+ table.add_row("Total edges", str(s["total_edges"]))
367
+ table.add_row("Modules", ", ".join(s["modules"]))
368
+ table.add_row("Isolated nodes", str(s["isolated_nodes"]))
369
+ table.add_row(
370
+ "Status",
371
+ "[bold green]βœ” Loaded[/bold green]"
372
+ if self.is_loaded()
373
+ else "[bold red]✘ Empty[/bold red]"
374
+ )
375
+
376
+ console.print(Panel(
377
+ table,
378
+ title="[bold cyan]Call Graph Stats[/bold cyan]",
379
+ border_style="cyan",
380
+ ))
381
+
382
+ def render_node(self, node: CallNode) -> None:
383
+ """Render a single node with its calls and callers."""
384
+ scope = f"{node.class_name}.{node.name}" if node.class_name else node.name
385
+
386
+ calls_str = "\n".join(f" β†’ {c}" for c in node.calls) or " [dim]none[/dim]"
387
+ called_by_str = "\n".join(f" ← {c}" for c in node.called_by) or " [dim]none[/dim]"
388
+
389
+ content = Text()
390
+ content.append("Module: ", style="dim")
391
+ content.append(f"{node.module}\n")
392
+ content.append("File: ", style="dim")
393
+ content.append(f"{node.file}\n\n")
394
+ content.append("Calls:\n", style="bold cyan")
395
+ content.append(calls_str + "\n\n")
396
+ content.append("Called by:\n", style="bold magenta")
397
+ content.append(called_by_str)
398
+
399
+ console.print(Panel(
400
+ content,
401
+ title=f"[bold white]{scope}[/bold white]",
402
+ border_style="cyan",
403
+ ))
404
+
405
+ def render_impact(self, function_name: str,
406
+ impact: dict[str, list[CallNode]]) -> None:
407
+ """Render the impact tree of a function change."""
408
+ root = Tree(
409
+ f"[bold yellow]⚑ Impact of changing:[/bold yellow] "
410
+ f"[bold white]{function_name}[/bold white]",
411
+ guide_style="dim yellow",
412
+ )
413
+
414
+ if not impact:
415
+ root.add("[dim]No callers found β€” isolated function.[/dim]")
416
+ else:
417
+ for level, nodes in impact.items():
418
+ level_num = level.split("_")[1]
419
+ level_branch = root.add(
420
+ f"[bold cyan]Level {level_num} β€” {len(nodes)} affected[/bold cyan]"
421
+ )
422
+ for node in nodes:
423
+ scope = (
424
+ f"{node.class_name}.{node.name}"
425
+ if node.class_name else node.name
426
+ )
427
+ level_branch.add(
428
+ f"[magenta]{scope}[/magenta] "
429
+ f"[dim]{node.file}[/dim]"
430
+ )
431
+
432
+ console.print(root)
433
+
434
+ def render_dependencies(self, function_name: str,
435
+ calls: list[CallNode],
436
+ called_by: list[CallNode]) -> None:
437
+ """Render outgoing and incoming dependencies for a function."""
438
+ table = Table(
439
+ "Direction", "Function", "Class", "Module", "File",
440
+ box=box.ROUNDED,
441
+ header_style="bold bright_cyan",
442
+ border_style="cyan",
443
+ show_lines=True,
444
+ title=f"Dependencies: {function_name}",
445
+ )
446
+
447
+ for node in calls:
448
+ table.add_row(
449
+ "[cyan]β†’ calls[/cyan]",
450
+ node.name,
451
+ node.class_name or "[dim]β€”[/dim]",
452
+ node.module,
453
+ node.file,
454
+ )
455
+
456
+ for node in called_by:
457
+ table.add_row(
458
+ "[magenta]← called by[/magenta]",
459
+ node.name,
460
+ node.class_name or "[dim]β€”[/dim]",
461
+ node.module,
462
+ node.file,
463
+ )
464
+
465
+ if not calls and not called_by:
466
+ console.print(f"[yellow]No dependencies found for '{function_name}'.[/yellow]")
467
+ else:
468
+ console.print(table)
469
+
470
+
471
+ # ── Convenience Pipeline ──────────────────────────────────────────────────────
472
+
473
+ def build_and_save(root_path: str,
474
+ graph_path: str = CALL_GRAPH_PATH) -> CallGraph:
475
+ """
476
+ Full pipeline: parse codebase β†’ build graph β†’ save to JSON.
477
+
478
+ Args:
479
+ root_path: Absolute path to the monolithic codebase root.
480
+ graph_path: Path to write call_graph.json.
481
+
482
+ Returns:
483
+ Built and saved CallGraph instance.
484
+ """
485
+ console.rule("[bold cyan]Call Graph Builder[/bold cyan]")
486
+ console.print(f"[bold]πŸ“‚ Root:[/bold] {root_path}\n")
487
+
488
+ parsed_files = parse_codebase(root_path)
489
+
490
+ graph = CallGraph()
491
+ graph.build(parsed_files)
492
+ graph.save(graph_path)
493
+ graph.render_stats()
494
+
495
+ return graph
496
+
497
+
498
+ def load_graph(graph_path: str = CALL_GRAPH_PATH) -> CallGraph:
499
+ """
500
+ Load a persisted call graph from JSON.
501
+
502
+ Args:
503
+ graph_path: Path to call_graph.json.
504
+
505
+ Returns:
506
+ Loaded CallGraph instance.
507
+ """
508
+ graph = CallGraph()
509
+ graph.load(graph_path)
510
+ return graph
511
+
512
+
513
+ # ── Singleton Access ──────────────────────────────────────────────────────────
514
+
515
+ _graph_instance: CallGraph | None = None
516
+
517
+
518
+ def get_call_graph(graph_path: str = CALL_GRAPH_PATH) -> CallGraph:
519
+ """
520
+ Return a singleton CallGraph instance.
521
+ Loads from JSON on first call, reuses on subsequent calls.
522
+
523
+ Args:
524
+ graph_path: Path to call_graph.json.
525
+
526
+ Returns:
527
+ Shared CallGraph instance.
528
+ """
529
+ global _graph_instance
530
+ if _graph_instance is None:
531
+ _graph_instance = load_graph(graph_path)
532
+ return _graph_instance
533
+
534
+
535
+ # ── Entry Point ───────────────────────────────────────────────────────────────
536
+
537
+ if __name__ == "__main__":
538
+ import sys
539
+
540
+ if len(sys.argv) < 2:
541
+ console.print(
542
+ "[red]Usage:[/red] python call_graph.py <codebase_path> "
543
+ "[function_name]"
544
+ )
545
+ sys.exit(1)
546
+
547
+ root = sys.argv[1]
548
+ query = sys.argv[2] if len(sys.argv) > 2 else None
549
+
550
+ # Build and save
551
+ graph = build_and_save(root)
552
+
553
+ if query:
554
+ console.rule(f"[bold cyan]Query: {query}[/bold cyan]")
555
+
556
+ # Node detail
557
+ nodes = graph.get_node(query)
558
+ if not nodes:
559
+ console.print(f"[yellow]Function '{query}' not found in graph.[/yellow]")
560
+ else:
561
+ for node in nodes:
562
+ graph.render_node(node)
563
+
564
+ # Dependencies
565
+ calls = graph.get_calls(query)
566
+ called_by = graph.get_called_by(query)
567
+ graph.render_dependencies(query, calls, called_by)
568
+
569
+ # Impact analysis
570
+ impact = graph.get_impact(query, depth=2)
571
+ graph.render_impact(query, impact)
572
+ else:
573
+ # Show sample nodes
574
+ console.rule("[cyan]Sample Nodes[/cyan]")
575
+ sample = list(graph._graph.values())[:5]
576
+ for node in sample:
577
+ graph.render_node(node)
store/vector_store.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ vector_store.py
3
+ ---------------
4
+ Pinecone-backed vector store interface for the Codebase Oracle system.
5
+ Reads from the same Pinecone index used by embed.py via namespaces.
6
+
7
+ Collections (as Pinecone namespaces):
8
+ - class_chunks : one chunk per class (macro / cross-module queries)
9
+ - function_chunks : one chunk per function/method (micro queries)
10
+
11
+ Depends on:
12
+ - pinecone
13
+ - ingest.embed (get_pinecone_index)
14
+ - rich
15
+ """
16
+
17
+ from dataclasses import dataclass
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+ from rich.panel import Panel
21
+ from rich.text import Text
22
+ from rich import box
23
+
24
+ from ingest.embed import get_pinecone_index, CLASS_COLLECTION, FUNCTION_COLLECTION
25
+
26
+ console = Console()
27
+
28
+
29
+ # ── Result Model ──────────────────────────────────────────────────────────────
30
+
31
+ @dataclass
32
+ class ChunkResult:
33
+ """Represents a single retrieved chunk."""
34
+ id: str
35
+ text: str
36
+ metadata: dict
37
+ distance: float | None = None
38
+
39
+ @property
40
+ def name(self) -> str:
41
+ return self.metadata.get("name", "unknown")
42
+
43
+ @property
44
+ def module(self) -> str:
45
+ return self.metadata.get("module", "unknown")
46
+
47
+ @property
48
+ def file(self) -> str:
49
+ return self.metadata.get("file", "unknown")
50
+
51
+ @property
52
+ def chunk_type(self) -> str:
53
+ return self.metadata.get("type", "unknown")
54
+
55
+ @property
56
+ def class_name(self) -> str:
57
+ return self.metadata.get("class_name", "")
58
+
59
+ @property
60
+ def relevance(self) -> float:
61
+ if self.distance is None:
62
+ return 0.0
63
+ return round(1 / (1 + self.distance), 4)
64
+
65
+
66
+ # ── VectorStore ───────────────────────────────────────────────────────────────
67
+
68
+ class VectorStore:
69
+ """
70
+ Pinecone-backed interface for stats and tree queries.
71
+ Reuses the same index as embed.py β€” no duplicate client.
72
+ """
73
+
74
+ def __init__(self):
75
+ self._index = get_pinecone_index()
76
+ console.print("[green]βœ”[/green] VectorStore ready (Pinecone)\n")
77
+
78
+ def _count(self, namespace: str) -> int:
79
+ """Return approximate vector count in a namespace."""
80
+ stats = self._index.describe_index_stats()
81
+ return stats["namespaces"].get(namespace, {}).get("vector_count", 0)
82
+
83
+ def stats(self) -> dict:
84
+ class_count = self._count(CLASS_COLLECTION)
85
+ func_count = self._count(FUNCTION_COLLECTION)
86
+ return {
87
+ "class_chunks": class_count,
88
+ "function_chunks": func_count,
89
+ "total": class_count + func_count,
90
+ }
91
+
92
+ def is_indexed(self) -> bool:
93
+ s = self.stats()
94
+ return s["total"] > 0
95
+
96
+ def get_all(self, namespace: str, limit: int = 10) -> list[ChunkResult]:
97
+ """
98
+ Fetch chunks from a namespace without a query vector.
99
+ Pinecone does not support scan β€” we use a zero vector as proxy.
100
+ """
101
+ from config.config import EMBEDDING_DIM
102
+ zero_vector = [0.0] * EMBEDDING_DIM
103
+
104
+ results = self._index.query(
105
+ vector=zero_vector,
106
+ top_k=limit,
107
+ namespace=namespace,
108
+ include_metadata=True,
109
+ )
110
+
111
+ output = []
112
+ for match in results["matches"]:
113
+ meta = dict(match["metadata"])
114
+ text = meta.pop("text", "")
115
+ output.append(ChunkResult(
116
+ id=match["id"],
117
+ text=text,
118
+ metadata=meta,
119
+ distance=1 - match["score"],
120
+ ))
121
+ return output
122
+
123
+ def render_stats(self) -> None:
124
+ s = self.stats()
125
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
126
+ table.add_column(style="dim")
127
+ table.add_column(style="bold white")
128
+ table.add_row("Class chunks", str(s["class_chunks"]))
129
+ table.add_row("Function chunks", str(s["function_chunks"]))
130
+ table.add_row("Total chunks", str(s["total"]))
131
+ table.add_row(
132
+ "Status",
133
+ "[bold green]βœ” Indexed[/bold green]"
134
+ if self.is_indexed()
135
+ else "[bold red]✘ Not indexed[/bold red]"
136
+ )
137
+ console.print(Panel(
138
+ table,
139
+ title="[bold cyan]VectorStore Stats[/bold cyan]",
140
+ border_style="cyan",
141
+ ))
142
+
143
+
144
+ # ── Singleton ─────────────────────────────────────────────────────────────────
145
+
146
+ _store_instance: VectorStore | None = None
147
+
148
+
149
+ def get_vector_store() -> VectorStore:
150
+ global _store_instance
151
+ if _store_instance is None:
152
+ _store_instance = VectorStore()
153
+ return _store_instance
ui/index.html ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Codebase Oracle</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="static/style.css" />
11
+ </head>
12
+ <body>
13
+
14
+ <!-- ── Top Bar ─────────────────────────────────────────────────────────── -->
15
+ <header class="topbar">
16
+ <div class="topbar__brand">
17
+ <span class="topbar__icon">⬑</span>
18
+ <span class="topbar__title">Codebase Oracle</span>
19
+ </div>
20
+
21
+ <div class="topbar__index">
22
+ <input
23
+ id="pathInput"
24
+ class="path-input"
25
+ type="text"
26
+ placeholder="/absolute/path/to/your/codebase"
27
+ autocomplete="off"
28
+ spellcheck="false"
29
+ />
30
+ <label class="btn btn--secondary" for="zipInput" id="zipLabel">Upload ZIP</label>
31
+ <input id="zipInput" type="file" accept=".zip" style="display:none" />
32
+ <button id="indexBtn" class="btn btn--primary">
33
+ <span id="indexBtnText">Index</span>
34
+ <span id="indexSpinner" class="spinner hidden"></span>
35
+ </button>
36
+ </div>
37
+
38
+ <div id="statusBadge" class="status-badge status-badge--idle">
39
+ <span class="status-badge__dot"></span>
40
+ <span id="statusText">Not indexed</span>
41
+ </div>
42
+ </header>
43
+
44
+ <!-- ── Main Layout ─────────────────────────────────────────────────────── -->
45
+ <div class="workspace">
46
+
47
+ <!-- Sidebar -->
48
+ <aside class="sidebar">
49
+ <div class="sidebar__header">
50
+ <span class="sidebar__label">Explorer</span>
51
+ <span id="chunkCount" class="sidebar__count"></span>
52
+ </div>
53
+ <div id="treeContainer" class="tree-container">
54
+ <div class="tree-placeholder">
55
+ <span class="tree-placeholder__icon">⬑</span>
56
+ <p>Index a codebase to explore its structure</p>
57
+ </div>
58
+ </div>
59
+ </aside>
60
+
61
+ <!-- Chat Area -->
62
+ <main class="chat">
63
+
64
+ <!-- Query Type Selector -->
65
+ <div class="query-controls">
66
+ <div class="query-type-group" id="queryTypeGroup">
67
+ <button class="query-type-btn active" data-type="micro">Micro</button>
68
+ <button class="query-type-btn" data-type="macro">Macro</button>
69
+ <button class="query-type-btn" data-type="cross_module">Cross-Module</button>
70
+ </div>
71
+
72
+ <!-- Macro subtype (shown only when macro selected) -->
73
+ <div id="macroSubtypes" class="macro-subtypes hidden">
74
+ <button class="subtype-btn active" data-subtype="overall_architecture">Architecture</button>
75
+ <button class="subtype-btn" data-subtype="module_responsibility">Module</button>
76
+ <button class="subtype-btn" data-subtype="data_flow">Data Flow</button>
77
+ </div>
78
+
79
+ <!-- Contextual inputs -->
80
+ <div id="contextInputs" class="context-inputs">
81
+ <input id="functionInput" class="context-field" type="text" placeholder="Function name" autocomplete="off" spellcheck="false" />
82
+ <input id="classInput" class="context-field" type="text" placeholder="Class name (optional)" autocomplete="off" spellcheck="false" />
83
+ <input id="moduleInput" class="context-field hidden" type="text" placeholder="Module name" autocomplete="off" spellcheck="false" />
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Messages -->
88
+ <div id="messages" class="messages">
89
+ <div class="message message--system">
90
+ <div class="message__content">
91
+ <p>Welcome to <strong>Codebase Oracle</strong>. Index a codebase using the path bar above, then ask anything about it.</p>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- Input Bar -->
97
+ <div class="input-bar">
98
+ <textarea
99
+ id="queryInput"
100
+ class="query-textarea"
101
+ placeholder="Ask anything about the codebase…"
102
+ rows="1"
103
+ spellcheck="false"
104
+ ></textarea>
105
+ <button id="sendBtn" class="btn btn--send" disabled>
106
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
107
+ <line x1="22" y1="2" x2="11" y2="13"></line>
108
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
109
+ </svg>
110
+ </button>
111
+ </div>
112
+
113
+ </main>
114
+ </div>
115
+
116
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
117
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
118
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
119
+ <script src="static/app.js"></script>
120
+ </body>
121
+ </html>
ui/static/app.js ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* app.js β€” Codebase Oracle frontend logic */
2
+
3
+ const API = 'http://localhost:8000';
4
+
5
+ // ── State ─────────────────────────────────────────────────────────────────────
6
+ const state = {
7
+ indexed: false,
8
+ querying: false,
9
+ queryType: 'micro',
10
+ macroSubtype: 'overall_architecture',
11
+ lastResponse: '',
12
+ };
13
+
14
+ // ── DOM Refs ──────────────────────────────────────────────────────────────────
15
+ const $ = id => document.getElementById(id);
16
+
17
+ const dom = {
18
+ pathInput: $('pathInput'),
19
+ zipInput: $('zipInput'),
20
+ zipLabel: $('zipLabel'),
21
+ indexBtn: $('indexBtn'),
22
+ indexBtnText: $('indexBtnText'),
23
+ indexSpinner: $('indexSpinner'),
24
+ statusBadge: $('statusBadge'),
25
+ statusText: $('statusText'),
26
+ chunkCount: $('chunkCount'),
27
+ treeContainer: $('treeContainer'),
28
+ messages: $('messages'),
29
+ queryInput: $('queryInput'),
30
+ sendBtn: $('sendBtn'),
31
+ functionInput: $('functionInput'),
32
+ classInput: $('classInput'),
33
+ moduleInput: $('moduleInput'),
34
+ macroSubtypes: $('macroSubtypes'),
35
+ };
36
+
37
+ // ── Marked config ─────────────────────────────────────────────────────────────
38
+ marked.setOptions({
39
+ breaks: true,
40
+ gfm: true,
41
+ });
42
+
43
+ marked.use({
44
+ renderer: (() => {
45
+ const r = new marked.Renderer();
46
+ r.code = (code, lang) => {
47
+ const highlighted = (lang && hljs.getLanguage(lang))
48
+ ? hljs.highlight(code, { language: lang }).value
49
+ : hljs.highlightAuto(code).value;
50
+ return `<pre><code class="hljs language-${lang || ''}">${highlighted}</code></pre>`;
51
+ };
52
+ return r;
53
+ })(),
54
+ });
55
+
56
+ // ── Status ────────────────────────────────────────────────────────────────────
57
+ function setStatus(type, text) {
58
+ dom.statusBadge.className = `status-badge status-badge--${type}`;
59
+ dom.statusText.textContent = text;
60
+ }
61
+
62
+ // ── Index ─────────────────────────────────────────────────────────────────────
63
+ dom.zipInput.addEventListener('change', () => {
64
+ if (dom.zipInput.files.length > 0) {
65
+ dom.zipLabel.textContent = dom.zipInput.files[0].name;
66
+ dom.pathInput.value = '';
67
+ }
68
+ });
69
+
70
+ dom.indexBtn.addEventListener('click', async () => {
71
+ const path = dom.pathInput.value.trim();
72
+ const zipFile = dom.zipInput.files[0];
73
+
74
+ if (!path && !zipFile) {
75
+ appendMessage('error', null, '❌ Provide an absolute path or upload a ZIP file.');
76
+ return;
77
+ }
78
+
79
+ dom.indexBtn.disabled = true;
80
+ dom.indexBtnText.classList.add('hidden');
81
+ dom.indexSpinner.classList.remove('hidden');
82
+ setStatus('indexing', 'Indexing…');
83
+
84
+ try {
85
+ let res, data;
86
+
87
+ if (zipFile) {
88
+ const form = new FormData();
89
+ form.append('file', zipFile);
90
+ res = await fetch(`${API}/upload-index`, { method: 'POST', body: form });
91
+ data = await res.json();
92
+ } else {
93
+ res = await fetch(`${API}/index`, {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify({ root_path: path }),
97
+ });
98
+ data = await res.json();
99
+ }
100
+
101
+ if (!res.ok) throw new Error(data.detail || 'Indexing failed');
102
+
103
+ const label = zipFile ? zipFile.name : path;
104
+ state.indexed = true;
105
+ setStatus('ready', `Ready Β· ${data.total_chunks} chunks`);
106
+ dom.chunkCount.textContent = `${data.total_chunks} chunks`;
107
+ dom.sendBtn.disabled = false;
108
+
109
+ appendMessage('system', null,
110
+ `βœ” Indexed **${label}**\n\n` +
111
+ `| Metric | Value |\n|--------|-------|\n` +
112
+ `| Class chunks | ${data.class_chunks} |\n` +
113
+ `| Function chunks | ${data.function_chunks} |\n` +
114
+ `| Total chunks | ${data.total_chunks} |\n` +
115
+ `| Graph nodes | ${data.graph_nodes} |\n` +
116
+ `| Graph edges | ${data.graph_edges} |`
117
+ );
118
+
119
+ loadTree();
120
+
121
+ } catch (err) {
122
+ setStatus('error', 'Error');
123
+ appendMessage('error', null, `❌ ${err.message}`);
124
+ } finally {
125
+ dom.indexBtn.disabled = false;
126
+ dom.indexBtnText.classList.remove('hidden');
127
+ dom.indexSpinner.classList.add('hidden');
128
+ dom.zipLabel.textContent = 'Upload ZIP';
129
+ dom.zipInput.value = '';
130
+ }
131
+ });
132
+
133
+ // ── Tree ──────────────────────────────────────────────────────────────────────
134
+ async function loadTree() {
135
+ try {
136
+ const res = await fetch(`${API}/tree`);
137
+ const data = await res.json();
138
+
139
+ if (!data.success || !data.tree.length) {
140
+ dom.treeContainer.innerHTML =
141
+ '<div class="tree-placeholder"><p>No structure found.</p></div>';
142
+ return;
143
+ }
144
+
145
+ dom.treeContainer.innerHTML = '';
146
+ data.tree.forEach(node => {
147
+ dom.treeContainer.appendChild(buildTreeNode(node, 0));
148
+ });
149
+
150
+ } catch (err) {
151
+ console.error('Tree load error:', err);
152
+ }
153
+ }
154
+
155
+ const ICONS = {
156
+ module: { icon: 'πŸ“¦', cls: 'icon-module' },
157
+ file: { icon: 'πŸ“„', cls: 'icon-file' },
158
+ class: { icon: 'πŸ”·', cls: 'icon-class' },
159
+ function: { icon: 'Ζ’', cls: 'icon-function' },
160
+ };
161
+
162
+ function buildTreeNode(node, depth) {
163
+ const wrap = document.createElement('div');
164
+ const hasKids = node.children && node.children.length > 0;
165
+ const info = ICONS[node.type] || ICONS.file;
166
+
167
+ wrap.className = 'tree-node';
168
+ wrap.dataset.depth = depth;
169
+
170
+ const row = document.createElement('div');
171
+ row.className = 'tree-node__row';
172
+
173
+ const toggle = document.createElement('span');
174
+ toggle.className = 'tree-node__toggle';
175
+ toggle.textContent = hasKids ? 'β–Ά' : '';
176
+
177
+ const icon = document.createElement('span');
178
+ icon.className = `tree-node__icon ${info.cls}`;
179
+ icon.textContent = info.icon;
180
+
181
+ const label = document.createElement('span');
182
+ label.className = 'tree-node__name';
183
+ label.textContent = node.name;
184
+
185
+ row.append(toggle, icon, label);
186
+ wrap.appendChild(row);
187
+
188
+ if (hasKids) {
189
+ const children = document.createElement('div');
190
+ children.className = 'tree-node__children';
191
+ node.children.forEach(child => {
192
+ children.appendChild(buildTreeNode(child, depth + 1));
193
+ });
194
+ wrap.appendChild(children);
195
+
196
+ row.addEventListener('click', () => {
197
+ const open = children.classList.toggle('open');
198
+ toggle.classList.toggle('open', open);
199
+ });
200
+ }
201
+
202
+ // Click leaf β†’ auto-fill inputs
203
+ if (node.type === 'function') {
204
+ row.addEventListener('click', () => {
205
+ dom.functionInput.value = node.name;
206
+ highlightRow(row);
207
+ });
208
+ }
209
+ if (node.type === 'class') {
210
+ row.addEventListener('click', () => {
211
+ dom.classInput.value = node.name;
212
+ highlightRow(row);
213
+ });
214
+ }
215
+
216
+ return wrap;
217
+ }
218
+
219
+ function highlightRow(row) {
220
+ document.querySelectorAll('.tree-node__row.selected')
221
+ .forEach(r => r.classList.remove('selected'));
222
+ row.classList.add('selected');
223
+ }
224
+
225
+ // ── Query Type Tabs ───────────────────────────────────────────────────────────
226
+ document.querySelectorAll('.query-type-btn').forEach(btn => {
227
+ btn.addEventListener('click', () => {
228
+ document.querySelectorAll('.query-type-btn')
229
+ .forEach(b => b.classList.remove('active'));
230
+ btn.classList.add('active');
231
+ state.queryType = btn.dataset.type;
232
+ updateControls();
233
+ });
234
+ });
235
+
236
+ document.querySelectorAll('.subtype-btn').forEach(btn => {
237
+ btn.addEventListener('click', () => {
238
+ document.querySelectorAll('.subtype-btn')
239
+ .forEach(b => b.classList.remove('active'));
240
+ btn.classList.add('active');
241
+ state.macroSubtype = btn.dataset.subtype;
242
+ updateControls();
243
+ });
244
+ });
245
+
246
+ function updateControls() {
247
+ const isMacro = state.queryType === 'macro';
248
+ const needsModule =
249
+ isMacro && state.macroSubtype === 'module_responsibility';
250
+
251
+ dom.macroSubtypes.classList.toggle('hidden', !isMacro);
252
+ dom.functionInput.classList.toggle('hidden', isMacro);
253
+ dom.classInput.classList.toggle('hidden', isMacro);
254
+ dom.moduleInput.classList.toggle('hidden', !needsModule);
255
+ }
256
+
257
+ // ── Textarea auto-resize ──────────────────────────────────────────────────────
258
+ dom.queryInput.addEventListener('input', () => {
259
+ dom.queryInput.style.height = 'auto';
260
+ dom.queryInput.style.height =
261
+ Math.min(dom.queryInput.scrollHeight, 160) + 'px';
262
+ });
263
+
264
+ dom.queryInput.addEventListener('keydown', e => {
265
+ if (e.key === 'Enter' && !e.shiftKey) {
266
+ e.preventDefault();
267
+ if (!dom.sendBtn.disabled) sendQuery();
268
+ }
269
+ });
270
+
271
+ dom.sendBtn.addEventListener('click', sendQuery);
272
+
273
+ // ── Send Query ────────────────────────────────────────────────────────────────
274
+ async function sendQuery() {
275
+ const query = dom.queryInput.value.trim();
276
+ if (!query || state.querying) return;
277
+
278
+ const badge = [
279
+ state.queryType,
280
+ state.queryType === 'macro'
281
+ ? state.macroSubtype.replace(/_/g, ' ')
282
+ : dom.functionInput.value.trim(),
283
+ ].filter(Boolean).join(' Β· ');
284
+
285
+ appendMessage('user', badge, query);
286
+ dom.queryInput.value = '';
287
+ dom.queryInput.style.height = 'auto';
288
+
289
+ const thinkId = appendThinking();
290
+ state.querying = true;
291
+ dom.sendBtn.disabled = true;
292
+
293
+ try {
294
+ const res = await fetch(`${API}/query`, {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({
298
+ query_type: state.queryType,
299
+ query,
300
+ subtype: state.queryType === 'macro' ? state.macroSubtype : '',
301
+ function_name: dom.functionInput.value.trim(),
302
+ class_name: dom.classInput.value.trim(),
303
+ module_name: dom.moduleInput.value.trim(),
304
+ followup: !!state.lastResponse,
305
+ previous_response: state.lastResponse,
306
+ }),
307
+ });
308
+
309
+ const data = await res.json();
310
+ removeMessage(thinkId);
311
+
312
+ if (!res.ok) throw new Error(data.detail || 'Query failed');
313
+ if (!data.success) throw new Error(data.error || 'No result');
314
+
315
+ state.lastResponse = data.content;
316
+ appendMessage('assistant', buildMetaBadge(data.metadata), data.content);
317
+
318
+ } catch (err) {
319
+ removeMessage(thinkId);
320
+ appendMessage('error', null, `❌ ${err.message}`);
321
+ } finally {
322
+ state.querying = false;
323
+ dom.sendBtn.disabled = false;
324
+ }
325
+ }
326
+
327
+ // ── Messages ──────────────────────────────────────────────────────────────────
328
+ let msgCounter = 0;
329
+
330
+ function appendMessage(role, badge, content) {
331
+ const id = `msg-${++msgCounter}`;
332
+ const div = document.createElement('div');
333
+ div.id = id;
334
+ div.className = `message message--${role}`;
335
+
336
+ const roleLabel = {
337
+ user: 'You', assistant: 'Oracle',
338
+ system: 'System', error: 'Error', thinking: 'Thinking',
339
+ }[role] || role;
340
+
341
+ let html = `<div class="message__meta">
342
+ <span class="message__role">${roleLabel}</span>
343
+ ${badge ? `<span class="message__badge">${badge}</span>` : ''}
344
+ </div>
345
+ <div class="message__content">`;
346
+
347
+ html += role === 'thinking'
348
+ ? `<span class="spinner"></span>${content}`
349
+ : marked.parse(content || '');
350
+
351
+ html += `</div>`;
352
+ div.innerHTML = html;
353
+
354
+ dom.messages.appendChild(div);
355
+ dom.messages.scrollTop = dom.messages.scrollHeight;
356
+ return id;
357
+ }
358
+
359
+ function appendThinking() {
360
+ return appendMessage('thinking', null, 'Analyzing codebase…');
361
+ }
362
+
363
+ function removeMessage(id) {
364
+ document.getElementById(id)?.remove();
365
+ }
366
+
367
+ function buildMetaBadge(meta) {
368
+ if (!meta) return null;
369
+ return [
370
+ meta.query_type,
371
+ meta.function_name,
372
+ meta.chunks_used ? `${meta.chunks_used} chunks` : null,
373
+ meta.context_chars ? `${meta.context_chars} chars` : null,
374
+ ].filter(Boolean).join(' Β· ');
375
+ }
376
+
377
+ // ── Init ──────────────────────────────────────────────────────────────────────
378
+ (async () => {
379
+ try {
380
+ const res = await fetch(`${API}/status`);
381
+ const data = await res.json();
382
+ if (data.indexed) {
383
+ state.indexed = true;
384
+ setStatus('ready', `Ready Β· ${data.total_chunks} chunks`);
385
+ dom.chunkCount.textContent = `${data.total_chunks} chunks`;
386
+ dom.sendBtn.disabled = false;
387
+ loadTree();
388
+ }
389
+ } catch { /* server not up yet β€” stay idle */ }
390
+
391
+ updateControls();
392
+ })();
ui/static/style.css ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Reset & Base ──────────────────────────────────────────────────────────── */
2
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
3
+
4
+ :root {
5
+ --bg-base: #0e0e10;
6
+ --bg-surface: #141416;
7
+ --bg-elevated: #1a1a1e;
8
+ --bg-hover: #202026;
9
+ --bg-active: #25252d;
10
+ --border: #2a2a32;
11
+ --border-focus: #4a90e8;
12
+ --text-primary: #d4d4d8;
13
+ --text-secondary: #71717a;
14
+ --text-muted: #3f3f46;
15
+ --text-bright: #f4f4f5;
16
+ --accent: #4a90e8;
17
+ --accent-dim: #1e3a5f;
18
+ --accent-glow: rgba(74, 144, 232, 0.15);
19
+ --green: #4ade80;
20
+ --yellow: #facc15;
21
+ --red: #f87171;
22
+ --purple: #c084fc;
23
+ --topbar-h: 60px;
24
+ --sidebar-w: 320px;
25
+ --radius: 6px;
26
+ --radius-lg: 10px;
27
+ --font-ui: 'Syne', sans-serif;
28
+ --font-mono: 'JetBrains Mono', monospace;
29
+ --transition: 150ms ease;
30
+ }
31
+
32
+ html, body {
33
+ height: 100%;
34
+ overflow: hidden;
35
+ background: var(--bg-base);
36
+ color: var(--text-primary);
37
+ font-family: var(--font-ui);
38
+ font-size: 16px;
39
+ line-height: 1.6;
40
+ -webkit-font-smoothing: antialiased;
41
+ }
42
+
43
+ ::-webkit-scrollbar { width: 5px; }
44
+ ::-webkit-scrollbar-track { background: transparent; }
45
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
46
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
47
+
48
+ /* ── Topbar ────────────────────────────────────────────────────────────────── */
49
+ .topbar {
50
+ position: fixed;
51
+ top: 0; left: 0; right: 0;
52
+ height: var(--topbar-h);
53
+ background: var(--bg-surface);
54
+ border-bottom: 1px solid var(--border);
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 16px;
58
+ padding: 0 20px;
59
+ z-index: 100;
60
+ }
61
+
62
+ .topbar__brand {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 8px;
66
+ flex-shrink: 0;
67
+ }
68
+
69
+ .topbar__icon {
70
+ font-size: 20px;
71
+ color: var(--accent);
72
+ animation: pulse 3s ease-in-out infinite;
73
+ }
74
+
75
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
76
+
77
+ .topbar__title {
78
+ font-weight: 800;
79
+ font-size: 15px;
80
+ letter-spacing: 0.04em;
81
+ color: var(--text-bright);
82
+ white-space: nowrap;
83
+ }
84
+
85
+ .topbar__index {
86
+ flex: 1;
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 8px;
90
+ max-width: 680px;
91
+ }
92
+
93
+ .path-input {
94
+ flex: 1;
95
+ height: 34px;
96
+ background: var(--bg-base);
97
+ border: 1px solid var(--border);
98
+ border-radius: var(--radius);
99
+ color: var(--text-primary);
100
+ font-family: var(--font-mono);
101
+ font-size: 12px;
102
+ padding: 0 12px;
103
+ outline: none;
104
+ transition: border-color var(--transition), box-shadow var(--transition);
105
+ }
106
+
107
+ .path-input:focus {
108
+ border-color: var(--border-focus);
109
+ box-shadow: 0 0 0 3px var(--accent-glow);
110
+ }
111
+
112
+ .path-input::placeholder { color: var(--text-muted); }
113
+
114
+ /* ── Buttons ───────────────────────────────────────────────────────────────── */
115
+ .btn {
116
+ display: inline-flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ gap: 6px;
120
+ border: none;
121
+ border-radius: var(--radius);
122
+ cursor: pointer;
123
+ font-family: var(--font-ui);
124
+ font-weight: 600;
125
+ font-size: 13px;
126
+ transition: background var(--transition), transform var(--transition);
127
+ white-space: nowrap;
128
+ outline: none;
129
+ }
130
+
131
+ .btn:active:not(:disabled) { transform: scale(0.97); }
132
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
133
+
134
+ .btn--primary {
135
+ height: 34px;
136
+ padding: 0 18px;
137
+ background: var(--accent);
138
+ color: #fff;
139
+ }
140
+ .btn--primary:hover:not(:disabled) { background: #3b7dd8; }
141
+
142
+ .btn--send {
143
+ width: 40px; height: 40px;
144
+ background: var(--accent);
145
+ color: #fff;
146
+ border-radius: var(--radius);
147
+ flex-shrink: 0;
148
+ }
149
+ .btn--send:hover:not(:disabled) { background: #3b7dd8; }
150
+
151
+ /* ── Status Badge ──────────────────────────────────────────────────────────── */
152
+ .status-badge {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 6px;
156
+ padding: 4px 12px;
157
+ border-radius: 999px;
158
+ font-size: 11px;
159
+ font-weight: 600;
160
+ letter-spacing: 0.05em;
161
+ white-space: nowrap;
162
+ flex-shrink: 0;
163
+ border: 1px solid transparent;
164
+ font-family: var(--font-mono);
165
+ }
166
+
167
+ .status-badge__dot {
168
+ width: 6px; height: 6px;
169
+ border-radius: 50%;
170
+ }
171
+
172
+ .status-badge--idle { background: var(--bg-elevated); border-color: var(--border); color: var(--text-secondary); }
173
+ .status-badge--idle .status-badge__dot { background: var(--text-muted); }
174
+
175
+ .status-badge--indexing { background: rgba(250,204,21,.08); border-color: rgba(250,204,21,.3); color: var(--yellow); }
176
+ .status-badge--indexing .status-badge__dot { background: var(--yellow); animation: blink .8s ease-in-out infinite; }
177
+
178
+ .status-badge--ready { background: rgba(74,222,128,.08); border-color: rgba(74,222,128,.3); color: var(--green); }
179
+ .status-badge--ready .status-badge__dot { background: var(--green); }
180
+
181
+ .status-badge--error { background: rgba(248,113,113,.08); border-color: rgba(248,113,113,.3); color: var(--red); }
182
+ .status-badge--error .status-badge__dot { background: var(--red); }
183
+
184
+ @keyframes blink { 0%,100% { opacity:1; } 50% { opacity:.2; } }
185
+
186
+ /* ── Workspace ─────────────────────────────────────────────────────────────── */
187
+ .workspace {
188
+ display: flex;
189
+ position: fixed;
190
+ top: var(--topbar-h); left: 0; right: 0; bottom: 0;
191
+ }
192
+
193
+ /* ── Sidebar ───────────────────────────────────────────────────────────────── */
194
+ .sidebar {
195
+ width: var(--sidebar-w);
196
+ flex-shrink: 0;
197
+ background: var(--bg-surface);
198
+ border-right: 1px solid var(--border);
199
+ display: flex;
200
+ flex-direction: column;
201
+ overflow: hidden;
202
+ }
203
+
204
+ .sidebar__header {
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: space-between;
208
+ padding: 12px 16px;
209
+ border-bottom: 1px solid var(--border);
210
+ flex-shrink: 0;
211
+ }
212
+
213
+ .sidebar__label {
214
+ font-size: 10px;
215
+ font-weight: 700;
216
+ letter-spacing: 0.12em;
217
+ text-transform: uppercase;
218
+ color: var(--text-secondary);
219
+ }
220
+
221
+ .sidebar__count {
222
+ font-family: var(--font-mono);
223
+ font-size: 10px;
224
+ color: var(--text-muted);
225
+ }
226
+
227
+ .tree-container {
228
+ flex: 1;
229
+ overflow-y: auto;
230
+ padding: 8px 0;
231
+ }
232
+
233
+ .tree-placeholder {
234
+ display: flex;
235
+ flex-direction: column;
236
+ align-items: center;
237
+ justify-content: center;
238
+ height: 100%;
239
+ gap: 12px;
240
+ padding: 40px 20px;
241
+ text-align: center;
242
+ }
243
+
244
+ .tree-placeholder__icon { font-size: 32px; color: var(--text-muted); opacity: .5; }
245
+ .tree-placeholder p { font-size: 12px; color: var(--text-muted); line-height: 1.6; }
246
+
247
+ /* ── Tree Nodes ────────────────────────────────────────────────────────────── */
248
+ .tree-node { user-select: none; }
249
+
250
+ .tree-node__row {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 5px;
254
+ padding: 3px 8px;
255
+ cursor: pointer;
256
+ border-radius: 4px;
257
+ margin: 0 6px;
258
+ transition: background var(--transition);
259
+ min-height: 24px;
260
+ }
261
+
262
+ .tree-node__row:hover { background: var(--bg-hover); }
263
+ .tree-node__row.selected { background: var(--accent-dim); }
264
+
265
+ .tree-node__toggle {
266
+ width: 14px; text-align: center;
267
+ font-size: 9px;
268
+ color: var(--text-muted);
269
+ flex-shrink: 0;
270
+ transition: transform var(--transition);
271
+ }
272
+ .tree-node__toggle.open { transform: rotate(90deg); }
273
+
274
+ .tree-node__icon { font-size: 12px; flex-shrink: 0; }
275
+ .tree-node__name {
276
+ font-family: var(--font-mono);
277
+ font-size: 13px;
278
+ color: var(--text-primary);
279
+ overflow: hidden;
280
+ text-overflow: ellipsis;
281
+ white-space: nowrap;
282
+ }
283
+
284
+ .tree-node__children { display: none; }
285
+ .tree-node__children.open { display: block; }
286
+
287
+ .tree-node[data-depth="1"] .tree-node__row { padding-left: 20px; }
288
+ .tree-node[data-depth="2"] .tree-node__row { padding-left: 34px; }
289
+ .tree-node[data-depth="3"] .tree-node__row { padding-left: 48px; }
290
+
291
+ .icon-module { color: var(--yellow); }
292
+ .icon-file { color: #60a5fa; }
293
+ .icon-class { color: var(--purple); }
294
+ .icon-function { color: var(--green); }
295
+
296
+ /* ── Chat ──────────────────────────────────────────────────────────────────── */
297
+ .chat {
298
+ flex: 1;
299
+ display: flex;
300
+ flex-direction: column;
301
+ overflow: hidden;
302
+ }
303
+
304
+ /* ── Query Controls ────────────────────────────────────────────────────────── */
305
+ .query-controls {
306
+ display: flex;
307
+ flex-direction: column;
308
+ gap: 10px;
309
+ padding: 14px 20px;
310
+ border-bottom: 1px solid var(--border);
311
+ background: var(--bg-surface);
312
+ flex-shrink: 0;
313
+ }
314
+
315
+ .query-type-group, .macro-subtypes {
316
+ display: flex;
317
+ gap: 4px;
318
+ flex-wrap: wrap;
319
+ }
320
+
321
+ .query-type-btn, .subtype-btn {
322
+ padding: 7px 18px;
323
+ border-radius: 999px;
324
+ border: 1px solid var(--border);
325
+ background: transparent;
326
+ color: var(--text-secondary);
327
+ font-family: var(--font-ui);
328
+ font-size: 13px;
329
+ font-weight: 600;
330
+ cursor: pointer;
331
+ transition: all var(--transition);
332
+ letter-spacing: 0.02em;
333
+ }
334
+
335
+ .query-type-btn:hover, .subtype-btn:hover {
336
+ border-color: var(--accent);
337
+ color: var(--text-primary);
338
+ }
339
+
340
+ .query-type-btn.active, .subtype-btn.active {
341
+ background: var(--accent);
342
+ border-color: var(--accent);
343
+ color: #fff;
344
+ }
345
+
346
+ .context-inputs {
347
+ display: flex;
348
+ gap: 8px;
349
+ flex-wrap: wrap;
350
+ }
351
+
352
+ .context-field {
353
+ height: 30px;
354
+ background: var(--bg-base);
355
+ border: 1px solid var(--border);
356
+ border-radius: var(--radius);
357
+ color: var(--text-primary);
358
+ font-family: var(--font-mono);
359
+ font-size: 12px;
360
+ padding: 0 10px;
361
+ outline: none;
362
+ min-width: 160px;
363
+ transition: border-color var(--transition), box-shadow var(--transition);
364
+ }
365
+
366
+ .context-field:focus {
367
+ border-color: var(--border-focus);
368
+ box-shadow: 0 0 0 3px var(--accent-glow);
369
+ }
370
+
371
+ .context-field::placeholder { color: var(--text-muted); }
372
+
373
+ /* ── Messages ──────────────────────────────────────────────────────────────── */
374
+ .messages {
375
+ flex: 1;
376
+ overflow-y: auto;
377
+ padding: 24px 20px;
378
+ display: flex;
379
+ flex-direction: column;
380
+ gap: 20px;
381
+ }
382
+
383
+ .message {
384
+ display: flex;
385
+ flex-direction: column;
386
+ gap: 6px;
387
+ animation: fadeSlideIn 200ms ease both;
388
+ }
389
+
390
+ @keyframes fadeSlideIn {
391
+ from { opacity: 0; transform: translateY(8px); }
392
+ to { opacity: 1; transform: translateY(0); }
393
+ }
394
+
395
+ .message__meta {
396
+ display: flex;
397
+ align-items: center;
398
+ gap: 8px;
399
+ }
400
+
401
+ .message__role {
402
+ font-size: 10px;
403
+ font-weight: 700;
404
+ letter-spacing: 0.1em;
405
+ text-transform: uppercase;
406
+ font-family: var(--font-mono);
407
+ }
408
+
409
+ .message--user .message__role { color: var(--accent); }
410
+ .message--system .message__role { color: var(--text-muted); }
411
+ .message--assistant .message__role { color: var(--green); }
412
+ .message--error .message__role { color: var(--red); }
413
+ .message--thinking .message__role { color: var(--yellow); }
414
+
415
+ .message__badge {
416
+ font-size: 10px;
417
+ font-family: var(--font-mono);
418
+ padding: 2px 8px;
419
+ border-radius: 999px;
420
+ background: var(--bg-elevated);
421
+ color: var(--text-muted);
422
+ border: 1px solid var(--border);
423
+ }
424
+
425
+ .message__content {
426
+ background: var(--bg-elevated);
427
+ border: 1px solid var(--border);
428
+ border-radius: var(--radius-lg);
429
+ padding: 14px 18px;
430
+ line-height: 1.7;
431
+ }
432
+
433
+ .message--user .message__content {
434
+ background: var(--accent-dim);
435
+ border-color: rgba(74,144,232,.3);
436
+ align-self: flex-end;
437
+ max-width: 80%;
438
+ }
439
+
440
+ .message--error .message__content {
441
+ background: rgba(248,113,113,.06);
442
+ border-color: rgba(248,113,113,.25);
443
+ color: var(--red);
444
+ }
445
+
446
+ .message--thinking .message__content {
447
+ color: var(--text-muted);
448
+ font-family: var(--font-mono);
449
+ font-size: 12px;
450
+ display: flex;
451
+ align-items: center;
452
+ gap: 10px;
453
+ }
454
+
455
+ /* ── Markdown ──────────────────────────────────────────────────────────────── */
456
+ .message__content h1,
457
+ .message__content h2,
458
+ .message__content h3 {
459
+ font-family: var(--font-ui);
460
+ font-weight: 700;
461
+ color: var(--text-bright);
462
+ margin-top: 20px;
463
+ margin-bottom: 8px;
464
+ line-height: 1.3;
465
+ }
466
+ .message__content h1 { font-size: 18px; }
467
+ .message__content h2 { font-size: 15px; color: var(--accent); }
468
+ .message__content h3 { font-size: 13px; }
469
+ .message__content h1:first-child,
470
+ .message__content h2:first-child { margin-top: 0; }
471
+
472
+ .message__content p { margin-bottom: 10px; font-size: 15px; }
473
+ .message__content p:last-child { margin-bottom: 0; }
474
+
475
+ .message__content ul,
476
+ .message__content ol { padding-left: 20px; margin-bottom: 10px; }
477
+ .message__content li { margin-bottom: 4px; font-size: 15px; }
478
+
479
+ .message__content strong { color: var(--text-bright); font-weight: 700; }
480
+ .message__content em { color: var(--text-secondary); }
481
+
482
+ .message__content code {
483
+ font-family: var(--font-mono);
484
+ font-size: 12px;
485
+ background: var(--bg-active);
486
+ border: 1px solid var(--border);
487
+ border-radius: 4px;
488
+ padding: 1px 6px;
489
+ color: var(--purple);
490
+ }
491
+
492
+ .message__content pre {
493
+ background: #0d0d10 !important;
494
+ border: 1px solid var(--border);
495
+ border-radius: var(--radius);
496
+ padding: 14px 16px;
497
+ overflow-x: auto;
498
+ margin: 12px 0;
499
+ }
500
+ .message__content pre code {
501
+ background: none; border: none; padding: 0;
502
+ color: var(--text-primary); font-size: 12px;
503
+ }
504
+
505
+ .message__content table {
506
+ width: 100%;
507
+ border-collapse: collapse;
508
+ margin: 12px 0;
509
+ font-size: 12.5px;
510
+ font-family: var(--font-mono);
511
+ }
512
+ .message__content th {
513
+ background: var(--bg-active);
514
+ color: var(--text-bright);
515
+ font-weight: 700;
516
+ padding: 8px 12px;
517
+ text-align: left;
518
+ border: 1px solid var(--border);
519
+ }
520
+ .message__content td {
521
+ padding: 7px 12px;
522
+ border: 1px solid var(--border);
523
+ }
524
+ .message__content tr:hover td { background: var(--bg-hover); }
525
+
526
+ .message__content blockquote {
527
+ border-left: 3px solid var(--accent);
528
+ padding-left: 14px;
529
+ margin: 10px 0;
530
+ color: var(--text-secondary);
531
+ font-style: italic;
532
+ }
533
+
534
+ .message__content hr {
535
+ border: none;
536
+ border-top: 1px solid var(--border);
537
+ margin: 16px 0;
538
+ }
539
+
540
+ /* ── Input Bar ─────────────────────────────────────────────────────────────── */
541
+ .input-bar {
542
+ display: flex;
543
+ align-items: flex-end;
544
+ gap: 10px;
545
+ padding: 14px 20px;
546
+ border-top: 1px solid var(--border);
547
+ background: var(--bg-surface);
548
+ flex-shrink: 0;
549
+ }
550
+
551
+ .query-textarea {
552
+ flex: 1;
553
+ background: var(--bg-base);
554
+ border: 1px solid var(--border);
555
+ border-radius: var(--radius);
556
+ color: var(--text-primary);
557
+ font-family: var(--font-mono);
558
+ font-size: 15px;
559
+ padding: 10px 14px;
560
+ resize: none;
561
+ outline: none;
562
+ min-height: 40px;
563
+ max-height: 160px;
564
+ overflow-y: auto;
565
+ line-height: 1.5;
566
+ transition: border-color var(--transition), box-shadow var(--transition);
567
+ }
568
+
569
+ .query-textarea:focus {
570
+ border-color: var(--border-focus);
571
+ box-shadow: 0 0 0 3px var(--accent-glow);
572
+ }
573
+
574
+ .query-textarea::placeholder { color: var(--text-muted); }
575
+
576
+ /* ── Spinner ───────────────────────────────────────────────────────────────── */
577
+ .spinner {
578
+ width: 14px; height: 14px;
579
+ border: 2px solid rgba(255,255,255,0.3);
580
+ border-top-color: #fff;
581
+ border-radius: 50%;
582
+ animation: spin .6s linear infinite;
583
+ }
584
+
585
+ @keyframes spin { to { transform: rotate(360deg); } }
586
+
587
+ .hidden { display: none !important; }