File size: 15,332 Bytes
944f820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
"""
main.py
-------
FastAPI backend for the Codebase Oracle system.
This is the HTTP layer β€” thin wrapper around inference.py.

Endpoints:
    POST /index          β€” ingest + embed a codebase from given path
    POST /query          β€” run a query (macro / micro / cross_module)
    GET  /status         β€” check if a codebase is indexed and ready
    GET  /tree           β€” return parsed codebase tree for UI sidebar
    GET  /health         β€” simple health check

Run:
    uvicorn main:app --reload --port 8000

Depends on:
    - inference.py
    - embedder.py
    - call_graph.py
    - ast_parser.py
    - vector_store.py
    - fastapi, uvicorn, pydantic, python-dotenv
"""

import os
from contextlib import asynccontextmanager
from dotenv import load_dotenv

import tempfile
import zipfile
import shutil
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from rich.console import Console

from inference.inference import get_engine, InferenceRequest
from ingest.embed import embed_codebase
from store.call_graph import build_and_save, get_call_graph, CALL_GRAPH_PATH
from ingest.parse_ast import parse_codebase
from store.vector_store import get_vector_store

load_dotenv()

console = Console()

# ── App Lifespan ──────────────────────────────────────────────────────────────

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Initialize shared resources on startup."""
    console.rule("[bold cyan]Codebase Oracle β€” Starting[/bold cyan]")
    # Pre-warm the inference engine (loads embedding model once)
    get_engine()
    console.print("[green]βœ”[/green] Server ready.\n")
    yield
    console.print("[dim]Server shutting down.[/dim]")


# ── App ───────────────────────────────────────────────────────────────────────

app = FastAPI(
    title="Codebase Oracle",
    description="AI-powered monolithic codebase comprehension system.",
    version="1.0.0",
    lifespan=lifespan,
)

# Allow UI (served from same origin or localhost dev)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# Serve UI static files
UI_DIR = os.path.join(os.path.dirname(__file__), "ui")
if os.path.exists(UI_DIR):
    app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui")
    app.mount("/static", StaticFiles(directory=os.path.join(UI_DIR, "static")), name="static")


# ── Pydantic Request / Response Models ────────────────────────────────────────

class IndexRequest(BaseModel):
    """Request body for POST /index"""
    root_path: str = Field(
        ...,
        description="Absolute path to the monolithic codebase root directory.",
        example="/home/user/projects/my-django-app"
    )


class QueryRequest(BaseModel):
    """Request body for POST /query"""
    query_type: str = Field(
        ...,
        description="One of: 'macro', 'micro', 'cross_module'",
        example="micro"
    )
    query: str = Field(
        ...,
        description="Natural language developer query.",
        example="What does process_payment do and how do I use it?"
    )
    subtype: str = Field(
        default="",
        description="Macro subtype: 'overall_architecture' | 'module_responsibility' | 'data_flow'",
        example="overall_architecture"
    )
    function_name: str = Field(
        default="",
        description="Target function/method name for micro and cross_module queries.",
        example="process_payment"
    )
    class_name: str = Field(
        default="",
        description="Target class name if function is a method.",
        example="PaymentProcessor"
    )
    module_name: str = Field(
        default="",
        description="Target module name for macro module_responsibility queries.",
        example="payments"
    )
    followup: bool = Field(
        default=False,
        description="True if this is a follow-up to a previous response."
    )
    previous_response: str = Field(
        default="",
        description="Previous LLM response for follow-up context."
    )


class IndexResponse(BaseModel):
    success:       bool
    message:       str
    class_chunks:  int = 0
    function_chunks: int = 0
    total_chunks:  int = 0
    graph_nodes:   int = 0
    graph_edges:   int = 0


class QueryResponse(BaseModel):
    success:  bool
    content:  str
    error:    str = ""
    metadata: dict = {}


class StatusResponse(BaseModel):
    indexed:          bool
    class_chunks:     int
    function_chunks:  int
    total_chunks:     int
    graph_loaded:     bool
    graph_nodes:      int


class TreeNode(BaseModel):
    name:     str
    type:     str           # "module" | "file" | "class" | "function"
    children: list["TreeNode"] = []

TreeNode.model_rebuild()


class TreeResponse(BaseModel):
    success: bool
    tree:    list[TreeNode] = []
    error:   str = ""


# ── Endpoints ─────────────────────────────────────────────────────────────────

@app.post("/upload-index", response_model=IndexResponse)
async def upload_index(file: UploadFile = File(...)):
    """
    Accept a ZIP file, extract it to a temp directory, and index it.
    Allows deployment without requiring local filesystem access.
    """
    if not file.filename.endswith(".zip"):
        raise HTTPException(status_code=400, detail="Only .zip files are accepted.")

    tmp_dir = tempfile.mkdtemp()

    try:
        zip_path = os.path.join(tmp_dir, file.filename)
        with open(zip_path, "wb") as f:
            shutil.copyfileobj(file.file, f)

        with zipfile.ZipFile(zip_path, "r") as zf:
            zf.extractall(tmp_dir)

        os.remove(zip_path)

        # Find the extracted root β€” skip __MACOSX and similar artifacts
        candidates = [
            os.path.join(tmp_dir, d)
            for d in os.listdir(tmp_dir)
            if os.path.isdir(os.path.join(tmp_dir, d)) and not d.startswith("__")
        ]
        root = candidates[0] if candidates else tmp_dir

        console.rule(f"[bold cyan]Indexing ZIP: {file.filename}[/bold cyan]")

        embed_codebase(root)

        graph = build_and_save(root)
        graph_stats = graph.stats()

        store = get_vector_store()
        vstats = store.stats()

        console.print("[bold green]βœ” ZIP Indexing complete.[/bold green]\n")

        return IndexResponse(
            success=True,
            message=f"ZIP indexed successfully: {file.filename}",
            class_chunks=vstats["class_chunks"],
            function_chunks=vstats["function_chunks"],
            total_chunks=vstats["total"],
            graph_nodes=graph_stats["total_nodes"],
            graph_edges=graph_stats["total_edges"],
        )

    except zipfile.BadZipFile:
        raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file.")

    except Exception as e:
        console.print(f"[red]❌ ZIP indexing failed: {e}[/red]")
        raise HTTPException(status_code=500, detail=f"ZIP indexing failed: {str(e)}")

    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)

@app.get("/health")
async def health():
    """Simple health check."""
    return {"status": "ok", "service": "Codebase Oracle"}


@app.get("/", response_class=FileResponse)
async def serve_ui():
    """Serve the UI index.html at root."""
    ui_path = os.path.join(UI_DIR, "index.html")
    if not os.path.exists(ui_path):
        raise HTTPException(
            status_code=404,
            detail="UI not found. Place index.html in the ui/ directory."
        )
    return FileResponse(ui_path)


@app.post("/index", response_model=IndexResponse)
async def index_codebase(req: IndexRequest):
    """
    Ingest, parse, embed, and index a monolithic codebase.
    Builds both ChromaDB vector index and call_graph.json.

    This is the first endpoint to call before any queries.
    """
    root = req.root_path.strip()

    if not os.path.exists(root):
        raise HTTPException(
            status_code=400,
            detail=f"Path does not exist: {root}"
        )

    if not os.path.isdir(root):
        raise HTTPException(
            status_code=400,
            detail=f"Path is not a directory: {root}"
        )

    try:
        console.rule(f"[bold cyan]Indexing: {root}[/bold cyan]")

        # Step 1 β€” Embed codebase into ChromaDB
        embed_codebase(root)

        # Step 2 β€” Build and save call graph
        graph = build_and_save(root)
        graph_stats = graph.stats()

        # Step 3 β€” Fetch vector store stats
        store = get_vector_store()
        vstats = store.stats()

        console.print("[bold green]βœ” Indexing complete.[/bold green]\n")

        return IndexResponse(
            success=True,
            message=f"Codebase indexed successfully: {root}",
            class_chunks=vstats["class_chunks"],
            function_chunks=vstats["function_chunks"],
            total_chunks=vstats["total"],
            graph_nodes=graph_stats["total_nodes"],
            graph_edges=graph_stats["total_edges"],
        )

    except Exception as e:
        console.print(f"[red]❌ Indexing failed: {e}[/red]")
        raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}")


@app.post("/query", response_model=QueryResponse)
async def query(req: QueryRequest):
    """
    Run a macro, micro, or cross-module query against the indexed codebase.
    Returns a markdown-formatted response string.
    """
    store = get_vector_store()
    if not store.is_indexed():
        raise HTTPException(
            status_code=400,
            detail="Codebase is not indexed yet. Call POST /index first."
        )

    engine = get_engine()

    inference_req = InferenceRequest(
        query_type=req.query_type,
        query=req.query,
        subtype=req.subtype,
        function_name=req.function_name,
        class_name=req.class_name,
        module_name=req.module_name,
        followup=req.followup,
        previous_response=req.previous_response,
    )

    resp = engine.infer(inference_req)

    return QueryResponse(
        success=resp.success,
        content=resp.content,
        error=resp.error,
        metadata=resp.metadata,
    )


@app.get("/status", response_model=StatusResponse)
async def status():
    """
    Check whether the codebase is indexed and the system is ready for queries.
    """
    store  = get_vector_store()
    vstats = store.stats()

    graph_loaded = False
    graph_nodes  = 0

    if os.path.exists(CALL_GRAPH_PATH):
        try:
            graph = get_call_graph()
            graph_loaded = graph.is_loaded()
            graph_nodes  = graph.stats()["total_nodes"]
        except Exception:
            pass

    return StatusResponse(
        indexed=store.is_indexed(),
        class_chunks=vstats["class_chunks"],
        function_chunks=vstats["function_chunks"],
        total_chunks=vstats["total"],
        graph_loaded=graph_loaded,
        graph_nodes=graph_nodes,
    )


@app.get("/tree", response_model=TreeResponse)
async def get_tree():
    """
    Return the parsed codebase structure as a nested tree.
    Used by the UI sidebar to render the codebase explorer.
    """
    store = get_vector_store()
    if not store.is_indexed():
        return TreeResponse(
            success=False,
            error="Codebase not indexed yet. Call POST /index first."
        )

    try:
        # Fetch both class and function chunks to reconstruct tree
        class_results = store.get_all("class_chunks", limit=500)
        func_results = store.get_all("function_chunks", limit=500)

        # Group by module β†’ file β†’ classes/functions
        modules: dict[str, dict[str, dict[str, set]]] = {}

        # --- classes ---
        for chunk in class_results:
            mod = chunk.module
            file = chunk.file
            modules.setdefault(mod, {}).setdefault(file, {"classes": set(), "functions": set()})
            modules[mod][file]["classes"].add(chunk.name)

        # --- functions (top-level only) ---
        for chunk in func_results:
            if not chunk.class_name:
                mod = chunk.module
                file = chunk.file
                modules.setdefault(mod, {}).setdefault(file, {"classes": set(), "functions": set()})
                modules[mod][file]["functions"].add(chunk.name)

        # Also fetch function chunks for top-level functions
        func_results = store.get_all("function_chunks", limit=500)
        func_by_file: dict[str, list[str]] = {}
        for chunk in func_results:
            if not chunk.class_name:  # top-level only
                func_by_file.setdefault(chunk.file, []).append(chunk.name)

                # Build tree structure

                all_files = set()
                for files in modules.values():
                    for file_path in files:
                        all_files.add(file_path)

                # Derive root directory name from common first path component
                first_parts = [f.split("/")[0] for f in all_files if "/" in f]
                root_name = first_parts[0] if first_parts else "codebase"

                root_node = TreeNode(name=root_name, type="module")

                for module_name, files in sorted(modules.items()):
                    module_node = TreeNode(name=module_name, type="module")

                    for file_path, content in sorted(files.items()):
                        file_node = TreeNode(
                            name=os.path.basename(file_path),
                            type="file"
                        )

                        for cls_name in sorted(content["classes"]):
                            file_node.children.append(
                                TreeNode(name=cls_name, type="class")
                            )

                        for fn_name in sorted(content["functions"]):
                            file_node.children.append(
                                TreeNode(name=fn_name, type="function")
                            )

                        module_node.children.append(file_node)

                    root_node.children.append(module_node)

                return TreeResponse(success=True, tree=[root_node])

    except Exception as e:
        console.print(f"[red]❌ Tree build failed: {e}[/red]")
        return TreeResponse(success=False, error=str(e))


# ── Entry Point ───────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        "main:app",
        port=8000,
        reload=True,
    )