Karush2807 commited on
Commit
db284b9
·
1 Parent(s): 6662c95

TraceRAG backend + demo graphs

Browse files
.dockerignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keep the image lean — but DO ship graphs/ (the read-only demo data).
2
+ __pycache__/
3
+ **/__pycache__/
4
+ *.pyc
5
+ .env
6
+ .env.*
7
+ .venv/
8
+ venv/
9
+ # scratch / local-only DBs (the demo graphs live in graphs/ and are kept)
10
+ memory.lbug
11
+ memory.lbug.*
12
+ _*.lbug
13
+ *.lbug.wal
14
+ *.lbug-shm
15
+ # debug + result artifacts
16
+ _*.out.txt
17
+ results*.csv
18
+ backups/
19
+ .pytest_cache/
20
+ # offline ingestion inputs — not needed by the running server (114 MB CSV etc.)
21
+ datasets/
22
+ Dockerfile
23
+ .dockerignore
.env.example ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env and fill in. The real .env is gitignored.
2
+ OPENROUTER_API_KEY=sk-or-v1-your_key_here
3
+
4
+ # Optional:
5
+ # OPENROUTER_MODEL=anthropic/claude-3-haiku
6
+ # OPENROUTER_MODEL=google/gemini-flash-1.5
7
+
8
+ # Neon Postgres (chat history + trace logs). Distinct from the .lbug RAG store.
9
+ # Use the pooled connection string from the Neon dashboard; keep sslmode=require.
10
+ APP_DATABASE_URL=postgresql://USER:PASSWORD@HOST/DB?sslmode=require
11
+
12
+ # Sentry error tracking + performance monitoring (optional; unset = disabled).
13
+ # DSN: Sentry → Project → Settings → Client Keys (DSN).
14
+ # SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
15
+ # SENTRY_ENVIRONMENT=production
16
+ # SENTRY_TRACES_SAMPLE_RATE=0.1 # dev=1.0; scale down in prod to cap cost/overhead
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.lbug filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TraceRAG backend — Hugging Face Spaces (Docker SDK), single warm instance.
2
+ FROM python:3.11-slim
3
+
4
+ # Build deps for any source-built wheels (as root).
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ build-essential && \
7
+ rm -rf /var/lib/apt/lists/*
8
+
9
+ # HF Spaces convention: run as uid 1000 with a writable home.
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH \
14
+ PYTHONUNBUFFERED=1 \
15
+ HF_HOME=/home/user/.cache/huggingface \
16
+ TRACERAG_DB_PATH=/home/user/app/graphs/fastapi__fastapi.lbug
17
+ WORKDIR /home/user/app
18
+
19
+ # Python deps first so this layer caches across code changes.
20
+ # Install CPU-only torch up front — otherwise sentence-transformers pulls the
21
+ # CUDA build (~1 GB of GPU libs) that's useless on CPU-only Spaces.
22
+ COPY --chown=user requirements.txt .
23
+ RUN pip install --no-cache-dir --upgrade pip && \
24
+ pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu && \
25
+ pip install --no-cache-dir -r requirements.txt
26
+
27
+ # Pre-pull the runtime models so the first request isn't a cold download:
28
+ # MiniLM embedder + spaCy small model. GLiNER is ingest-only, so it is skipped.
29
+ RUN python -m spacy download en_core_web_sm && \
30
+ python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')"
31
+
32
+ # App code + the baked-in, read-only demo graphs.
33
+ COPY --chown=user . .
34
+
35
+ EXPOSE 7860
36
+ # One worker: the in-memory rate limiter + single-file LadybugDB require it.
37
+ CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md CHANGED
@@ -1,11 +1,19 @@
1
  ---
2
- title: TraceRAG Backend
3
- emoji: 🐨
4
- colorFrom: green
5
  colorTo: purple
6
  sdk: docker
 
7
  pinned: false
8
- short_description: observability tool using knowledge graph RAG
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: TraceRAG API
3
+ emoji: 🐞
4
+ colorFrom: indigo
5
  colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
 
9
  ---
10
 
11
+ # TraceRAG Backend API
12
+
13
+ GraphRAG retrieval engine (FastAPI + LadybugDB). This Space serves the backend
14
+ for the [TraceRAG Studio](https://github.com/Kcodess2807/GraphOps-Studio) frontend.
15
+
16
+ Runs as a single warm container (Docker SDK). The demo graphs are baked into the
17
+ image; secrets (LLM keys, Postgres URL, Clerk) are supplied via Space **Settings → Secrets**.
18
+
19
+ See `../DEPLOY.md` in the repo for the full deployment guide.
api.py ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI backend for the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import asdict
9
+ from pathlib import Path
10
+
11
+ from fastapi import Depends, FastAPI, HTTPException, Request
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import StreamingResponse
14
+ from pydantic import BaseModel
15
+ from slowapi.errors import RateLimitExceeded
16
+ from slowapi import _rate_limit_exceeded_handler
17
+
18
+ from tracerag import config
19
+ from tracerag.db import TraceDB
20
+ from tracerag.router import TraceRouter, shutdown_embed_executor
21
+ from tracerag.integrations.langchain import format_page_content
22
+
23
+ from auth import get_current_user
24
+ from cache import LRUCache
25
+ from database import init_db
26
+ from ratelimit import limiter, LLM_RATE_LIMIT
27
+ from routers import history
28
+
29
+ logging.basicConfig(level=logging.INFO)
30
+ logger = logging.getLogger("tracerag.api")
31
+
32
+ # Error tracking + performance monitoring. No-op when SENTRY_DSN is unset, so
33
+ # dev runs are unaffected. Initialized before the app is created so Sentry's
34
+ # auto-enabled FastAPI/Starlette/asyncio/threading integrations hook in — the
35
+ # asyncio + threading ones are what capture errors raised inside our async
36
+ # routes and the embed ThreadPoolExecutor (they re-raise via future.result()).
37
+ if config.SENTRY_ENABLED:
38
+ import sentry_sdk
39
+
40
+ sentry_sdk.init(
41
+ dsn=config.SENTRY_DSN,
42
+ environment=config.SENTRY_ENVIRONMENT,
43
+ traces_sample_rate=config.SENTRY_TRACES_SAMPLE_RATE,
44
+ send_default_pii=False, # don't ship request bodies / auth headers to Sentry
45
+ )
46
+ logger.info(
47
+ "Sentry enabled (env=%s, traces_sample_rate=%.2f)",
48
+ config.SENTRY_ENVIRONMENT, config.SENTRY_TRACES_SAMPLE_RATE,
49
+ )
50
+
51
+ _state: dict = {}
52
+
53
+
54
+ @asynccontextmanager
55
+ async def lifespan(app: FastAPI):
56
+ """Startup: open the DB + warm models. Shutdown: drain pools, flush to disk."""
57
+ # ---- startup --------------------------------------------------------------
58
+ db = TraceDB(config.DB_PATH)
59
+ db.init_schema()
60
+ _state["db"] = db
61
+ _state["db_path"] = str(config.DB_PATH)
62
+ _state["router"] = TraceRouter(db)
63
+
64
+ # Cold-boot warmup: pull model weights into RAM and spin up the embed-executor
65
+ # threads + spaCy pipeline, so the first real UI query skips the cold-start
66
+ # latency spike. Runs off the event loop and never blocks boot on failure.
67
+ try:
68
+ await asyncio.to_thread(_state["router"].warm)
69
+ logger.info("Warmup complete (embedder + executor threads + spaCy primed).")
70
+ except Exception as exc: # noqa: BLE001 — warmup is best-effort
71
+ logger.warning("Router warmup skipped (%s).", exc)
72
+
73
+ # history is optional; api still boots without it
74
+ try:
75
+ init_db()
76
+ except Exception as exc: # noqa: BLE001
77
+ logger.warning("Postgres history disabled (%s).", exc)
78
+
79
+ logger.info("TraceRAG STUDIO API ready (db=%s)", config.DB_PATH)
80
+ try:
81
+ yield
82
+ finally:
83
+ # ---- graceful shutdown ------------------------------------------------
84
+ # Close BOTH the original and the currently-active DB: a graph hot-swap
85
+ # replaces _state["db"] with a new TraceDB, so the active one would
86
+ # otherwise leak its pooled connections. close() drains every leased
87
+ # connection and lets LadybugDB flush its WAL to disk (idempotent, so
88
+ # closing an already-swapped-out handle is a safe no-op).
89
+ for handle in {db, _state.get("db")}: # set dedupes when no swap happened
90
+ if handle is None:
91
+ continue
92
+ try:
93
+ handle.close()
94
+ except Exception as exc: # noqa: BLE001
95
+ logger.warning("DB close failed during shutdown: %s", exc)
96
+ # Join the embed executor's worker threads (let in-flight encodes finish).
97
+ try:
98
+ shutdown_embed_executor(wait=True)
99
+ except Exception as exc: # noqa: BLE001
100
+ logger.warning("Embed executor shutdown failed: %s", exc)
101
+ logger.info("TraceRAG shutdown complete (pools drained, flushed to disk).")
102
+
103
+
104
+ app = FastAPI(title="TraceRAG STUDIO API", version="0.1.0", lifespan=lifespan)
105
+
106
+ # Rate limiting: slowapi reads the limiter off app.state, and the handler turns
107
+ # the library's RateLimitExceeded into a clean HTTP 429 (+ Retry-After header).
108
+ app.state.limiter = limiter
109
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
110
+
111
+ app.include_router(history.router)
112
+
113
+ app.add_middleware(
114
+ CORSMiddleware,
115
+ allow_origins=config.CORS_ORIGINS, # "*" by default; lock to the frontend in prod
116
+ allow_methods=["*"],
117
+ allow_headers=["*"],
118
+ )
119
+
120
+
121
+ class TraceRequest(BaseModel):
122
+ query: str
123
+ top_k: int | None = None
124
+ session_id: str | None = None # set -> persist this execution
125
+
126
+
127
+ class SubgraphRequest(BaseModel):
128
+ node_ids: list[str]
129
+
130
+
131
+ @app.post("/api/trace")
132
+ async def trace(
133
+ req: TraceRequest,
134
+ user_id: str = Depends(get_current_user), # require a valid token
135
+ ) -> dict:
136
+ """Run the router and return the full trace for visualization."""
137
+ router: TraceRouter = _state["router"]
138
+ # aroute offloads the blocking hybrid retrieval (DB pool + embed pool) off
139
+ # the event loop, so concurrent requests no longer serialize on one thread.
140
+ response = await router.aroute(req.query, top_k=req.top_k or config.TOP_K_VECTOR)
141
+ payload = asdict(response)
142
+ # rendered context the generation model would receive
143
+ for node, result in zip(response.results, payload["results"]):
144
+ result["page_content"] = format_page_content(node)
145
+ payload["context"] = router.build_context(response.results)
146
+ if req.session_id:
147
+ # Ownership check (write-side IDOR): only persist into a session that
148
+ # belongs to the authenticated user. Unknown session → 404; someone
149
+ # else's session → 403. A query with no session_id stays anonymous.
150
+ # Postgres calls are blocking → run them off the loop too.
151
+ owner = await asyncio.to_thread(history.session_owner, req.session_id)
152
+ if owner is None:
153
+ raise HTTPException(status_code=404, detail="session not found")
154
+ if owner != user_id:
155
+ raise HTTPException(status_code=403, detail="not your session")
156
+ payload["trace_id"] = await asyncio.to_thread(
157
+ history.persist_trace,
158
+ session_id=req.session_id,
159
+ query=req.query,
160
+ execution_plan=payload["trace_log"],
161
+ graph_payload=payload["results"],
162
+ )
163
+ return payload
164
+
165
+
166
+ @app.post("/api/subgraph")
167
+ async def subgraph(req: SubgraphRequest) -> dict:
168
+ """Requested nodes plus their 1-hop neighbors and edges."""
169
+ db: TraceDB = _state["db"]
170
+ # blocking DB read -> offload so the loop stays free under concurrency
171
+ return await asyncio.to_thread(db.subgraph, req.node_ids)
172
+
173
+
174
+ class SummarizeRequest(BaseModel):
175
+ key: str # cache key (doc id / node id)
176
+ text: str
177
+
178
+
179
+ # bounded so a long-running server can't leak memory; LRU evicts cold snippets
180
+ _SUMMARY_CACHE: LRUCache[str, str] = LRUCache(capacity=512)
181
+
182
+
183
+ @app.post("/api/summarize")
184
+ @limiter.limit(LLM_RATE_LIMIT) # per-user cap; needs the `request` param below
185
+ def summarize(
186
+ request: Request, # required by slowapi to read the rate-limit key
187
+ req: SummarizeRequest,
188
+ _user: str = Depends(get_current_user), # require a valid token (LLM = billed)
189
+ ) -> dict:
190
+ """One-sentence LLM summary of a node's snippet, cached by key."""
191
+ key = req.key or req.text[:64]
192
+ cached = _SUMMARY_CACHE.get(key)
193
+ if cached is not None:
194
+ return {"summary": cached, "cached": True}
195
+ text = (req.text or "").strip()
196
+ if not text:
197
+ return {"summary": "", "cached": False}
198
+ try:
199
+ client = _state.get("llm")
200
+ if client is None:
201
+ from tracerag.llm import make_client
202
+
203
+ client = make_client()
204
+ _state["llm"] = client
205
+ resp = client.chat.completions.create(
206
+ model=config.OPENROUTER_MODEL,
207
+ messages=[{
208
+ "role": "user",
209
+ "content": (
210
+ "Summarize this engineering note in one concise sentence "
211
+ "(max 25 words): what changed and why. Reply with only the "
212
+ "sentence.\n\n" + text
213
+ ),
214
+ }],
215
+ temperature=0,
216
+ )
217
+ summary = (resp.choices[0].message.content or "").strip()
218
+ except Exception as exc: # noqa: BLE001
219
+ logger.warning("summarize failed for %s: %s", key, exc)
220
+ return {"summary": "", "cached": False, "error": str(exc)}
221
+ _SUMMARY_CACHE.set(key, summary)
222
+ return {"summary": summary, "cached": False}
223
+
224
+
225
+ # swap the active .lbug without restarting; router models stay warm
226
+ def _graphs_dir() -> Path:
227
+ return config.PROJECT_ROOT / "graphs"
228
+
229
+
230
+ def _graph_label(path: Path) -> str:
231
+ stem = path.stem
232
+ if path.resolve() == config.DB_PATH.resolve():
233
+ return f"{stem} (default)"
234
+ return stem.replace("__", "/") # pallets__flask -> pallets/flask
235
+
236
+
237
+ def discover_graphs() -> list[Path]:
238
+ """Selectable .lbug files: the configured default plus graphs/*."""
239
+ paths: list[Path] = []
240
+ if config.DB_PATH.exists():
241
+ paths.append(config.DB_PATH)
242
+ gdir = _graphs_dir()
243
+ if gdir.exists():
244
+ paths.extend(sorted(gdir.glob("*.lbug")))
245
+ seen: set[Path] = set()
246
+ out: list[Path] = []
247
+ for p in paths:
248
+ rp = p.resolve()
249
+ if rp not in seen:
250
+ seen.add(rp)
251
+ out.append(p)
252
+ return out
253
+
254
+
255
+ class SwitchGraphRequest(BaseModel):
256
+ id: str # the graph's file name
257
+
258
+
259
+ @app.get("/api/graphs")
260
+ def list_graphs() -> dict:
261
+ active = _state.get("db_path")
262
+ active_rp = Path(active).resolve() if active else None
263
+ graphs = [
264
+ {
265
+ "id": p.name,
266
+ "label": _graph_label(p),
267
+ "active": active_rp is not None and p.resolve() == active_rp,
268
+ }
269
+ for p in discover_graphs()
270
+ ]
271
+ return {"graphs": graphs, "active": Path(active).name if active else None}
272
+
273
+
274
+ @app.post("/api/graphs/switch")
275
+ def switch_graph(req: SwitchGraphRequest) -> dict:
276
+ """Hot-swap the active graph; id must be one of the discovered files."""
277
+ target = next((p for p in discover_graphs() if p.name == req.id), None)
278
+ if target is None:
279
+ raise HTTPException(status_code=404, detail=f"unknown graph: {req.id}")
280
+
281
+ old = _state.get("db")
282
+ new_db = TraceDB(target)
283
+ new_db.init_schema()
284
+ # re-point router at the new connection, keep warm models
285
+ _state["db"] = new_db
286
+ _state["db_path"] = str(target)
287
+ _state["router"].db = new_db
288
+ _SUMMARY_CACHE.clear() # snippets differ per graph
289
+ if old is not None and old is not new_db:
290
+ try:
291
+ old.close()
292
+ except Exception as exc: # noqa: BLE001
293
+ logger.warning("closing previous graph failed: %s", exc)
294
+
295
+ logger.info("Switched active graph -> %s", target)
296
+ return {
297
+ "active": target.name,
298
+ "label": _graph_label(target),
299
+ "nodes": new_db.count_nodes(),
300
+ }
301
+
302
+
303
+ # per-entity-type question templates
304
+ _SUGGESTION_TEMPLATES: dict[str, str] = {
305
+ "Person": "What did {label} work on?",
306
+ "Team": "What does {label} own?",
307
+ "Service": "What depends on {label}?",
308
+ "Library": "What changed in {label}?",
309
+ "Tool": "What is {label} used for?",
310
+ "PR": "What is related to {label}?",
311
+ "Ticket": "What is linked to {label}?",
312
+ }
313
+ _SUGGESTION_DEFAULT = "What is related to {label}?"
314
+
315
+
316
+ @app.get("/api/suggestions")
317
+ def suggestions(limit: int = 5) -> dict:
318
+ """Example questions built from the active graph's hub entities."""
319
+ db: TraceDB = _state["db"]
320
+ try:
321
+ hubs = db.top_entities(limit=limit * 4) # over-fetch, then diversify
322
+ except Exception as exc: # noqa: BLE001
323
+ logger.warning("top_entities failed: %s", exc)
324
+ return {"suggestions": []}
325
+
326
+ out: list[dict] = []
327
+ seen_types: dict[str, int] = {}
328
+ # one question per type, in degree order
329
+ for ent in hubs:
330
+ etype = ent.get("type") or ""
331
+ label = (ent.get("label") or "").strip()
332
+ if not label or seen_types.get(etype, 0) >= 1:
333
+ continue
334
+ tmpl = _SUGGESTION_TEMPLATES.get(etype, _SUGGESTION_DEFAULT)
335
+ out.append({"query": tmpl.format(label=label), "entity": label, "type": etype})
336
+ seen_types[etype] = seen_types.get(etype, 0) + 1
337
+ if len(out) >= limit:
338
+ break
339
+ # backfill by degree if we still have room
340
+ if len(out) < limit:
341
+ used = {s["query"] for s in out}
342
+ for ent in hubs:
343
+ label = (ent.get("label") or "").strip()
344
+ if not label:
345
+ continue
346
+ tmpl = _SUGGESTION_TEMPLATES.get(ent.get("type") or "", _SUGGESTION_DEFAULT)
347
+ q = tmpl.format(label=label)
348
+ if q in used:
349
+ continue
350
+ out.append({"query": q, "entity": label, "type": ent.get("type") or ""})
351
+ used.add(q)
352
+ if len(out) >= limit:
353
+ break
354
+ return {"suggestions": out}
355
+
356
+
357
+ class AnswerRequest(BaseModel):
358
+ query: str
359
+ context: str
360
+
361
+
362
+ # bounded LRU shared by the blocking and streaming answer endpoints below
363
+ _ANSWER_CACHE: LRUCache[str, str] = LRUCache(capacity=512)
364
+
365
+
366
+ def _answer_key(query: str, context: str) -> str:
367
+ return f"{query}\n#{hash(context)}"
368
+
369
+
370
+ def _answer_prompt(query: str, context: str) -> str:
371
+ """Grounded-answer prompt shared by the blocking and streaming endpoints."""
372
+ return (
373
+ "You are answering a teammate's question about an engineering "
374
+ "knowledge graph. Use ONLY the context below. Answer in 2-3 "
375
+ "plain sentences a non-expert can follow, naming the specific "
376
+ "PRs / people / components involved. If the context does not "
377
+ "contain the answer, say so plainly rather than guessing.\n\n"
378
+ f"Question: {query}\n\nContext:\n{context}"
379
+ )
380
+
381
+
382
+ def _answer_client():
383
+ """Lazily build and cache the OpenRouter client."""
384
+ client = _state.get("llm")
385
+ if client is None:
386
+ from tracerag.llm import make_client
387
+
388
+ client = make_client()
389
+ _state["llm"] = client
390
+ return client
391
+
392
+
393
+ @app.post("/api/answer")
394
+ @limiter.limit(LLM_RATE_LIMIT) # per-user cap; needs the `request` param below
395
+ def answer(
396
+ request: Request, # required by slowapi to read the rate-limit key
397
+ req: AnswerRequest,
398
+ _user: str = Depends(get_current_user), # require a valid token (LLM = billed)
399
+ ) -> dict:
400
+ """Plain-language answer grounded in the retrieved context, cached per (query, context)."""
401
+ key = _answer_key(req.query, req.context)
402
+ cached = _ANSWER_CACHE.get(key)
403
+ if cached is not None:
404
+ return {"answer": cached, "cached": True}
405
+ context = (req.context or "").strip()
406
+ if not context:
407
+ return {"answer": "No supporting context was retrieved for this query.",
408
+ "cached": False}
409
+ try:
410
+ resp = _answer_client().chat.completions.create(
411
+ model=config.OPENROUTER_MODEL,
412
+ messages=[{"role": "user", "content": _answer_prompt(req.query, context)}],
413
+ temperature=0,
414
+ )
415
+ text = (resp.choices[0].message.content or "").strip()
416
+ except Exception as exc: # noqa: BLE001
417
+ logger.warning("answer failed: %s", exc)
418
+ return {"answer": "", "cached": False, "error": str(exc)}
419
+ _ANSWER_CACHE.set(key, text)
420
+ return {"answer": text, "cached": False}
421
+
422
+
423
+ @app.post("/api/answer/stream")
424
+ @limiter.limit(LLM_RATE_LIMIT) # same per-user cap — the streaming twin hits the
425
+ # same billing API, so it must be capped too
426
+ def answer_stream(
427
+ request: Request, # required by slowapi to read the rate-limit key
428
+ req: AnswerRequest,
429
+ _user: str = Depends(get_current_user), # require a valid token (LLM = billed)
430
+ ) -> StreamingResponse:
431
+ """Same answer as /api/answer, streamed token-by-token as plain UTF-8 chunks."""
432
+
433
+ def gen():
434
+ key = _answer_key(req.query, req.context)
435
+ cached = _ANSWER_CACHE.get(key)
436
+ if cached is not None:
437
+ yield cached
438
+ return
439
+ context = (req.context or "").strip()
440
+ if not context:
441
+ yield "No supporting context was retrieved for this query."
442
+ return
443
+ parts: list[str] = []
444
+ try:
445
+ stream = _answer_client().chat.completions.create(
446
+ model=config.OPENROUTER_MODEL,
447
+ messages=[{"role": "user", "content": _answer_prompt(req.query, context)}],
448
+ temperature=0,
449
+ stream=True,
450
+ )
451
+ for chunk in stream:
452
+ # guard every level; providers differ
453
+ delta = ""
454
+ try:
455
+ delta = chunk.choices[0].delta.content or ""
456
+ except (AttributeError, IndexError):
457
+ delta = ""
458
+ if delta:
459
+ parts.append(delta)
460
+ yield delta
461
+ except Exception as exc: # noqa: BLE001
462
+ logger.warning("answer stream failed: %s", exc)
463
+ if not parts:
464
+ yield "Sorry — the answer could not be generated right now."
465
+ return
466
+ full = "".join(parts).strip()
467
+ if full:
468
+ _ANSWER_CACHE.set(key, full)
469
+
470
+ # no-store + disable proxy buffering so chunks flush immediately
471
+ return StreamingResponse(
472
+ gen(),
473
+ media_type="text/plain; charset=utf-8",
474
+ headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
475
+ )
476
+
477
+
478
+ @app.get("/api/health")
479
+ def health() -> dict:
480
+ db: TraceDB = _state["db"]
481
+ return {"status": "ok", "nodes": db.count_nodes()}
482
+
483
+
484
+ if __name__ == "__main__":
485
+ import uvicorn
486
+
487
+ uvicorn.run("api:app", host="127.0.0.1", port=8000, reload=True)
auth.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Clerk session-token verification for FastAPI (networkless JWT verification).
2
+
3
+ Clerk issues short-lived RS256 JWTs. RS256 is *asymmetric*: Clerk signs with a
4
+ private key it never shares, and publishes the matching PUBLIC key at a JWKS
5
+ (JSON Web Key Set) endpoint. We fetch those public keys once, cache them, and
6
+ verify each incoming token's signature locally — no network round-trip per
7
+ request. If the signature + claims check out, the token's `sub` claim is a
8
+ Clerk user id we can trust (it could not have been forged without Clerk's
9
+ private key).
10
+
11
+ Dev escape hatch: when CLERK_ISSUER is unset we skip verification entirely and
12
+ return a fixed dev user id, so local development without Clerk env vars is never
13
+ locked out. Configured => enforce; unconfigured => warn-and-allow.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+
20
+ import jwt
21
+ from fastapi import Header, HTTPException, Request, status
22
+ from jwt import PyJWKClient, PyJWKClientError
23
+
24
+ from tracerag import config
25
+
26
+ logger = logging.getLogger("tracerag.auth")
27
+
28
+ # PyJWKClient fetches the JWKS once and caches the signing keys in-process,
29
+ # only re-fetching when it sees a token signed by an unknown `kid` (key id) —
30
+ # which is exactly how it transparently handles Clerk rotating its keys.
31
+ # Built lazily as a singleton so we don't hit the network at import time.
32
+ _jwk_client: PyJWKClient | None = None
33
+ _warned_dev_bypass = False
34
+
35
+
36
+ def _get_jwk_client() -> PyJWKClient:
37
+ global _jwk_client
38
+ if _jwk_client is None:
39
+ _jwk_client = PyJWKClient(config.CLERK_JWKS_URL, cache_keys=True)
40
+ return _jwk_client
41
+
42
+
43
+ def _extract_bearer(authorization: str | None) -> str:
44
+ """Pull the raw JWT out of an `Authorization: Bearer <token>` header."""
45
+ if not authorization:
46
+ raise HTTPException(
47
+ status_code=status.HTTP_401_UNAUTHORIZED,
48
+ detail="Missing Authorization header.",
49
+ headers={"WWW-Authenticate": "Bearer"},
50
+ )
51
+ # partition splits on the FIRST space: "Bearer" | " " | "<token>"
52
+ scheme, _, token = authorization.partition(" ")
53
+ if scheme.lower() != "bearer" or not token:
54
+ raise HTTPException(
55
+ status_code=status.HTTP_401_UNAUTHORIZED,
56
+ detail="Authorization header must be 'Bearer <token>'.",
57
+ headers={"WWW-Authenticate": "Bearer"},
58
+ )
59
+ return token
60
+
61
+
62
+ def _verify_token(token: str) -> dict:
63
+ """Verify the signature and the standard claims; return the decoded payload.
64
+
65
+ Two distinct failure classes, surfaced differently:
66
+ - we couldn't resolve a signing key (JWKS unreachable / unknown kid)
67
+ - the token itself is bad (expired, wrong issuer, tampered signature)
68
+ """
69
+ # --- Step 1: find the PUBLIC key that matches this token's `kid` header. ---
70
+ # PyJWKClient reads the unverified header (just the kid, not the payload) and
71
+ # hands back the corresponding public key from Clerk's JWKS.
72
+ try:
73
+ signing_key = _get_jwk_client().get_signing_key_from_jwt(token)
74
+ except PyJWKClientError as exc:
75
+ # Couldn't fetch/match a key — a server/config problem, not a forged
76
+ # token. Log loudly; still answer 401 so we never leak internals.
77
+ logger.warning("JWKS key resolution failed: %s", exc)
78
+ raise HTTPException(
79
+ status_code=status.HTTP_401_UNAUTHORIZED,
80
+ detail="Could not resolve token signing key.",
81
+ headers={"WWW-Authenticate": "Bearer"},
82
+ )
83
+ except jwt.DecodeError as exc:
84
+ # Header itself was malformed (not a real JWT).
85
+ raise HTTPException(
86
+ status_code=status.HTTP_401_UNAUTHORIZED,
87
+ detail=f"Malformed token: {exc}",
88
+ headers={"WWW-Authenticate": "Bearer"},
89
+ )
90
+
91
+ # --- Step 2: verify signature AND the registered claims in one call. ---
92
+ # jwt.decode() does ALL of the following atomically and raises if any fails:
93
+ # * RS256 signature matches `signing_key` -> token is authentic
94
+ # * `exp` (expiry) is in the future -> token still valid
95
+ # * `nbf`/`iat` not in the future (with leeway) -> token already active
96
+ # * `iss` equals our Clerk instance -> minted by OUR Clerk
97
+ # * the `require` claims are all present -> well-formed session
98
+ try:
99
+ claims = jwt.decode(
100
+ token,
101
+ signing_key.key,
102
+ algorithms=["RS256"], # PIN the algorithm — never accept
103
+ # 'none'/HS256, which would let an
104
+ # attacker self-sign a token.
105
+ issuer=config.CLERK_ISSUER, # `iss` must equal our Clerk instance.
106
+ leeway=5, # tolerate ≤5s of clock skew on exp/nbf.
107
+ options={
108
+ "require": ["exp", "iat", "sub"], # must be present or reject.
109
+ "verify_aud": False, # Clerk session tokens carry no `aud`;
110
+ # we authorize via `azp` below instead.
111
+ },
112
+ )
113
+ except jwt.ExpiredSignatureError:
114
+ raise HTTPException(
115
+ status_code=status.HTTP_401_UNAUTHORIZED,
116
+ detail="Token expired.",
117
+ headers={"WWW-Authenticate": "Bearer"},
118
+ )
119
+ except jwt.InvalidTokenError as exc:
120
+ # Catch-all for bad signature, wrong issuer, missing required claim, etc.
121
+ raise HTTPException(
122
+ status_code=status.HTTP_401_UNAUTHORIZED,
123
+ detail=f"Invalid token: {exc}",
124
+ headers={"WWW-Authenticate": "Bearer"},
125
+ )
126
+
127
+ # --- Step 3: optional `azp` (authorized party) check. ---
128
+ # `azp` is the origin the token was minted for. Verifying it against an
129
+ # allow-list stops a token issued for some other site from being replayed
130
+ # against our API. Skipped entirely if CLERK_AUTHORIZED_PARTIES is unset.
131
+ if config.CLERK_AUTHORIZED_PARTIES:
132
+ azp = claims.get("azp")
133
+ if azp and azp not in config.CLERK_AUTHORIZED_PARTIES:
134
+ raise HTTPException(
135
+ status_code=status.HTTP_401_UNAUTHORIZED,
136
+ detail="Token authorized-party (azp) is not allowed.",
137
+ headers={"WWW-Authenticate": "Bearer"},
138
+ )
139
+ return claims
140
+
141
+
142
+ def get_current_user(
143
+ request: Request,
144
+ authorization: str | None = Header(default=None),
145
+ ) -> str:
146
+ """FastAPI dependency → the verified Clerk user id (`sub`).
147
+
148
+ Add `user_id: str = Depends(get_current_user)` to any route to (a) require a
149
+ valid token and (b) receive the trustworthy user id. Routes should rely on
150
+ THIS value, never on a client-supplied user_id — that's what closes IDOR.
151
+
152
+ Side effect: stashes the resolved id on `request.state.user_id`. This is the
153
+ bridge that lets slowapi's key_func rate-limit per USER — the key_func only
154
+ receives the Request, not this dependency's return value, but FastAPI
155
+ resolves dependencies BEFORE the rate-limit decorator runs, so by then
156
+ request.state.user_id is populated. See ratelimit.py.
157
+ """
158
+ user_id = _resolve_user_id(authorization)
159
+ request.state.user_id = user_id
160
+ return user_id
161
+
162
+
163
+ def _resolve_user_id(authorization: str | None) -> str:
164
+ """Verify the token (or dev-bypass) and return the user id."""
165
+ # Dev-bypass: no Clerk configured → don't verify, return a fixed dev user so
166
+ # local testing isn't blocked. Warn once so it can't silently ship to prod.
167
+ if not config.CLERK_ENABLED:
168
+ global _warned_dev_bypass
169
+ if not _warned_dev_bypass:
170
+ logger.warning(
171
+ "CLERK_ISSUER unset — auth is in DEV-BYPASS mode; every request "
172
+ "runs as '%s'. Do NOT deploy without setting CLERK_ISSUER.",
173
+ config.DEV_USER_ID,
174
+ )
175
+ _warned_dev_bypass = True
176
+ return config.DEV_USER_ID
177
+
178
+ token = _extract_bearer(authorization)
179
+ claims = _verify_token(token)
180
+ return claims["sub"] # the Clerk user id — safe to trust post-verification.
cache.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A tiny bounded LRU (Least-Recently-Used) cache backed by OrderedDict.
2
+
3
+ Why not functools.lru_cache? These caches are SHARED between two endpoints (the
4
+ blocking and the streaming answer handlers read *and* write the same store),
5
+ they need an explicit `cached` flag in the HTTP response, and they must support
6
+ a per-graph .clear() when the active graph is hot-swapped. A decorator gives you
7
+ none of those. ~25 lines of OrderedDict buys all of it with O(1) get/set/evict
8
+ and a hard memory ceiling, which is the whole point — the old plain-dict caches
9
+ grew without bound and leaked memory on a long-running server.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections import OrderedDict
15
+ from typing import Generic, Optional, TypeVar
16
+
17
+ K = TypeVar("K")
18
+ V = TypeVar("V")
19
+
20
+
21
+ class LRUCache(Generic[K, V]):
22
+ """Fixed-capacity cache that evicts the least-recently-used entry first.
23
+
24
+ The eviction policy IS the OrderedDict's ordering: we keep the *oldest*
25
+ (least-recently-used) entry on the left and the *most-recently-used* on the
26
+ right. Every read or write moves the touched key to the right, so whatever
27
+ has drifted to the far left is, by definition, the coldest entry — and that's
28
+ exactly what we drop when we're over capacity.
29
+ """
30
+
31
+ def __init__(self, capacity: int = 256) -> None:
32
+ if capacity <= 0:
33
+ raise ValueError("LRUCache capacity must be positive")
34
+ self.capacity = capacity
35
+ self._store: "OrderedDict[K, V]" = OrderedDict()
36
+
37
+ def get(self, key: K) -> Optional[V]:
38
+ """Return the value (and mark it most-recently-used), or None on miss."""
39
+ if key not in self._store:
40
+ return None
41
+ # A successful read counts as "use": promote the key to the right end so
42
+ # it survives the next eviction. This is what makes it LRU and not FIFO.
43
+ self._store.move_to_end(key)
44
+ return self._store[key]
45
+
46
+ def __contains__(self, key: K) -> bool:
47
+ # Note: membership does NOT promote — only get()/set() count as a "use".
48
+ return key in self._store
49
+
50
+ def set(self, key: K, value: V) -> None:
51
+ """Insert/overwrite a value, then evict oldest entries past capacity."""
52
+ if key in self._store:
53
+ # Overwriting an existing key still counts as a use → promote it.
54
+ self._store.move_to_end(key)
55
+ self._store[key] = value
56
+ # Evict from the LEFT (oldest) until we're back within the ceiling.
57
+ # last=False pops the first-inserted / least-recently-used item; a normal
58
+ # popitem() would pop the newest, which is the opposite of what we want.
59
+ while len(self._store) > self.capacity:
60
+ self._store.popitem(last=False)
61
+
62
+ def clear(self) -> None:
63
+ """Drop everything — used when the active graph changes (snippets differ)."""
64
+ self._store.clear()
65
+
66
+ def __len__(self) -> int:
67
+ return len(self._store)
database.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLModel engine and session dependency for the Postgres history store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import Iterator
8
+ from pathlib import Path
9
+
10
+ from sqlmodel import Session, SQLModel, create_engine
11
+
12
+ logger = logging.getLogger("tracerag.database")
13
+
14
+ # load .env so APP_DATABASE_URL is available; no-op if absent
15
+ try:
16
+ from dotenv import load_dotenv
17
+
18
+ load_dotenv(Path(__file__).resolve().parent / ".env")
19
+ except ImportError: # pragma: no cover
20
+ pass
21
+
22
+ _engine = None # lazily created
23
+
24
+
25
+ def get_engine():
26
+ """Return the process-wide SQLModel engine, creating it on first use."""
27
+ global _engine
28
+ if _engine is None:
29
+ url = os.getenv("APP_DATABASE_URL")
30
+ if not url:
31
+ raise RuntimeError(
32
+ "APP_DATABASE_URL is not set. Add your Neon connection string "
33
+ "(postgresql://USER:PASSWORD@HOST/DB?sslmode=require) to .env."
34
+ )
35
+ _engine = create_engine(url, echo=False, pool_pre_ping=True)
36
+ return _engine
37
+
38
+
39
+ def init_db() -> None:
40
+ """Create the history tables if they don't exist (idempotent)."""
41
+ # import models so their tables register before create
42
+ from models import ChatSession, TraceLog, User # noqa: F401
43
+ from sqlalchemy.orm import configure_mappers
44
+
45
+ # validate relationships at startup, not on first query
46
+ configure_mappers()
47
+ SQLModel.metadata.create_all(get_engine())
48
+ logger.info("Postgres history schema ready.")
49
+
50
+
51
+ def get_session() -> Iterator[Session]:
52
+ """FastAPI dependency yielding a session, closed afterwards."""
53
+ with Session(get_engine()) as session:
54
+ yield session
graphs/apache__kafka.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eadcec16587cc7fb693d08c433b8ae5b23399b747d96a0081129fc16139d87a7
3
+ size 4182016
graphs/encode__httpx.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:104ae0ce36ed1a5e8184bd0ee3e963ff93cdb20a88ec4dc91f2becea0e2ed4bc
3
+ size 2469888
graphs/encode__starlette.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0e31168dacb81c2bdf01cd51c7a2e0d27526e6393c0a2682f4055545f0623ead
3
+ size 2752512
graphs/encode__uvicorn.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e448cbece67fa179048a5024a6a60e43a61f6fbe4d4da9cc0c9044fe72b455e5
3
+ size 1777664
graphs/fastapi__fastapi.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2118082f932d55bc68bc5628f8f04bb5c9c84af701e21904e625e3e692dfece8
3
+ size 2351104
graphs/huggingface__transformers.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5a259f404ea050e140cb449af8d492ecaa66bf18cc37f958c6ce537fe59d08e0
3
+ size 2301952
graphs/langchain-ai__langchain.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:93da5499622a6acfe0a3658f55621e6d2f2fb366adbfa1110a61c5cbe98a8ffd
3
+ size 2023424
graphs/openai__openai-python.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:64edace5e78b359c308dbd4b8287c7f8b66f1db36aa428ae6b89152e75c75c59
3
+ size 1404928
graphs/pallets__click.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2982d2ea449c3f47eaf810e59d23b36ae464b90028f9ce5cdd5d1241702e4d96
3
+ size 1589248
graphs/pallets__flask.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6d4f77883333fc432ef92b22988c0aee0bb451e6eaa17e07796810a36f127644
3
+ size 1523712
graphs/psf__requests.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0ac54923dae6f7baaae9de459c40e3146a19232e49c9b7be497635c511f033db
3
+ size 1613824
graphs/pydantic__pydantic.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7ca527aa2ff1d7a3a843488cdb09962fa7353f15ded354a0c64f6399c913bae2
3
+ size 1953792
graphs/tiangolo__typer.lbug ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a7ae9ad07103e943be40610741899c42809b45e339fb413e7755104e43237ef1
3
+ size 2617344
models.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLModel tables for the Postgres history store (users, sessions, traces)."""
2
+
3
+ # no `from __future__ import annotations`: it breaks SQLModel relationship targets
4
+
5
+ from datetime import datetime, timezone
6
+ from uuid import uuid4
7
+
8
+ from sqlalchemy import Column, JSON
9
+ from sqlmodel import Field, Relationship, SQLModel
10
+
11
+
12
+ def _utcnow() -> datetime:
13
+ return datetime.now(timezone.utc)
14
+
15
+
16
+ def _new_id() -> str:
17
+ return str(uuid4())
18
+
19
+
20
+ class User(SQLModel, table=True):
21
+ # Clerk user id from the frontend, not auto-generated
22
+ id: str = Field(primary_key=True)
23
+ email: str = Field(unique=True, index=True)
24
+ created_at: datetime = Field(default_factory=_utcnow)
25
+
26
+ sessions: list["ChatSession"] = Relationship(back_populates="user")
27
+
28
+
29
+ class ChatSession(SQLModel, table=True):
30
+ id: str = Field(default_factory=_new_id, primary_key=True)
31
+ user_id: str = Field(foreign_key="user.id", index=True)
32
+ title: str = Field(default="New Chat")
33
+ created_at: datetime = Field(default_factory=_utcnow)
34
+
35
+ user: User | None = Relationship(back_populates="sessions")
36
+ traces: list["TraceLog"] = Relationship(back_populates="session")
37
+
38
+
39
+ class TraceLog(SQLModel, table=True):
40
+ id: str = Field(default_factory=_new_id, primary_key=True)
41
+ session_id: str = Field(foreign_key="chatsession.id", index=True)
42
+ query: str
43
+ execution_plan: dict = Field(default_factory=dict, sa_column=Column(JSON))
44
+ graph_payload: dict | list = Field(default_factory=dict, sa_column=Column(JSON))
45
+ created_at: datetime = Field(default_factory=_utcnow)
46
+
47
+ session: ChatSession | None = Relationship(back_populates="traces")
ratelimit.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-user rate limiting for the LLM (billed) endpoints, built on slowapi.
2
+
3
+ slowapi wraps the `limits` library. A limit like "10/minute" is a FIXED-WINDOW
4
+ counter by default: one count per (key, 60s window). It's cheap and perfect for
5
+ "stop a single user from hammering a paid API", with one caveat — the window
6
+ boundary allows a short burst (10 at :59 + 10 at 1:01 = 20 in ~2s). For a smooth
7
+ limit with no boundary burst, switch to the moving-window strategy (see below).
8
+
9
+ The interesting design choice here is the KEY: we rate-limit per authenticated
10
+ USER, not per IP. The default slowapi key_func (get_remote_address) keys by IP,
11
+ which would make a whole office behind one NAT share a single bucket. Instead our
12
+ key_func reads request.state.user_id — which get_current_user populates during
13
+ dependency resolution, before this key_func is ever called (see auth.py).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from slowapi import Limiter
19
+ from slowapi.util import get_remote_address
20
+ from starlette.requests import Request
21
+
22
+
23
+ def _user_key(request: Request) -> str:
24
+ """Bucket key = the authenticated user id, falling back to client IP.
25
+
26
+ By the time slowapi calls this, FastAPI has already run get_current_user
27
+ (a dependency on the limited routes), so request.state.user_id is set. The
28
+ IP fallback only matters for routes that have no auth dependency — it keeps
29
+ the key_func total so slowapi never crashes on a missing attribute.
30
+ """
31
+ user_id = getattr(request.state, "user_id", None)
32
+ if user_id:
33
+ return f"user:{user_id}"
34
+ return f"ip:{get_remote_address(request)}"
35
+
36
+
37
+ # The process-wide limiter.
38
+ #
39
+ # storage_uri: omitted → defaults to IN-MEMORY storage. That's correct for a
40
+ # single uvicorn worker. With --workers N each process keeps its OWN counter, so
41
+ # the effective limit becomes N × the configured rate. When you scale to
42
+ # multi-worker / multi-instance, point all of them at a shared store:
43
+ #
44
+ # limiter = Limiter(
45
+ # key_func=_user_key,
46
+ # storage_uri="redis://localhost:6379", # shared counter across workers
47
+ # strategy="moving-window", # smooth limit, no boundary burst
48
+ # )
49
+ #
50
+ # strategy: omitted → "fixed-window" (the simple counter described above).
51
+ limiter = Limiter(key_func=_user_key)
52
+
53
+ # Single source of truth for the LLM-endpoint rate, applied via @limiter.limit().
54
+ LLM_RATE_LIMIT = "10/minute"
requirements.txt ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Storage Core ---
2
+ ladybug
3
+
4
+ # ---Embeddings (local, 384-dim) ---
5
+ sentence-transformers>=2.7.0 # all-MiniLM-L6-v2 -> FLOAT[384]
6
+
7
+ # --- Entity extraction (GLiNER primary, spaCy fallback) ---
8
+ gliner>=0.2.0
9
+ # PIN: onnxruntime 1.18+ (incl. 1.26) fails "DLL load failed ... initialization
10
+ # routine failed" on Windows/Anaconda boxes lacking the newer MSVC runtime,
11
+ # which silently demotes GLiNER to the spaCy fallback (type pollution). 1.17.1
12
+ # loads against the installed runtime. Do not bump without re-verifying import.
13
+ onnxruntime==1.17.1
14
+ spacy>=3.7.0
15
+
16
+ # --- Deep Merge LLM (local) ---
17
+ openai>=1.40.0 # OpenRouter client (deep-merge + intent + judge)
18
+ python-dotenv>=1.0.0 # load OPENROUTER_API_KEY from .env
19
+
20
+ # --- Data / numerics ---
21
+ numpy>=1.26.0
22
+ pandas>=2.1.0 # conn.execute(...).get_as_df()
23
+
24
+ # --- Loaders (generic ingest) ---
25
+ python-frontmatter>=1.1.0 # .md front-matter parsing
26
+ pypdf>=4.0.0 # .pdf loader
27
+
28
+ # --- Benchmark ---
29
+ tiktoken>=0.7.0 # context-window token counting
30
+
31
+ # --- Integrations ---
32
+ langchain-core>=0.2.0 # BaseRetriever subclass
33
+
34
+ # --- API (TraceRAG STUDIO dashboard) ---
35
+ fastapi>=0.110.0
36
+ uvicorn[standard]>=0.29.0
37
+
38
+ # --- History store (Neon Postgres via SQLModel) ---
39
+ sqlmodel>=0.0.16 # User / ChatSession / TraceLog tables
40
+ psycopg2-binary>=2.9.9 # Postgres driver for the postgresql:// engine
41
+
42
+ # --- Auth (Clerk session-token verification) ---
43
+ # [crypto] pulls in `cryptography` for RS256 signature verification; PyJWKClient
44
+ # (fetches + caches Clerk's public JWKS) ships in the base PyJWT package.
45
+ PyJWT[crypto]>=2.8.0
46
+
47
+ # --- Ops / testing ---
48
+ tqdm>=4.66.0 # ingest progress bars
49
+ aiohttp>=3.9.0 # scripts/stress_test.py load generator
50
+ slowapi>=0.1.9 # FastAPI rate limiting (see api.py hardening)
51
+ requests>=2.31.0 # scripts/ingest_github.py live PR connector
52
+
53
+ # --- Observability ---
54
+ sentry-sdk[fastapi]>=2.0.0 # error tracking + perf monitoring (opt-in via SENTRY_DSN)
55
+
routers/__init__.py ADDED
File without changes
routers/history.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat-history endpoints backed by Postgres via SQLModel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+ from pydantic import BaseModel
10
+ from sqlmodel import Session, select
11
+
12
+ from auth import get_current_user
13
+ from database import get_engine, get_session
14
+ from models import ChatSession, TraceLog, User
15
+
16
+ logger = logging.getLogger("tracerag.history")
17
+
18
+ router = APIRouter(prefix="/api", tags=["history"])
19
+
20
+
21
+ class SessionUpsert(BaseModel):
22
+ # user_id is intentionally NOT trusted from the body anymore — the real user
23
+ # id comes from the verified token (get_current_user). Kept optional purely
24
+ # for backward-compat with the existing frontend payload; it is ignored.
25
+ user_id: str | None = None
26
+ email: str | None = None # required only on first sight of a user
27
+ title: str = "New Chat"
28
+ session_id: str | None = None # set -> rename that session instead of creating
29
+
30
+
31
+ class SessionRead(BaseModel):
32
+ id: str
33
+ user_id: str
34
+ title: str
35
+ created_at: datetime
36
+
37
+
38
+ class TraceRead(BaseModel):
39
+ id: str
40
+ session_id: str
41
+ query: str
42
+ execution_plan: dict
43
+ graph_payload: dict | list
44
+ created_at: datetime
45
+
46
+
47
+ def _ensure_user(db: Session, user_id: str, email: str | None) -> User:
48
+ """Get the user by id, creating the row on first sight.
49
+
50
+ Email is best-effort, not a hard requirement: dev-bypass has none, and
51
+ Clerk's email can lag the user id on first load — blocking "new chat" on a
52
+ missing email made history silently break. Fall back to a deterministic
53
+ per-user placeholder (unique, so no constraint clash) and use the real
54
+ address whenever the client supplies it.
55
+ """
56
+ user = db.get(User, user_id)
57
+ if user is None:
58
+ placeholder = f"{user_id}@users.tracerag.local"
59
+ candidate = email or placeholder
60
+ # The email column is unique. If this address is already held by a
61
+ # DIFFERENT id (e.g. a real-Clerk row vs. the dev-bypass "dev-user"),
62
+ # claiming it would 500. Fall back to the per-id placeholder, which only
63
+ # this id can ever own.
64
+ clash = db.exec(select(User).where(User.email == candidate)).first()
65
+ if clash is not None and clash.id != user_id:
66
+ candidate = placeholder
67
+ user = User(id=user_id, email=candidate)
68
+ db.add(user)
69
+ db.flush() # surface any remaining unique violations before committing
70
+ return user
71
+
72
+
73
+ def session_owner(session_id: str) -> str | None:
74
+ """Return the user_id that owns a session, or None if it doesn't exist.
75
+
76
+ Opens its own DB session (like persist_trace) so it can be called from the
77
+ /api/trace endpoint, which has no injected `db` dependency. Used to verify
78
+ ownership before persisting a trace into a session — closes the write-side
79
+ IDOR (writing your trace into someone else's session).
80
+ """
81
+ try:
82
+ with Session(get_engine()) as db:
83
+ chat = db.get(ChatSession, session_id)
84
+ return chat.user_id if chat is not None else None
85
+ except Exception as exc: # noqa: BLE001 — DB hiccup shouldn't 500 the query
86
+ logger.warning("session_owner lookup failed for %s: %s", session_id, exc)
87
+ return None
88
+
89
+
90
+ def persist_trace(
91
+ session_id: str,
92
+ query: str,
93
+ execution_plan: dict,
94
+ graph_payload: dict | list,
95
+ ) -> str | None:
96
+ """Save one execution to Postgres; returns the new trace id or None on failure."""
97
+ try:
98
+ with Session(get_engine()) as db:
99
+ log = TraceLog(
100
+ session_id=session_id,
101
+ query=query,
102
+ execution_plan=execution_plan,
103
+ graph_payload=graph_payload,
104
+ )
105
+ db.add(log)
106
+ db.commit()
107
+ db.refresh(log)
108
+ return log.id
109
+ except Exception as exc: # noqa: BLE001
110
+ logger.warning("persist_trace failed for session %s: %s", session_id, exc)
111
+ return None
112
+
113
+
114
+ @router.post("/sessions", response_model=SessionRead)
115
+ def create_or_rename_session(
116
+ body: SessionUpsert,
117
+ user_id: str = Depends(get_current_user), # verified id, NOT body.user_id
118
+ db: Session = Depends(get_session),
119
+ ) -> ChatSession:
120
+ if body.session_id:
121
+ chat = db.get(ChatSession, body.session_id)
122
+ if chat is None:
123
+ raise HTTPException(status_code=404, detail="session not found")
124
+ # ownership check: you can only rename a session that is yours
125
+ if chat.user_id != user_id:
126
+ raise HTTPException(status_code=403, detail="not your session")
127
+ chat.title = body.title
128
+ else:
129
+ # create the user row (if new) and the session under the TOKEN's user id
130
+ _ensure_user(db, user_id, body.email)
131
+ chat = ChatSession(user_id=user_id, title=body.title)
132
+ db.add(chat)
133
+ db.commit()
134
+ db.refresh(chat)
135
+ return chat
136
+
137
+
138
+ @router.get("/sessions", response_model=list[SessionRead])
139
+ def list_sessions(
140
+ user_id: str = Depends(get_current_user), # scope strictly to the caller
141
+ db: Session = Depends(get_session),
142
+ ) -> list[ChatSession]:
143
+ # Only ever return the authenticated user's own sessions. A client can no
144
+ # longer pass someone else's id to read their history (closes the IDOR).
145
+ stmt = (
146
+ select(ChatSession)
147
+ .where(ChatSession.user_id == user_id)
148
+ .order_by(ChatSession.created_at.desc())
149
+ )
150
+ return list(db.exec(stmt))
151
+
152
+
153
+ @router.get("/sessions/{session_id}/traces", response_model=list[TraceRead])
154
+ def session_traces(
155
+ session_id: str,
156
+ user_id: str = Depends(get_current_user),
157
+ db: Session = Depends(get_session),
158
+ ) -> list[TraceLog]:
159
+ chat = db.get(ChatSession, session_id)
160
+ if chat is None:
161
+ raise HTTPException(status_code=404, detail="session not found")
162
+ # ownership check: you can only read traces from a session that is yours
163
+ if chat.user_id != user_id:
164
+ raise HTTPException(status_code=403, detail="not your session")
165
+ stmt = (
166
+ select(TraceLog)
167
+ .where(TraceLog.session_id == session_id)
168
+ .order_by(TraceLog.created_at.asc())
169
+ )
170
+ return list(db.exec(stmt))
scripts/audit_data.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data-integrity linter for the TraceRAG graph.
2
+
3
+ python scripts/audit_data.py [--db memory.lbug] [--alias-threshold 0.85] [--json report.json]
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import logging
11
+ import sys
12
+ from collections import defaultdict
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from tracerag import config # noqa: E402
20
+ from tracerag.db import TraceDB # noqa: E402
21
+
22
+ logger = logging.getLogger("tracerag.audit")
23
+
24
+ _OWNABLE_TYPES = ("Service", "Repo", "Ticket", "PR", "Tool")
25
+ _OWNER_TYPES = ("Person", "Team")
26
+
27
+
28
+ def find_orphaned_nodes(db: TraceDB) -> list[dict]:
29
+ """Entities with no RELATES_TO edges (isolated in the entity graph)."""
30
+ rows = db._records(db.execute(
31
+ f"MATCH (e:{config.NODE_TABLE}) "
32
+ f"OPTIONAL MATCH (e)-[r:{config.REL_TABLE}]-(:{config.NODE_TABLE}) "
33
+ f"RETURN e.id AS id, e.label AS label, e.type AS type, count(r) AS degree;"
34
+ ))
35
+ return [r for r in rows if int(r["degree"]) == 0]
36
+
37
+
38
+ def find_conflicting_edges(db: TraceDB) -> list[dict]:
39
+ """Ownable nodes linked to more than one distinct Person/Team."""
40
+ rows = db._records(db.execute(
41
+ f"MATCH (a:{config.NODE_TABLE})-[:{config.REL_TABLE}]-(b:{config.NODE_TABLE}) "
42
+ f"WHERE a.type IN $ownable AND b.type IN $owners "
43
+ f"RETURN a.id AS aid, a.label AS alabel, a.type AS atype, "
44
+ f"b.label AS owner, b.type AS owner_type;",
45
+ {"ownable": list(_OWNABLE_TYPES), "owners": list(_OWNER_TYPES)},
46
+ ))
47
+ grouped: dict[str, dict] = {}
48
+ for r in rows:
49
+ g = grouped.setdefault(r["aid"], {
50
+ "id": r["aid"], "label": r["alabel"], "type": r["atype"], "owners": set()
51
+ })
52
+ g["owners"].add(f'{r["owner"]} ({r["owner_type"]})')
53
+ conflicts = []
54
+ for g in grouped.values():
55
+ if len(g["owners"]) > 1:
56
+ g["owners"] = sorted(g["owners"])
57
+ conflicts.append(g)
58
+ return conflicts
59
+
60
+
61
+ def find_alias_drift(db: TraceDB, threshold: float) -> list[dict]:
62
+ """Separate nodes with near-duplicate embeddings (cosine >= threshold)."""
63
+ rows = db._records(db.execute(
64
+ f"MATCH (e:{config.NODE_TABLE}) "
65
+ f"RETURN e.id AS id, e.label AS label, e.type AS type, e.embedding AS emb;"
66
+ ))
67
+ rows = [r for r in rows if r.get("emb") is not None]
68
+ if len(rows) < 2:
69
+ return []
70
+ mat = np.asarray([r["emb"] for r in rows], dtype=np.float32) # pre-normalized
71
+ sims = mat @ mat.T
72
+ pairs = []
73
+ for i in range(len(rows)):
74
+ for j in range(i + 1, len(rows)):
75
+ s = float(sims[i, j])
76
+ if s >= threshold:
77
+ pairs.append({
78
+ "a": rows[i]["label"], "a_type": rows[i]["type"],
79
+ "b": rows[j]["label"], "b_type": rows[j]["type"],
80
+ "similarity": round(s, 4),
81
+ })
82
+ pairs.sort(key=lambda p: p["similarity"], reverse=True)
83
+ return pairs
84
+
85
+
86
+ def _section(title: str, items: list, render) -> None:
87
+ print(f"\n{'=' * 70}\n {title} ({len(items)} found)\n{'-' * 70}")
88
+ if not items:
89
+ print(" (none)")
90
+ return
91
+ for it in items[:40]:
92
+ print(" " + render(it))
93
+ if len(items) > 40:
94
+ print(f" ... and {len(items) - 40} more")
95
+
96
+
97
+ def main(argv: list[str] | None = None) -> int:
98
+ p = argparse.ArgumentParser(description="TraceRAG data-integrity linter.")
99
+ p.add_argument("--db", type=Path, default=config.DB_PATH)
100
+ p.add_argument("--alias-threshold", type=float, default=config.DEEP_MERGE_THRESHOLD,
101
+ help="Cosine >= this between distinct nodes flags alias drift.")
102
+ p.add_argument("--json", type=Path, default=None, help="Also write the report as JSON.")
103
+ args = p.parse_args(argv)
104
+ logging.basicConfig(level=logging.WARNING)
105
+
106
+ db = TraceDB(args.db)
107
+ try:
108
+ total_nodes = db.count_nodes()
109
+ orphaned = find_orphaned_nodes(db)
110
+ conflicts = find_conflicting_edges(db)
111
+ drift = find_alias_drift(db, args.alias_threshold)
112
+ finally:
113
+ db.close()
114
+
115
+ print(f"\nTraceRAG data audit — {args.db} ({total_nodes} entities)")
116
+ _section("ORPHANED NODES (0 RELATES_TO edges)", orphaned,
117
+ lambda r: f'{r["label"]!r} ({r["type"]}) id={r["id"]}')
118
+ _section("CONFLICTING OWNERSHIP (>1 Person/Team)", conflicts,
119
+ lambda c: f'{c["label"]!r} ({c["type"]}) -> {", ".join(c["owners"])}')
120
+ _section(f"ALIAS DRIFT (cosine >= {args.alias_threshold})", drift,
121
+ lambda d: f'{d["similarity"]} {d["a"]!r} ({d["a_type"]}) ~ {d["b"]!r} ({d["b_type"]})')
122
+
123
+ summary = {
124
+ "db": str(args.db), "total_nodes": total_nodes,
125
+ "orphaned": orphaned, "conflicts": conflicts, "alias_drift": drift,
126
+ }
127
+ print(f"\n{'=' * 70}\n SUMMARY: {len(orphaned)} orphaned | "
128
+ f"{len(conflicts)} ownership conflicts | {len(drift)} alias-drift pairs\n")
129
+ if args.json:
130
+ args.json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
131
+ print(f"Wrote JSON report -> {args.json}")
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())
scripts/benchmark.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM-as-a-judge benchmark for the TraceRAG pipeline.
2
+
3
+ python -m scripts.benchmark --db memory.lbug --out results.csv
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import csv
10
+ import logging
11
+ import os
12
+ import sys
13
+ import time
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from tracerag import config # noqa: E402
20
+ from tracerag.db import TraceDB # noqa: E402
21
+ from tracerag.router import TraceRouter # noqa: E402
22
+ from tracerag.llm import make_client # noqa: E402
23
+
24
+ logger = logging.getLogger("tracerag.benchmark")
25
+
26
+ JUDGE_CONTEXT_CHARS = int(os.getenv("TRACERAG_JUDGE_CHARS", "100000"))
27
+ JUDGE_SLEEP_SEC = float(os.getenv("TRACERAG_JUDGE_SLEEP", "1"))
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TestQuery:
32
+ query: str
33
+ category: str
34
+
35
+
36
+ TEST_SET: list[TestQuery] = [
37
+ # semantic / conceptual
38
+ TestQuery("Explain the ShopFlow commerce platform architecture.", "semantic"),
39
+ TestQuery("What is the responsibility of the PaymentService?", "semantic"),
40
+ TestQuery("How does the AuthLayer handle login and JWT issuance?", "semantic"),
41
+ TestQuery("What is the role of the InventoryService?", "semantic"),
42
+ TestQuery("Summarize the ShopFlow microservices and what each one does.", "semantic"),
43
+ # relational / multi-hop
44
+ TestQuery("What was incident INC-4471 about?", "relational"),
45
+ TestQuery("Which component was affected in incident INC-4480?", "relational"),
46
+ TestQuery("Who is the tech lead and owning team for PaymentService?", "relational"),
47
+ TestQuery("What does payment-service depend on?", "relational"),
48
+ TestQuery("What is related to PR #847?", "relational"),
49
+ ]
50
+
51
+
52
+ def approx_tokens(text: str) -> int:
53
+ """Cheap token estimate: words * 1.3."""
54
+ return int(len(text.split()) * 1.3)
55
+
56
+
57
+ def baseline_context(db: TraceDB, node_ids: list[str]) -> str:
58
+ """Pure-vector control: concatenate the unique chunk texts of the top-k hits."""
59
+ docs_by_entity = db.documents_for_entities(node_ids)
60
+ seen, parts = set(), []
61
+ for nid in node_ids:
62
+ for d in docs_by_entity.get(nid, []):
63
+ text = (d.get("content") or "").strip()
64
+ if text and text not in seen:
65
+ seen.add(text)
66
+ parts.append(text)
67
+ return "\n\n".join(parts)
68
+
69
+
70
+ def _safe_vector_search(db: TraceDB, embedding: list[float], k: int) -> list[dict]:
71
+ try:
72
+ return db.vector_search(embedding, k=k)
73
+ except Exception as exc: # noqa: BLE001
74
+ logger.debug("baseline vector_search returned nothing (%s)", exc)
75
+ return []
76
+
77
+
78
+ def judge_sufficient(client, query: str, context: str) -> tuple[int, str]:
79
+ """LLM-as-judge: is the context relevant to answering the query? -> (1/0, raw)."""
80
+ prompt = (
81
+ f"Does the provided context contain facts relevant to answering the query? "
82
+ f"Reply YES if it includes information that helps answer it (even partially, "
83
+ f"or via connected relationships); reply NO only if the context is completely "
84
+ f"unrelated or off-topic.\n\n"
85
+ f"Note: The context contains SYSTEM TRACES (hard factual relationships "
86
+ f"between engineering entities) followed by standard DOCUMENT CHUNKS. Treat "
87
+ f"the traces as absolute facts.\n\n"
88
+ f"Output YES or NO as the very first word of your reply.\n\n"
89
+ f"Query: {query}\n\n"
90
+ f"Context:\n{context if context.strip() else '(no context retrieved)'}"
91
+ )
92
+ try:
93
+ resp = client.chat.completions.create(
94
+ model=config.OPENROUTER_MODEL,
95
+ messages=[{"role": "user", "content": prompt}],
96
+ temperature=0,
97
+ )
98
+ answer = resp.choices[0].message.content.strip().upper()
99
+ except Exception as exc: # noqa: BLE001
100
+ logger.warning("Judge LLM failed (%s); scoring as 0.", exc)
101
+ return 0, f"ERROR: {exc}"
102
+ return (1 if answer.startswith("YES") else 0), answer
103
+
104
+
105
+ def run(db_path: Path, out_path: Path, k: int) -> list[dict]:
106
+ db = TraceDB(db_path)
107
+ db.init_schema()
108
+ router = TraceRouter(db)
109
+ client = make_client()
110
+
111
+ rows: list[dict] = []
112
+ try:
113
+ for tq in TEST_SET:
114
+ # hybrid (router) arm
115
+ resp = router.route(tq.query, top_k=k)
116
+ context = router.build_context(resp.results)
117
+ intent = resp.trace_log.get("intent", {})
118
+ tokens = approx_tokens(context)
119
+
120
+ # pure-vector baseline arm
121
+ embedding = router.embed_query(tq.query)
122
+ baseline_ids = [h["id"] for h in _safe_vector_search(db, embedding, k)
123
+ if h.get("id")]
124
+ baseline_tokens = approx_tokens(baseline_context(db, baseline_ids))
125
+ reduction = (
126
+ (1 - tokens / baseline_tokens) * 100 if baseline_tokens else 0.0
127
+ )
128
+
129
+ verdict, raw = judge_sufficient(
130
+ client, tq.query, context[:JUDGE_CONTEXT_CHARS]
131
+ )
132
+ time.sleep(JUDGE_SLEEP_SEC)
133
+
134
+ rows.append({
135
+ "query": tq.query,
136
+ "category": tq.category,
137
+ "intent_type": intent.get("type"),
138
+ "alpha": intent.get("alpha"),
139
+ "beta": intent.get("beta"),
140
+ "num_docs": len(resp.results),
141
+ "context_tokens": tokens,
142
+ "baseline_tokens": baseline_tokens,
143
+ "token_reduction_pct": round(reduction, 1),
144
+ "judge": verdict,
145
+ "judge_raw": raw,
146
+ })
147
+ logger.info("[%-10s] judge=%d tokens=%4d baseline=%4d reduction=%5.1f%% %s",
148
+ tq.category, verdict, tokens, baseline_tokens, reduction, tq.query)
149
+ finally:
150
+ db.close()
151
+
152
+ _write_csv(rows, out_path)
153
+ return rows
154
+
155
+
156
+ def _write_csv(rows: list[dict], out_path: Path) -> None:
157
+ fields = ["query", "category", "intent_type", "alpha", "beta", "num_docs",
158
+ "context_tokens", "baseline_tokens", "token_reduction_pct",
159
+ "judge", "judge_raw"]
160
+ with out_path.open("w", newline="", encoding="utf-8") as f:
161
+ writer = csv.DictWriter(f, fieldnames=fields)
162
+ writer.writeheader()
163
+ writer.writerows(rows)
164
+ logger.info("Wrote %d rows -> %s", len(rows), out_path)
165
+
166
+
167
+ def print_summary(rows: list[dict]) -> None:
168
+ header = (f"{'Category':<12}{'Queries':>9}{'Hybrid Tok':>12}"
169
+ f"{'Baseline Tok':>14}{'Reduction':>11}{'Accuracy':>11}")
170
+ width = len(header)
171
+
172
+ def line(label: str, group: list[dict]) -> None:
173
+ n = len(group)
174
+ if not n:
175
+ return
176
+ hyb = sum(r["context_tokens"] for r in group) / n
177
+ base = sum(r["baseline_tokens"] for r in group) / n
178
+ red = sum(r["token_reduction_pct"] for r in group) / n
179
+ acc = sum(r["judge"] for r in group) / n
180
+ print(f"{label:<12}{n:>9}{hyb:>12.1f}{base:>14.1f}{red:>10.1f}%{acc:>11.1%}")
181
+
182
+ print("\n" + "=" * width)
183
+ print(header)
184
+ print("-" * width)
185
+ for category in ("semantic", "relational"):
186
+ line(category, [r for r in rows if r["category"] == category])
187
+ print("-" * width)
188
+ line("OVERALL", rows)
189
+ print("=" * width + "\n")
190
+
191
+
192
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
193
+ p = argparse.ArgumentParser(description="TraceRAG LLM-as-judge benchmark.")
194
+ p.add_argument("--db", type=Path, default=config.DB_PATH)
195
+ p.add_argument("--out", type=Path, default=config.RESULTS_CSV)
196
+ p.add_argument("--k", type=int, default=config.TOP_K_VECTOR,
197
+ help="Top-k entities retrieved per query.")
198
+ p.add_argument("-v", "--verbose", action="store_true")
199
+ return p.parse_args(argv)
200
+
201
+
202
+ def main(argv: list[str] | None = None) -> int:
203
+ args = parse_args(argv)
204
+ logging.basicConfig(
205
+ level=logging.DEBUG if args.verbose else logging.INFO,
206
+ format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
207
+ )
208
+ for noisy in ("httpx", "httpcore", "openai", "sentence_transformers"):
209
+ logging.getLogger(noisy).setLevel(logging.WARNING)
210
+ rows = run(args.db, args.out, args.k)
211
+ print_summary(rows)
212
+ return 0
213
+
214
+
215
+ if __name__ == "__main__":
216
+ raise SystemExit(main())
scripts/ingest.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Headless ingest entry point.
2
+
3
+ python -m scripts.ingest --datasets datasets --db memory.lbug
4
+ python scripts/ingest.py --reset
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import logging
12
+ import sys
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Iterator
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from tracerag import config # noqa: E402
20
+ from tracerag.db import TraceDB # noqa: E402
21
+ from tracerag.extract import EntityExtractor # noqa: E402
22
+ from tracerag.curation import CurationEngine, IngestStats # noqa: E402
23
+
24
+ logger = logging.getLogger("tracerag.ingest")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Document:
29
+ doc_id: str
30
+ text: str
31
+ meta: dict
32
+
33
+
34
+ def load_documents(datasets_dir: Path) -> Iterator[Document]:
35
+ if not datasets_dir.exists():
36
+ logger.warning("Datasets dir does not exist: %s", datasets_dir)
37
+ return
38
+
39
+ for path in sorted(datasets_dir.rglob("*")):
40
+ if not path.is_file():
41
+ continue
42
+ suffix = path.suffix.lower()
43
+ rel = path.relative_to(datasets_dir).as_posix()
44
+ try:
45
+ if suffix in (".md", ".markdown"):
46
+ yield _load_markdown(path, rel)
47
+ elif suffix == ".json":
48
+ yield from _load_json(path, rel)
49
+ elif suffix == ".pdf":
50
+ yield _load_pdf(path, rel)
51
+ else:
52
+ logger.debug("Skipping unsupported file: %s", rel)
53
+ except Exception as exc: # noqa: BLE001
54
+ logger.warning("Failed to load %s: %s", rel, exc)
55
+
56
+
57
+ def _load_markdown(path: Path, rel: str) -> Document:
58
+ raw = path.read_text(encoding="utf-8", errors="replace")
59
+ meta, body = {}, raw
60
+ try:
61
+ import frontmatter
62
+
63
+ post = frontmatter.loads(raw)
64
+ meta, body = dict(post.metadata), post.content
65
+ except Exception: # noqa: BLE001
66
+ pass
67
+ return Document(doc_id=rel, text=body, meta=meta)
68
+
69
+
70
+ def _load_json(path: Path, rel: str) -> Iterator[Document]:
71
+ # arrays -> one Document per record
72
+ data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
73
+ records = data if isinstance(data, list) else [data]
74
+ for i, rec in enumerate(records):
75
+ if isinstance(rec, dict):
76
+ text = "\n".join(f"{k}: {v}" for k, v in _flatten(rec).items())
77
+ doc_id = f"{rel}#{rec.get('key', rec.get('id', i))}"
78
+ else:
79
+ text, doc_id = str(rec), f"{rel}#{i}"
80
+ yield Document(doc_id=doc_id, text=text, meta={"source_file": rel})
81
+
82
+
83
+ def _load_pdf(path: Path, rel: str) -> Document:
84
+ from pypdf import PdfReader
85
+
86
+ reader = PdfReader(str(path))
87
+ text = "\n".join((page.extract_text() or "") for page in reader.pages)
88
+ return Document(doc_id=rel, text=text, meta={"pages": len(reader.pages)})
89
+
90
+
91
+ def _flatten(obj: dict, prefix: str = "") -> dict:
92
+ out: dict = {}
93
+ for k, v in obj.items():
94
+ key = f"{prefix}{k}"
95
+ if isinstance(v, dict):
96
+ out.update(_flatten(v, f"{key}."))
97
+ elif isinstance(v, list):
98
+ out[key] = ", ".join(str(x) for x in v)
99
+ else:
100
+ out[key] = v
101
+ return out
102
+
103
+
104
+ def ingest_text(
105
+ engine: CurationEngine,
106
+ extractor: EntityExtractor,
107
+ doc_id: str,
108
+ text: str,
109
+ source: str | None = None,
110
+ ) -> IngestStats:
111
+ """Run one text blob through extraction -> curation -> graph write.
112
+
113
+ Callers must invoke ``db.build_vector_index()`` once after the final document.
114
+ """
115
+ entities = extractor.extract(text)
116
+ return engine.ingest(doc_id, text, entities, source=source)
117
+
118
+
119
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
120
+ p = argparse.ArgumentParser(description="TraceRAG headless ingest.")
121
+ p.add_argument("--datasets", type=Path, default=config.DATASETS_DIR)
122
+ p.add_argument("--db", type=Path, default=config.DB_PATH)
123
+ p.add_argument("--reset", action="store_true",
124
+ help="Delete the existing .lbug file before ingesting.")
125
+ p.add_argument("--dry-run", action="store_true",
126
+ help="Extract and report entities without writing.")
127
+ p.add_argument("-v", "--verbose", action="store_true")
128
+ return p.parse_args(argv)
129
+
130
+
131
+ def main(argv: list[str] | None = None) -> int:
132
+ args = parse_args(argv)
133
+ logging.basicConfig(
134
+ level=logging.DEBUG if args.verbose else logging.INFO,
135
+ format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
136
+ )
137
+ for noisy in ("httpx", "httpcore", "openai", "sentence_transformers"):
138
+ logging.getLogger(noisy).setLevel(logging.WARNING)
139
+
140
+ if args.reset:
141
+ # remove the .lbug file plus its .wal/.lock/.tmp sidecars
142
+ for p in sorted(args.db.parent.glob(args.db.name + "*")):
143
+ logger.info("Reset: removing %s", p)
144
+ p.unlink()
145
+
146
+ extractor = EntityExtractor()
147
+ db = TraceDB(args.db)
148
+ db.init_schema()
149
+ engine = None if args.dry_run else CurationEngine(db)
150
+
151
+ totals = IngestStats()
152
+ try:
153
+ for doc in load_documents(args.datasets):
154
+ entities = extractor.extract(doc.text)
155
+ logger.info("%-40s %3d entities", doc.doc_id, len(entities))
156
+
157
+ if args.dry_run:
158
+ totals.docs += 1
159
+ totals.entities += len(entities)
160
+ continue
161
+
162
+ stats = engine.ingest(doc.doc_id, doc.text, entities)
163
+ totals.merge(stats)
164
+ logger.debug(" %s", stats)
165
+
166
+ if not args.dry_run:
167
+ db.build_vector_index()
168
+
169
+ logger.info(
170
+ "Done. %d docs, %d entities | created=%d fast=%d deep_yes=%d "
171
+ "deep_no=%d ollama=%d | rel=%d mentions=%d | nodes_in_db=%d",
172
+ totals.docs, totals.entities, totals.created, totals.fast_merged,
173
+ totals.deep_merged_yes, totals.deep_merged_no, totals.ollama_calls,
174
+ totals.relates_edges, totals.mentions_edges, db.count_nodes(),
175
+ )
176
+ finally:
177
+ db.close()
178
+ return 0
179
+
180
+
181
+ if __name__ == "__main__":
182
+ raise SystemExit(main())
scripts/ingest_github.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pull closed GitHub PRs into the TraceRAG graph.
2
+
3
+ python scripts/ingest_github.py --repo langchain-ai/langchain
4
+ python scripts/ingest_github.py --repo owner/repo --limit 100 --reset
5
+
6
+ Set GITHUB_TOKEN in .env to raise the rate limit from 60/hr to 5000/hr.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import logging
13
+ import os
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import requests
19
+ from tqdm import tqdm
20
+
21
+ _SCRIPTS = Path(__file__).resolve().parent
22
+ sys.path.insert(0, str(_SCRIPTS.parent))
23
+ sys.path.insert(0, str(_SCRIPTS))
24
+
25
+ from tracerag import config # noqa: E402
26
+ from tracerag.db import TraceDB # noqa: E402
27
+ from tracerag.extract import EntityExtractor # noqa: E402
28
+ from tracerag.curation import CurationEngine, IngestStats # noqa: E402
29
+ from ingest import ingest_text # noqa: E402
30
+
31
+ logger = logging.getLogger("tracerag.github")
32
+
33
+ GITHUB_API = "https://api.github.com"
34
+ _PER_PAGE = 100 # GitHub's max page size
35
+
36
+
37
+ _MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
38
+ _HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
39
+ _WS = re.compile(r"\s+")
40
+
41
+
42
+ def clean_pr_body(body: str | None) -> str:
43
+ """Strip markdown images and HTML comments; collapse whitespace."""
44
+ if not body:
45
+ return ""
46
+ t = _MD_IMAGE.sub(" ", body)
47
+ t = _HTML_COMMENT.sub(" ", t)
48
+ return _WS.sub(" ", t).strip()
49
+
50
+
51
+ def assemble_text(pr: dict) -> tuple[str, str]:
52
+ """Build (doc_id, clean text blob) the existing pipeline can parse."""
53
+ number = pr.get("number")
54
+ title = (pr.get("title") or "").strip()
55
+ author = (pr.get("user") or {}).get("login") or "unknown"
56
+ # rstrip trailing periods/spaces so we don't emit "...client.."
57
+ body = clean_pr_body(pr.get("body")).rstrip(". ")
58
+ text = f"PR #{number} merged by {author}. Title: {title}. Description: {body}."
59
+ return f"pr-{number}", text
60
+
61
+
62
+ def fetch_merged_prs(repo: str, limit: int, token: str | None) -> list[dict]:
63
+ """Page through closed PRs, keeping only merged ones (merged_at != null), until limit."""
64
+ headers = {
65
+ "Accept": "application/vnd.github+json",
66
+ "X-GitHub-Api-Version": "2022-11-28",
67
+ }
68
+ if token:
69
+ headers["Authorization"] = f"Bearer {token}"
70
+
71
+ merged: list[dict] = []
72
+ page = 1
73
+ while len(merged) < limit:
74
+ resp = requests.get(
75
+ f"{GITHUB_API}/repos/{repo}/pulls",
76
+ headers=headers,
77
+ params={"state": "closed", "per_page": _PER_PAGE, "page": page},
78
+ timeout=30,
79
+ )
80
+ if resp.status_code != 200:
81
+ raise RuntimeError(
82
+ f"GitHub API {resp.status_code} for {repo}: {resp.text[:200]}"
83
+ )
84
+ batch = resp.json()
85
+ if not batch:
86
+ break
87
+ merged.extend(pr for pr in batch if pr.get("merged_at"))
88
+ page += 1
89
+ return merged[:limit]
90
+
91
+
92
+ def repo_db_path(repo: str, graphs_dir: Path) -> Path:
93
+ """Per-repo .lbug file, e.g. pallets/flask -> graphs/pallets__flask.lbug."""
94
+ slug = re.sub(r"[^a-z0-9_]+", "-", repo.lower().replace("/", "__"))
95
+ return graphs_dir / f"{slug}.lbug"
96
+
97
+
98
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
99
+ p = argparse.ArgumentParser(description="Ingest closed GitHub PRs into TraceRAG.")
100
+ p.add_argument("--repo", nargs="+", required=True,
101
+ help='One or more repos, e.g. "pallets/flask psf/requests".')
102
+ p.add_argument("--limit", type=int, default=50,
103
+ help="Max MERGED PRs to ingest per repo (protects rate limits).")
104
+ p.add_argument("--db", type=Path, default=None,
105
+ help="Override output file (single-repo only; otherwise one "
106
+ "per-repo file is created under --graphs-dir).")
107
+ p.add_argument("--graphs-dir", type=Path, default=None,
108
+ help="Directory for per-repo .lbug files (default backend/graphs).")
109
+ p.add_argument("--reset", action="store_true",
110
+ help="Delete each target .lbug (+ sidecars) before ingesting.")
111
+ p.add_argument("-v", "--verbose", action="store_true")
112
+ return p.parse_args(argv)
113
+
114
+
115
+ def ingest_repo(
116
+ repo: str, db_path: Path, limit: int, reset: bool,
117
+ token: str | None, extractor: EntityExtractor,
118
+ ) -> None:
119
+ """Fetch + ingest one repo's merged PRs into its own .lbug file."""
120
+ db_path.parent.mkdir(parents=True, exist_ok=True)
121
+ if reset:
122
+ for p in sorted(db_path.parent.glob(db_path.name + "*")):
123
+ logger.info("[%s] reset: removing %s", repo, p.name)
124
+ p.unlink()
125
+
126
+ logger.info("[%s] fetching up to %d merged PRs", repo, limit)
127
+ prs = fetch_merged_prs(repo, limit, token)
128
+ logger.info("[%s] fetched %d merged PRs -> %s", repo, len(prs), db_path.name)
129
+
130
+ db = TraceDB(db_path)
131
+ db.init_schema()
132
+ engine = CurationEngine(db)
133
+ totals, skipped = IngestStats(), 0
134
+ try:
135
+ for pr in tqdm(prs, total=len(prs), desc=f"{repo}", unit="pr"):
136
+ doc_id, text = assemble_text(pr)
137
+ if not text.strip():
138
+ skipped += 1
139
+ continue
140
+ totals.merge(
141
+ ingest_text(engine, extractor, doc_id, text, source=pr.get("html_url"))
142
+ )
143
+ db.build_vector_index()
144
+ logger.info(
145
+ "[%s] done. %d PRs (%d skipped) | %d entities | "
146
+ "created=%d fast=%d deep_yes=%d deep_no=%d llm=%d | "
147
+ "rel=%d mentions=%d | nodes_in_db=%d",
148
+ repo, totals.docs, skipped, totals.entities, totals.created,
149
+ totals.fast_merged, totals.deep_merged_yes, totals.deep_merged_no,
150
+ totals.ollama_calls, totals.relates_edges, totals.mentions_edges,
151
+ db.count_nodes(),
152
+ )
153
+ finally:
154
+ db.close()
155
+
156
+
157
+ def main(argv: list[str] | None = None) -> int:
158
+ args = parse_args(argv)
159
+ logging.basicConfig(
160
+ level=logging.DEBUG if args.verbose else logging.INFO,
161
+ format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
162
+ )
163
+ for noisy in ("httpx", "httpcore", "openai", "urllib3", "sentence_transformers"):
164
+ logging.getLogger(noisy).setLevel(logging.WARNING)
165
+
166
+ token = os.getenv("GITHUB_TOKEN")
167
+ if not token:
168
+ logger.warning("GITHUB_TOKEN not set — unauthenticated requests are capped "
169
+ "at 60/hr. Add it to .env to raise the limit.")
170
+
171
+ repo_root = config.PROJECT_ROOT.parent
172
+ graphs_dir = args.graphs_dir or (config.PROJECT_ROOT / "graphs")
173
+ if not graphs_dir.is_absolute():
174
+ graphs_dir = repo_root / graphs_dir
175
+
176
+ if args.db and len(args.repo) > 1:
177
+ logger.warning("--db is ignored with multiple repos; using per-repo files.")
178
+
179
+ extractor = EntityExtractor()
180
+ for repo in args.repo:
181
+ if args.db and len(args.repo) == 1:
182
+ db_path = args.db if args.db.is_absolute() else (repo_root / args.db)
183
+ else:
184
+ db_path = repo_db_path(repo, graphs_dir)
185
+ try:
186
+ ingest_repo(repo, db_path, args.limit, args.reset, token, extractor)
187
+ except Exception as exc: # noqa: BLE001
188
+ logger.error("[%s] FAILED: %s", repo, exc)
189
+
190
+ logger.info("Batch complete. Graphs in %s", graphs_dir)
191
+ return 0
192
+
193
+
194
+ if __name__ == "__main__":
195
+ raise SystemExit(main())
scripts/ingest_jira_api.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest real Jira tickets straight from a public Jira REST API.
2
+
3
+ Pulls issues for one project (default: Apache Kafka on issues.apache.org),
4
+ maps each to the same text shape the CSV pipeline uses, and runs them through
5
+ the existing extraction -> curation -> graph pipeline.
6
+
7
+ # see what would be ingested, no DB / no GLiNER:
8
+ python scripts/ingest_jira_api.py --project KAFKA --limit 20 --dry-run
9
+
10
+ # build a fresh graph the dropdown will auto-discover:
11
+ python scripts/ingest_jira_api.py --project KAFKA --limit 150 \
12
+ --db graphs/apache__kafka.lbug --reset
13
+
14
+ Any public Jira works via --base, e.g. Apache SPARK / LUCENE / FLINK, or
15
+ Hyperledger, etc. Requires no auth for public instances.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import logging
23
+ import sys
24
+ import urllib.parse
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+ _SCRIPTS = Path(__file__).resolve().parent
29
+ sys.path.insert(0, str(_SCRIPTS.parent))
30
+ sys.path.insert(0, str(_SCRIPTS))
31
+
32
+ from tracerag import config # noqa: E402
33
+ from tracerag.db import TraceDB # noqa: E402
34
+ from tracerag.extract import EntityExtractor # noqa: E402
35
+ from tracerag.curation import CurationEngine, IngestStats # noqa: E402
36
+ from ingest import ingest_text # noqa: E402
37
+ from ingest_kaggle_jira import clean_jira_text # noqa: E402 (reuse markup stripper)
38
+
39
+ logger = logging.getLogger("tracerag.jira_api")
40
+
41
+ DEFAULT_BASE = "https://issues.apache.org/jira"
42
+ _FIELDS = "key,summary,issuetype,status,priority,assignee,reporter,components,labels,description"
43
+ _PAGE = 100 # Jira caps maxResults at 100 for most instances
44
+
45
+
46
+ def _name(obj: dict | None, key: str = "name") -> str:
47
+ """Safely pull a display string out of a nested Jira field object."""
48
+ if not isinstance(obj, dict):
49
+ return ""
50
+ return str(obj.get(key) or "").strip()
51
+
52
+
53
+ def assemble_text(issue: dict) -> tuple[str, str]:
54
+ """Build (doc_id, clean text blob) from one Jira REST issue record."""
55
+ f = issue.get("fields") or {}
56
+ key = str(issue.get("key") or "").strip()
57
+
58
+ parts: list[str] = []
59
+ summary = str(f.get("summary") or "").strip()
60
+ if key or summary:
61
+ parts.append(f"Ticket {key}: {summary}.".strip())
62
+
63
+ for label, val in (
64
+ ("Type", _name(f.get("issuetype"))),
65
+ ("Status", _name(f.get("status"))),
66
+ ("Priority", _name(f.get("priority"))),
67
+ ):
68
+ if val:
69
+ parts.append(f"{label}: {val}.")
70
+
71
+ assignee = _name(f.get("assignee"), "displayName")
72
+ if assignee:
73
+ parts.append(f"Assigned to {assignee}.")
74
+ reporter = _name(f.get("reporter"), "displayName")
75
+ if reporter:
76
+ parts.append(f"Reported by {reporter}.")
77
+
78
+ components = ", ".join(
79
+ c.get("name", "") for c in (f.get("components") or []) if c.get("name")
80
+ )
81
+ if components:
82
+ parts.append(f"Component: {components}.")
83
+ labels = ", ".join(l for l in (f.get("labels") or []) if l)
84
+ if labels:
85
+ parts.append(f"Labels: {labels}.")
86
+
87
+ desc = clean_jira_text(str(f.get("description") or ""))
88
+ if desc:
89
+ parts.append(f"Description: {desc}")
90
+
91
+ return key, " ".join(parts)
92
+
93
+
94
+ def fetch_issues(base: str, jql: str, limit: int) -> list[dict]:
95
+ """Page through the Jira search API until `limit` issues are collected."""
96
+ out: list[dict] = []
97
+ start = 0
98
+ while len(out) < limit:
99
+ page_size = min(_PAGE, limit - len(out))
100
+ params = urllib.parse.urlencode(
101
+ {"jql": jql, "startAt": start, "maxResults": page_size, "fields": _FIELDS}
102
+ )
103
+ url = f"{base.rstrip('/')}/rest/api/2/search?{params}"
104
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
105
+ logger.info("GET %s (have %d/%d)", url.split("?")[0], len(out), limit)
106
+ with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (trusted host)
107
+ data = json.loads(resp.read().decode("utf-8"))
108
+ issues = data.get("issues") or []
109
+ if not issues:
110
+ break
111
+ out.extend(issues)
112
+ total = data.get("total", 0)
113
+ start += len(issues)
114
+ if start >= total:
115
+ break
116
+ return out[:limit]
117
+
118
+
119
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
120
+ p = argparse.ArgumentParser(description="Ingest Jira tickets from a public REST API.")
121
+ p.add_argument("--project", default="KAFKA", help="Jira project key, e.g. KAFKA, SPARK.")
122
+ p.add_argument("--base", default=DEFAULT_BASE, help="Jira base URL.")
123
+ p.add_argument("--jql", default=None,
124
+ help="Override the JQL (default: project=<PROJECT> ORDER BY created DESC).")
125
+ p.add_argument("--limit", type=int, default=150, help="Number of issues to ingest.")
126
+ p.add_argument("--db", type=Path, default=config.DB_PATH)
127
+ p.add_argument("--reset", action="store_true",
128
+ help="Delete the existing .lbug (+ sidecars) before ingesting.")
129
+ p.add_argument("--dry-run", action="store_true",
130
+ help="Fetch + assemble + print samples; no GLiNER, no DB writes.")
131
+ p.add_argument("-v", "--verbose", action="store_true")
132
+ return p.parse_args(argv)
133
+
134
+
135
+ def main(argv: list[str] | None = None) -> int:
136
+ args = parse_args(argv)
137
+ logging.basicConfig(
138
+ level=logging.DEBUG if args.verbose else logging.INFO,
139
+ format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
140
+ )
141
+ for noisy in ("httpx", "httpcore", "openai", "sentence_transformers", "urllib3"):
142
+ logging.getLogger(noisy).setLevel(logging.WARNING)
143
+
144
+ jql = args.jql or f"project={args.project} ORDER BY created DESC"
145
+ logger.info("Fetching up to %d issues | %s | %s", args.limit, args.base, jql)
146
+ issues = fetch_issues(args.base, jql, args.limit)
147
+ logger.info("Fetched %d issues", len(issues))
148
+
149
+ if args.dry_run:
150
+ shown = 0
151
+ empty = 0
152
+ for issue in issues:
153
+ doc_id, text = assemble_text(issue)
154
+ if not text.strip():
155
+ empty += 1
156
+ continue
157
+ if shown < 5:
158
+ logger.info("--- %s ---\n%s", doc_id, text[:400])
159
+ shown += 1
160
+ logger.info("DRY RUN: %d ingestable, %d empty, of %d fetched",
161
+ len(issues) - empty, empty, len(issues))
162
+ return 0
163
+
164
+ if args.reset:
165
+ for p in sorted(args.db.parent.glob(args.db.name + "*")):
166
+ logger.info("Reset: removing %s", p)
167
+ p.unlink()
168
+
169
+ extractor = EntityExtractor()
170
+ db = TraceDB(args.db)
171
+ db.init_schema()
172
+ engine = CurationEngine(db)
173
+
174
+ totals = IngestStats()
175
+ skipped = 0
176
+ try:
177
+ for i, issue in enumerate(issues):
178
+ doc_id, text = assemble_text(issue)
179
+ if not text.strip():
180
+ skipped += 1
181
+ continue
182
+ totals.merge(ingest_text(engine, extractor, doc_id or f"issue-{i}", text))
183
+ if (i + 1) % 25 == 0:
184
+ logger.info(" ingested %d/%d", i + 1, len(issues))
185
+
186
+ db.build_vector_index()
187
+ logger.info(
188
+ "Done. %d issues ingested (%d skipped) | %d entities | "
189
+ "created=%d fast=%d deep_yes=%d deep_no=%d llm=%d | "
190
+ "rel=%d mentions=%d | nodes_in_db=%d",
191
+ totals.docs, skipped, totals.entities, totals.created, totals.fast_merged,
192
+ totals.deep_merged_yes, totals.deep_merged_no, totals.ollama_calls,
193
+ totals.relates_edges, totals.mentions_edges, db.count_nodes(),
194
+ )
195
+ finally:
196
+ db.close()
197
+ return 0
198
+
199
+
200
+ if __name__ == "__main__":
201
+ raise SystemExit(main())
scripts/ingest_kaggle_jira.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Batch-ingest a Jira CSV export into the TraceRAG graph.
2
+
3
+ python scripts/ingest_kaggle_jira.py --file datasets/GFG_FINAL.csv --limit 100
4
+ python scripts/ingest_kaggle_jira.py --file datasets/GFG_FINAL.csv --reset
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import logging
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import pandas as pd
16
+ from tqdm import tqdm
17
+
18
+ _SCRIPTS = Path(__file__).resolve().parent
19
+ sys.path.insert(0, str(_SCRIPTS.parent))
20
+ sys.path.insert(0, str(_SCRIPTS))
21
+
22
+ from tracerag import config # noqa: E402
23
+ from tracerag.db import TraceDB # noqa: E402
24
+ from tracerag.extract import EntityExtractor # noqa: E402
25
+ from tracerag.curation import CurationEngine, IngestStats # noqa: E402
26
+ from ingest import ingest_text # noqa: E402
27
+
28
+ logger = logging.getLogger("tracerag.kaggle")
29
+
30
+ # pandas suffixes repeated columns with .1/.2/... in wide exports
31
+ _COMPONENT_COLS = ["Component/s", "Component/s.1", "Component/s.2", "Component/s.3"]
32
+ _LABEL_COLS = ["Labels"] + [f"Labels.{i}" for i in range(1, 7)]
33
+
34
+
35
+ _CODE_BLOCK = re.compile(r"\{code(:[^}]*)?\}.*?\{code\}", re.DOTALL | re.IGNORECASE)
36
+ _NOFORMAT = re.compile(r"\{noformat\}.*?\{noformat\}", re.DOTALL | re.IGNORECASE)
37
+ _HTML_TAG = re.compile(r"<[^>]+>")
38
+ _MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]+\)") # [text](url) -> text
39
+ _JIRA_LINK = re.compile(r"\[([^|\]]+)\|[^\]]+\]") # [text|url] -> text
40
+ _BRACE_MACRO = re.compile(r"\{[^}]*\}") # leftover {quote}/{panel}/{color}
41
+ _URL = re.compile(r"https?://\S+")
42
+ _WS = re.compile(r"\s+")
43
+
44
+
45
+ def clean_jira_text(raw: str) -> str:
46
+ """Strip HTML, markdown/Jira links, {code}/{noformat}/{macro} blocks and URLs."""
47
+ if not raw:
48
+ return ""
49
+ t = _CODE_BLOCK.sub(" ", raw)
50
+ t = _NOFORMAT.sub(" ", t)
51
+ t = _HTML_TAG.sub(" ", t)
52
+ t = _MD_LINK.sub(r"\1", t)
53
+ t = _JIRA_LINK.sub(r"\1", t)
54
+ t = _BRACE_MACRO.sub(" ", t)
55
+ t = _URL.sub(" ", t)
56
+ return _WS.sub(" ", t).strip()
57
+
58
+
59
+ def _combine(row: pd.Series, cols: list[str]) -> str:
60
+ """Join distinct, non-empty values from a set of (possibly repeated) columns."""
61
+ seen, out = set(), []
62
+ for c in cols:
63
+ v = str(row.get(c, "")).strip()
64
+ if v and v.lower() != "nan" and v not in seen:
65
+ seen.add(v)
66
+ out.append(v)
67
+ return ", ".join(out)
68
+
69
+
70
+ def assemble_text(row: pd.Series) -> tuple[str, str]:
71
+ """Build (doc_id, clean text blob) the existing pipeline can parse."""
72
+ def f(col: str) -> str:
73
+ v = str(row.get(col, "")).strip()
74
+ return "" if v.lower() == "nan" else v
75
+
76
+ key = f("Issue key")
77
+ parts: list[str] = []
78
+ summary = f("Summary")
79
+ if key or summary:
80
+ parts.append(f"Ticket {key}: {summary}.".strip())
81
+ for label, col in (("Type", "Issue Type"), ("Status", "Status"),
82
+ ("Priority", "Priority"), ("Project", "Project name")):
83
+ if f(col):
84
+ parts.append(f"{label}: {f(col)}.")
85
+ if f("Assignee"):
86
+ parts.append(f"Assigned to {f('Assignee')}.")
87
+ if f("Reporter"):
88
+ parts.append(f"Reported by {f('Reporter')}.")
89
+ components = _combine(row, _COMPONENT_COLS)
90
+ if components:
91
+ parts.append(f"Component: {components}.")
92
+ labels = _combine(row, _LABEL_COLS)
93
+ if labels:
94
+ parts.append(f"Labels: {labels}.")
95
+ desc = clean_jira_text(f("Description"))
96
+ if desc:
97
+ parts.append(f"Description: {desc}")
98
+ return key, " ".join(parts)
99
+
100
+
101
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
102
+ p = argparse.ArgumentParser(description="Ingest a Jira CSV export into TraceRAG.")
103
+ p.add_argument("--file", type=Path, required=True, help="Path to the Jira CSV.")
104
+ p.add_argument("--limit", type=int, default=None,
105
+ help="Ingest only the first N rows (for quick tests).")
106
+ p.add_argument("--db", type=Path, default=config.DB_PATH)
107
+ p.add_argument("--reset", action="store_true",
108
+ help="Delete the existing .lbug (+ sidecars) before ingesting.")
109
+ p.add_argument("-v", "--verbose", action="store_true")
110
+ return p.parse_args(argv)
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ args = parse_args(argv)
115
+ logging.basicConfig(
116
+ level=logging.DEBUG if args.verbose else logging.INFO,
117
+ format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
118
+ )
119
+ for noisy in ("httpx", "httpcore", "openai", "sentence_transformers"):
120
+ logging.getLogger(noisy).setLevel(logging.WARNING)
121
+
122
+ if args.reset:
123
+ for p in sorted(args.db.parent.glob(args.db.name + "*")):
124
+ logger.info("Reset: removing %s", p)
125
+ p.unlink()
126
+
127
+ logger.info("Reading %s", args.file)
128
+ df = pd.read_csv(args.file, low_memory=False).fillna("")
129
+ if args.limit:
130
+ df = df.head(args.limit)
131
+ logger.info("Loaded %d rows, %d columns", len(df), df.shape[1])
132
+
133
+ extractor = EntityExtractor()
134
+ db = TraceDB(args.db)
135
+ db.init_schema()
136
+ engine = CurationEngine(db)
137
+
138
+ totals = IngestStats()
139
+ skipped = 0
140
+ try:
141
+ for i, row in tqdm(df.iterrows(), total=len(df), desc="ingesting jira", unit="ticket"):
142
+ doc_id, text = assemble_text(row)
143
+ if not text.strip():
144
+ skipped += 1
145
+ continue
146
+ totals.merge(ingest_text(engine, extractor, doc_id or f"row-{i}", text))
147
+
148
+ db.build_vector_index()
149
+ logger.info(
150
+ "Done. %d rows ingested (%d skipped) | %d entities | "
151
+ "created=%d fast=%d deep_yes=%d deep_no=%d llm=%d | "
152
+ "rel=%d mentions=%d | nodes_in_db=%d",
153
+ totals.docs, skipped, totals.entities, totals.created, totals.fast_merged,
154
+ totals.deep_merged_yes, totals.deep_merged_no, totals.ollama_calls,
155
+ totals.relates_edges, totals.mentions_edges, db.count_nodes(),
156
+ )
157
+ finally:
158
+ db.close()
159
+ return 0
160
+
161
+
162
+ if __name__ == "__main__":
163
+ raise SystemExit(main())
scripts/profile_route.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Profile TraceRouter.route() latency phase-by-phase.
2
+
3
+ python scripts/profile_route.py --db graphs/pallets__flask.lbug
4
+ python scripts/profile_route.py --db graphs/pydantic__pydantic.lbug -q "what changed in validation"
5
+
6
+ LadybugDB is single-writer, so point --db at a graph the running server isn't serving.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import logging
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from tracerag.db import TraceDB # noqa: E402
20
+ from tracerag.router import TraceRouter # noqa: E402
21
+
22
+ # mix of marker and keyword-less queries to exercise both LLM and LLM-free phases
23
+ DEFAULT_QUERIES = [
24
+ "who reviewed the async changes", # relational marker -> no intent LLM
25
+ "what did the maintainers work on", # keyword-less -> intent LLM fires
26
+ "explain the recent changes", # semantic marker -> no intent LLM
27
+ ]
28
+
29
+
30
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
31
+ p = argparse.ArgumentParser(description="Profile route() latency per phase.")
32
+ p.add_argument("--db", type=Path, default=Path("graphs/pallets__flask.lbug"))
33
+ p.add_argument("-q", "--query", action="append",
34
+ help="Query to run (repeatable). Defaults to a built-in mix.")
35
+ p.add_argument("--top-k", type=int, default=10)
36
+ return p.parse_args(argv)
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ args = parse_args(argv)
41
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
42
+ for noisy in ("httpx", "httpcore", "openai", "sentence_transformers",
43
+ "numexpr.utils", "gliner", "sentence_transformers.SentenceTransformer"):
44
+ logging.getLogger(noisy).setLevel(logging.WARNING)
45
+
46
+ try:
47
+ db = TraceDB(args.db)
48
+ except Exception as exc: # noqa: BLE001
49
+ print(f"[profile] could not open {args.db}: {exc}")
50
+ print("[profile] is the uvicorn server serving this same graph? "
51
+ "Point --db at a different graphs/*.lbug or stop the server.")
52
+ return 1
53
+
54
+ router = TraceRouter(db)
55
+ print(f"[profile] db={args.db} nodes={db.count_nodes()}")
56
+ print("[profile] warming embedder (one-time model load)...")
57
+ t = time.perf_counter()
58
+ router.embed_query("warmup")
59
+ print(f"[profile] embedder warm in {(time.perf_counter() - t) * 1000:.0f} ms\n")
60
+
61
+ queries = args.query or DEFAULT_QUERIES
62
+ try:
63
+ for q in queries:
64
+ t = time.perf_counter()
65
+ resp = router.route(q, top_k=args.top_k)
66
+ wall = (time.perf_counter() - t) * 1000
67
+ print(f" >> '{q}' wall={wall:.0f} ms, {len(resp.results)} results\n")
68
+ finally:
69
+ db.close()
70
+ return 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main())
scripts/ratelimit_test.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Burst test that verifies the slowapi per-user rate limit on /api/answer.
2
+
3
+ Unlike scripts/stress_test.py (which THROTTLES with a semaphore to measure
4
+ graceful degradation), this script does the opposite: it fires every request at
5
+ the *same instant* -- a thundering herd -- to prove the limiter admits exactly N
6
+ and blocks the rest in a single fixed window.
7
+
8
+ Expected for --burst 25 --limit 10: 10 x HTTP 200 + 15 x HTTP 429
9
+
10
+ ------------------------------------------------------------------------------
11
+ HOW TO RUN
12
+ ------------------------------------------------------------------------------
13
+ 1. The server MUST run single-worker (uvicorn's default). slowapi's in-memory
14
+ counter is per-process; with --workers N the burst is split across N
15
+ independent buckets and far more than `limit` requests pass. So:
16
+
17
+ uvicorn api:app --port 8000 # (no --workers flag)
18
+
19
+ 2. Mode B -- dev-bypass (DEFAULT, for CI): leave CLERK_ISSUER unset on the
20
+ server. Every request maps to the synthetic 'dev-user', so all 25 share one
21
+ bucket. No token needed:
22
+
23
+ python scripts/ratelimit_test.py
24
+
25
+ 3. Mode A -- real Clerk JWT (manual end-to-end check): grab a fresh session
26
+ token from the browser (it's short-lived, ~60s) and pass it:
27
+
28
+ python scripts/ratelimit_test.py --token "eyJhbGci..."
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import asyncio
35
+ import time
36
+ from collections import Counter
37
+
38
+ import aiohttp
39
+
40
+
41
+ async def _fire(
42
+ session: aiohttp.ClientSession,
43
+ url: str,
44
+ payload: dict,
45
+ timeout: float,
46
+ start_gun: asyncio.Event,
47
+ idx: int,
48
+ ) -> dict:
49
+ """One request that waits at the barrier, then fires the instant it opens.
50
+
51
+ The `await start_gun.wait()` is the concurrency barrier: every worker parks
52
+ here until the main coroutine fires the starting gun, so all `burst` requests
53
+ leave the line on the SAME event-loop tick instead of trickling out as each
54
+ task gets scheduled. That true simultaneity is what makes this a real burst
55
+ test of the fixed window (a semaphore would serialize them and hide the seam).
56
+ """
57
+ await start_gun.wait()
58
+ t0 = time.perf_counter()
59
+ try:
60
+ # NOTE: aiohttp does NOT raise on 4xx/5xx unless raise_for_status=True,
61
+ # which we deliberately omit -- a 429 is the EXPECTED result here, not an
62
+ # error. We just read the status code off the response.
63
+ async with session.post(url, json=payload, timeout=timeout) as resp:
64
+ await resp.read() # drain the body so the connection is freed
65
+ return {"idx": idx, "status": resp.status,
66
+ "latency": time.perf_counter() - t0}
67
+ except asyncio.TimeoutError:
68
+ # graceful: a slow/blocked request becomes a recorded outcome, not a crash
69
+ return {"idx": idx, "status": "timeout",
70
+ "latency": time.perf_counter() - t0}
71
+ except aiohttp.ClientError as exc:
72
+ return {"idx": idx, "status": f"error:{type(exc).__name__}",
73
+ "latency": time.perf_counter() - t0}
74
+
75
+
76
+ async def run(url: str, token: str | None, burst: int, limit: int,
77
+ timeout: float) -> bool:
78
+ """Fire `burst` simultaneous requests; return True if the limit held exactly."""
79
+ # --- identity: one token (or dev-bypass) => one rate-limit bucket ---
80
+ headers: dict[str, str] = {}
81
+ if token:
82
+ headers["Authorization"] = f"Bearer {token}"
83
+ mode = "Mode A -- real Clerk JWT (--token)"
84
+ else:
85
+ # No header -> server runs every request as the synthetic 'dev-user',
86
+ # so the whole burst still shares ONE bucket. Perfect for CI.
87
+ mode = "Mode B -- dev-bypass (synthetic 'dev-user', no token)"
88
+
89
+ # Empty context deliberately: /api/answer returns 200 instantly for empty
90
+ # context WITHOUT calling the LLM. The rate-limit decorator runs BEFORE the
91
+ # endpoint body, so the 429s still fire -- we prove the limiter for zero LLM
92
+ # cost. (Set a real context only if you want to exercise the answer path.)
93
+ payload = {"query": "rate-limit burst probe", "context": ""}
94
+
95
+ expected_pass = limit
96
+ expected_block = burst - limit
97
+
98
+ print(f"Bursting {burst} simultaneous requests at {url}")
99
+ print(f" {mode}")
100
+ print(f" expecting {expected_pass} x 200 and {expected_block} x 429\n")
101
+ print(" [!] Server MUST be single-worker (uvicorn default, NO --workers N)")
102
+ print(" - slowapi's in-memory bucket is per-process; multiple workers")
103
+ print(" mean multiple buckets and more than `limit` requests pass.\n")
104
+
105
+ start_gun = asyncio.Event()
106
+
107
+ async with aiohttp.ClientSession(headers=headers) as session:
108
+ # Create all tasks first. Each immediately parks on `start_gun.wait()`.
109
+ tasks = [
110
+ asyncio.create_task(_fire(session, url, payload, timeout, start_gun, i))
111
+ for i in range(burst)
112
+ ]
113
+ # Yield control so EVERY task gets scheduled and reaches the barrier
114
+ # before we fire. Without this beat, set() could race ahead of tasks
115
+ # that haven't hit `await start_gun.wait()` yet.
116
+ await asyncio.sleep(0.1)
117
+
118
+ wall_start = time.perf_counter()
119
+ start_gun.set() # FIRE: all `burst` requests go now
120
+ results = await asyncio.gather(*tasks)
121
+ wall = time.perf_counter() - wall_start
122
+
123
+ # --- tally outcomes ---
124
+ statuses = Counter(r["status"] for r in results)
125
+ n_200 = statuses.get(200, 0)
126
+ n_429 = statuses.get(429, 0)
127
+ lat = sorted(r["latency"] for r in results)
128
+
129
+ print("=" * 60)
130
+ print(f"{'Status / outcome':<28}{'count':>10}")
131
+ print("-" * 60)
132
+ for status, n in sorted(statuses.items(), key=lambda kv: str(kv[0])):
133
+ print(f"{str(status):<28}{n:>10}")
134
+ print("-" * 60)
135
+ print(f"wall time {wall:>8.3f}s")
136
+ print(f"200 (admitted) {n_200:>8} / expected {expected_pass}")
137
+ print(f"429 (blocked) {n_429:>8} / expected {expected_block}")
138
+ if lat:
139
+ print(f"latency min/max {lat[0]:.3f} / {lat[-1]:.3f} s")
140
+ print("=" * 60)
141
+
142
+ # --- verdict ---
143
+ passed = (n_200 == expected_pass and n_429 == expected_block)
144
+ if passed:
145
+ print(f"\n[PASS] Limiter held exactly: {expected_pass}x200, "
146
+ f"{expected_block}x429. The 429 path is working.")
147
+ return True
148
+
149
+ print("\n[FAIL] Counts did not match. Likely causes, in order:")
150
+ # Diagnostics ranked by how often each is the real culprit.
151
+ if n_200 > expected_pass:
152
+ print(f" - {n_200} passed (> {expected_pass}). Either the server is "
153
+ f"running MULTIPLE WORKERS (each with its own in-memory bucket),")
154
+ print(f" or -- far less likely at this speed -- the burst straddled a "
155
+ f"60s fixed-window boundary. Re-run; if it persists it's workers.")
156
+ if any(str(s).startswith(("error", "timeout")) for s in statuses):
157
+ print(" - Hard failures (timeout/conn-error) present -- is the server up "
158
+ "at this URL, and is the timeout high enough?")
159
+ if n_200 == 0 and n_429 == 0:
160
+ print(" - Nothing got through at all -- check the URL and that the API "
161
+ "is actually listening.")
162
+ if n_429 == 0 and n_200 < burst:
163
+ print(" - No 429s seen -- is slowapi actually wired (app.state.limiter + "
164
+ "@limiter.limit on /api/answer)?")
165
+ return False
166
+
167
+
168
+ def main(argv: list[str] | None = None) -> int:
169
+ p = argparse.ArgumentParser(description="Verify the per-user rate limit on /api/answer.")
170
+ p.add_argument("--url", default="http://127.0.0.1:8000/api/answer")
171
+ p.add_argument("--token", default=None,
172
+ help="Clerk JWT for Mode A; omit for Mode B dev-bypass.")
173
+ p.add_argument("--burst", type=int, default=25,
174
+ help="total simultaneous requests (default 25)")
175
+ p.add_argument("--limit", type=int, default=10,
176
+ help="the server's per-minute cap, i.e. expected 200s (default 10)")
177
+ p.add_argument("--timeout", type=float, default=30.0)
178
+ args = p.parse_args(argv)
179
+
180
+ ok = asyncio.run(run(args.url, args.token, args.burst, args.limit, args.timeout))
181
+ # exit code 0 on PASS, 1 on FAIL -- so CI (GitHub Actions) fails the job loudly.
182
+ return 0 if ok else 1
183
+
184
+
185
+ if __name__ == "__main__":
186
+ raise SystemExit(main())
scripts/stress_graph.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Direct stress test of the GraphRAG retrieval core against ONE graph.
3
+
4
+ Hammers TraceRouter.route() concurrently (no HTTP, no auth, no answer-LLM) with
5
+ a battery of valid + adversarial + fuzz queries to surface crashes, injection
6
+ issues, encoding bugs, latency outliers, thread-safety problems, and correctness
7
+ regressions on a new graph before it goes on camera.
8
+
9
+ python scripts/stress_graph.py --db graphs/apache__kafka.lbug \
10
+ --iterations 400 --concurrency 16
11
+
12
+ Hermetic: the intent-classifier LLM fallback is stubbed, so no billed calls and
13
+ no network — what's measured is purely vector + graph retrieval over the graph.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ import time
20
+ import traceback
21
+ from collections import Counter, defaultdict
22
+ from concurrent.futures import ThreadPoolExecutor, as_completed
23
+ from pathlib import Path
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26
+
27
+ from tracerag import config # noqa: E402
28
+ from tracerag.db import TraceDB # noqa: E402
29
+ from tracerag.router import TraceRouter # noqa: E402
30
+
31
+ # (label, query, category, expect_hits)
32
+ # valid -> must return results; we also spot-check correctness
33
+ # offdomain-> should gracefully return few/none, never crash
34
+ # fuzz -> must NOT crash; result count irrelevant
35
+ BATTERY: list[tuple[str, str, str, bool]] = [
36
+ # --- valid relational (the real demo questions) ---
37
+ ("hero", "What is related to KAFKA-20688?", "valid", True),
38
+ ("ticket2", "What is related to KAFKA-20570?", "valid", True),
39
+ ("person1", "What did Matthias J. Sax work on?", "valid", True),
40
+ ("person2", "What did Alieh Saeedi work on?", "valid", True),
41
+ ("person_acc", "What did José Armando García Sancio work on?", "valid", True),
42
+ ("lib1", "What is connected to Kafka Streams?", "valid", True),
43
+ ("lib2", "What is related to streams?", "valid", True),
44
+ ("kip", "What is KIP-1301 about?", "valid", True),
45
+ # --- semantic (intent stubbed, exercises vector arm) ---
46
+ ("sem1", "Explain the recent RocksDB state-store changes.", "valid", True),
47
+ ("sem2", "How does Kafka handle exactly-once semantics?", "valid", True),
48
+ # --- off-domain: should degrade gracefully ---
49
+ ("off1", "How does the payment gateway handle refunds?", "offdomain", False),
50
+ ("off2", "Best recipe for sourdough bread", "offdomain", False),
51
+ ("off3", "What is the capital of France?", "offdomain", False),
52
+ # --- fuzz / adversarial: must not crash ---
53
+ ("empty", "", "fuzz", False),
54
+ ("spaces", " ", "fuzz", False),
55
+ ("one_char", "a", "fuzz", False),
56
+ ("digits", "1234567890", "fuzz", False),
57
+ ("emoji", "🔥🔥 what is related to streams 🔥🔥", "fuzz", False),
58
+ ("accents", "Què està relacionat amb José Armando García Sancio?", "fuzz", False),
59
+ ("cypher_inj", "x'); MATCH (n) DETACH DELETE n; //", "fuzz", False),
60
+ ("sql_inj", "Robert'); DROP TABLE Entity;-- ", "fuzz", False),
61
+ ("tmpl_inj", "{{7*7}} ${jndi:ldap://x} <script>alert(1)</script>", "fuzz", False),
62
+ ("path_trav", "../../../../etc/passwd related to KAFKA", "fuzz", False),
63
+ ("nullbyte", "KAFKA-20688\x00\x00 related to", "fuzz", False),
64
+ ("ctrl_chars", "rel\tated\r\n to\x07 streams", "fuzz", False),
65
+ ("very_long", ("KAFKA related to streams " * 2000), "fuzz", False),
66
+ ("unicode_zw", "what​ is​ related​ to​ streams", "fuzz", False),
67
+ ("quotes", "\"'`related`'\" to 'streams'", "fuzz", False),
68
+ ]
69
+
70
+
71
+ def stub_intent(router: TraceRouter) -> None:
72
+ """Make intent classification hermetic: never hit the network/LLM."""
73
+ w = config.ROUTER_WEIGHTS_CONCEPTUAL
74
+ router._classify_intent_llm = lambda q: (w["vector"], w["graph"], "semantic")
75
+
76
+
77
+ def correctness_checks(router: TraceRouter, log: list[str]) -> int:
78
+ """Single-threaded spot-checks. Returns number of FAILURES."""
79
+ fails = 0
80
+ resp = router.route("What is related to KAFKA-20688?")
81
+ labels = {(r.label or "") for r in resp.results}
82
+ log.append("hero result labels: " + repr(sorted(l for l in labels if l)[:15]))
83
+ if "KAFKA-20688" not in labels:
84
+ log.append(" FAIL: hero query did not surface KAFKA-20688 itself")
85
+ fails += 1
86
+ # at least one real human / component neighbor should ride along
87
+ if len(resp.results) < 3:
88
+ log.append(" FAIL: hero query returned < 3 results (%d)" % len(resp.results))
89
+ fails += 1
90
+
91
+ resp2 = router.route("What did Matthias J. Sax work on?")
92
+ if not resp2.results:
93
+ log.append(" FAIL: person query returned 0 results")
94
+ fails += 1
95
+
96
+ # accented name must round-trip with no replacement char
97
+ resp3 = router.route("What did José Armando García Sancio work on?")
98
+ joined = " ".join((r.label or "") for r in resp3.results)
99
+ if "�" in joined:
100
+ log.append(" FAIL: accented-name query produced U+FFFD in results")
101
+ fails += 1
102
+ log.append("correctness failures: %d" % fails)
103
+ return fails
104
+
105
+
106
+ def main(argv: list[str] | None = None) -> int:
107
+ p = argparse.ArgumentParser(description="Direct GraphRAG retrieval stress test.")
108
+ p.add_argument("--db", type=Path, default=Path("graphs/apache__kafka.lbug"))
109
+ p.add_argument("--iterations", type=int, default=400)
110
+ p.add_argument("--concurrency", type=int, default=16)
111
+ p.add_argument("--timeout", type=float, default=20.0, help="Per-call soft budget (s).")
112
+ args = p.parse_args(argv)
113
+
114
+ if not args.db.exists():
115
+ print("ERROR: graph not found:", args.db)
116
+ return 2
117
+
118
+ print("Loading %s ..." % args.db)
119
+ db = TraceDB(args.db)
120
+ db.init_schema()
121
+ router = TraceRouter(db)
122
+ stub_intent(router)
123
+ try:
124
+ router.warm()
125
+ except Exception as exc: # noqa: BLE001
126
+ print("warm() skipped:", exc)
127
+
128
+ log: list[str] = []
129
+ print("Running correctness checks ...")
130
+ corr_fails = correctness_checks(router, log)
131
+
132
+ n = args.iterations
133
+ jobs = [BATTERY[i % len(BATTERY)] for i in range(n)]
134
+
135
+ lat_by_cat: dict[str, list[float]] = defaultdict(list)
136
+ empty_by_cat: dict[str, int] = defaultdict(int)
137
+ errors: list[tuple[str, str, str]] = [] # (category, query[:60], traceback-last-line)
138
+ slow: list[tuple[float, str]] = []
139
+ statuses: Counter = Counter()
140
+
141
+ def one(job: tuple[str, str, str, bool]) -> dict:
142
+ _label, query, cat, _expect = job
143
+ t0 = time.perf_counter()
144
+ try:
145
+ resp = router.route(query)
146
+ dt = time.perf_counter() - t0
147
+ return {"cat": cat, "dt": dt, "n": len(resp.results), "query": query, "err": None}
148
+ except Exception: # noqa: BLE001 — capture, don't abort the run
149
+ dt = time.perf_counter() - t0
150
+ tb = traceback.format_exc().strip().splitlines()[-1]
151
+ return {"cat": cat, "dt": dt, "n": -1, "query": query, "err": tb}
152
+
153
+ print("Hammering: %d calls, concurrency=%d ...\n" % (n, args.concurrency))
154
+ wall0 = time.perf_counter()
155
+ with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
156
+ futs = [pool.submit(one, j) for j in jobs]
157
+ for f in as_completed(futs):
158
+ r = f.result()
159
+ if r["err"]:
160
+ statuses["error"] += 1
161
+ errors.append((r["cat"], r["query"][:60], r["err"]))
162
+ else:
163
+ statuses["ok"] += 1
164
+ lat_by_cat[r["cat"]].append(r["dt"])
165
+ if r["n"] == 0:
166
+ empty_by_cat[r["cat"]] += 1
167
+ if r["dt"] > args.timeout:
168
+ slow.append((r["dt"], r["query"][:60]))
169
+ wall = time.perf_counter() - wall0
170
+
171
+ all_lat = sorted(v for vs in lat_by_cat.values() for v in vs)
172
+
173
+ def pct(pp: float) -> float:
174
+ return all_lat[min(len(all_lat) - 1, int(len(all_lat) * pp))] if all_lat else 0.0
175
+
176
+ # ---- write full detail (UTF-8) ----
177
+ detail = list(log)
178
+ detail.append("")
179
+ detail.append("=== latency by category (count, p50, p90, max) ===")
180
+ for cat, vs in sorted(lat_by_cat.items()):
181
+ s = sorted(vs)
182
+ detail.append(" %-10s n=%-4d p50=%.3f p90=%.3f max=%.3f empty=%d" % (
183
+ cat, len(s), s[len(s)//2], s[min(len(s)-1, int(len(s)*0.9))], s[-1],
184
+ empty_by_cat.get(cat, 0)))
185
+ detail.append("")
186
+ detail.append("=== errors (%d) ===" % len(errors))
187
+ for cat, q, tb in errors[:40]:
188
+ detail.append(" [%s] %r -> %s" % (cat, q, tb))
189
+ Path("scripts/_stress_graph.out.txt").write_text("\n".join(detail), encoding="utf-8")
190
+
191
+ # ---- console summary (ASCII-safe) ----
192
+ bar = "=" * 60
193
+ print(bar)
194
+ print("STRESS SUMMARY db=%s" % args.db.name)
195
+ print(bar)
196
+ print("calls %d (ok=%d, error=%d)" % (n, statuses["ok"], statuses["error"]))
197
+ print("wall time %.2fs throughput %.0f calls/s" % (wall, n / wall if wall else 0))
198
+ print("latency p50/p90 %.3f / %.3f s" % (pct(.5), pct(.9)))
199
+ print("latency p99/max %.3f / %.3f s" % (pct(.99), all_lat[-1] if all_lat else 0))
200
+ print("correctness %s (%d failures)" % ("PASS" if corr_fails == 0 else "FAIL", corr_fails))
201
+ print("empty results valid=%d offdomain=%d fuzz=%d" % (
202
+ empty_by_cat.get("valid", 0), empty_by_cat.get("offdomain", 0),
203
+ empty_by_cat.get("fuzz", 0)))
204
+ print("slow (> %.0fs) %d" % (args.timeout, len(slow)))
205
+ print(bar)
206
+
207
+ hard_fail = statuses["error"] > 0 or corr_fails > 0 or empty_by_cat.get("valid", 0) > 0
208
+ if statuses["error"]:
209
+ print("\n[!] %d calls threw exceptions (crash/injection/thread-safety):" % statuses["error"])
210
+ for cat, q, tb in errors[:8]:
211
+ print(" [%s] %r" % (cat, q))
212
+ print(" %s" % tb)
213
+ if empty_by_cat.get("valid", 0):
214
+ print("\n[!] %d VALID demo queries returned ZERO results -- not demo-safe."
215
+ % empty_by_cat["valid"])
216
+ if not hard_fail:
217
+ print("\n[ok] No crashes. Correctness passed. Every valid query returned hits.")
218
+ print(" Fuzz/injection inputs degraded gracefully (no exceptions).")
219
+ print("\nFull detail -> scripts/_stress_graph.out.txt")
220
+ db.close()
221
+ return 1 if hard_fail else 0
222
+
223
+
224
+ if __name__ == "__main__":
225
+ raise SystemExit(main())
scripts/stress_test.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Async load test for the TraceRAG FastAPI endpoint.
2
+
3
+ Start the API first (uvicorn api:app --port 8000), then:
4
+
5
+ python scripts/stress_test.py --requests 200 --concurrency 25
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import asyncio
12
+ import time
13
+ from collections import Counter
14
+
15
+ import aiohttp
16
+
17
+ QUERIES = [
18
+ "What was incident INC-4471 about?",
19
+ "What is the responsibility of the PaymentService?",
20
+ "Who is the tech lead and owning team for PaymentService?",
21
+ "What does payment-service depend on?",
22
+ "How does the AuthLayer handle login and JWT issuance?",
23
+ "Explain the ShopFlow commerce platform architecture.",
24
+ "What is related to PR #847?",
25
+ "Which component was affected in incident INC-4480?",
26
+ ]
27
+
28
+
29
+ async def _one(session, url, query, sem, timeout) -> dict:
30
+ async with sem:
31
+ start = time.perf_counter()
32
+ try:
33
+ async with session.post(url, json={"query": query}, timeout=timeout) as resp:
34
+ await resp.read() # drain
35
+ return {"status": resp.status, "latency": time.perf_counter() - start}
36
+ except asyncio.TimeoutError:
37
+ return {"status": "timeout", "latency": time.perf_counter() - start}
38
+ except aiohttp.ClientError as exc:
39
+ return {"status": f"error:{type(exc).__name__}", "latency": time.perf_counter() - start}
40
+
41
+
42
+ async def run(url: str, total: int, concurrency: int, timeout: float) -> None:
43
+ sem = asyncio.Semaphore(concurrency)
44
+ print(f"Hammering {url}\n {total} requests, concurrency={concurrency}, "
45
+ f"timeout={timeout}s\n")
46
+ wall_start = time.perf_counter()
47
+ async with aiohttp.ClientSession() as session:
48
+ tasks = [
49
+ _one(session, url, QUERIES[i % len(QUERIES)], sem, timeout)
50
+ for i in range(total)
51
+ ]
52
+ results = await asyncio.gather(*tasks)
53
+ wall = time.perf_counter() - wall_start
54
+
55
+ statuses = Counter(r["status"] for r in results)
56
+ ok = [r["latency"] for r in results if r["status"] == 200]
57
+ lat = sorted(r["latency"] for r in results)
58
+
59
+ def pct(p: float) -> float:
60
+ return lat[min(len(lat) - 1, int(len(lat) * p))] if lat else 0.0
61
+
62
+ print("=" * 60)
63
+ print(f"{'Status / outcome':<28}{'count':>10}")
64
+ print("-" * 60)
65
+ for status, n in sorted(statuses.items(), key=lambda kv: str(kv[0])):
66
+ print(f"{str(status):<28}{n:>10}")
67
+ print("-" * 60)
68
+ print(f"wall time {wall:>8.2f}s")
69
+ print(f"throughput {total / wall:>8.1f} req/s")
70
+ print(f"success (200) {len(ok):>8} / {total}")
71
+ print(f"latency p50/p90/p99 {pct(.5):.3f} / {pct(.9):.3f} / {pct(.99):.3f} s")
72
+ print(f"latency max {(lat[-1] if lat else 0):.3f} s")
73
+ print("=" * 60)
74
+ if any(str(s).startswith(("error", "timeout")) or s in (500, 502) for s in statuses):
75
+ print("\n[!] Hard failures present (5xx/timeout/conn-error): the worker may "
76
+ "not be degrading gracefully — check for crashes or DB read-locks.")
77
+ if statuses.get(429) or statuses.get(503):
78
+ print("\n[ok] Server shed load with 429/503 under pressure (graceful).")
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ p = argparse.ArgumentParser(description="TraceRAG async load test.")
83
+ p.add_argument("--url", default="http://127.0.0.1:8000/api/trace")
84
+ p.add_argument("--requests", type=int, default=100)
85
+ p.add_argument("--concurrency", type=int, default=20)
86
+ p.add_argument("--timeout", type=float, default=30.0)
87
+ args = p.parse_args(argv)
88
+ asyncio.run(run(args.url, args.requests, args.concurrency, args.timeout))
89
+ return 0
90
+
91
+
92
+ if __name__ == "__main__":
93
+ raise SystemExit(main())
tracerag/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Local-first GraphRAG curation and debugging layer."""
2
+
3
+ __version__ = "0.1.0"
tracerag/config.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central configuration for TraceRAG."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
9
+
10
+ # load local .env if present; real env vars still win
11
+ try:
12
+ from dotenv import load_dotenv
13
+
14
+ load_dotenv(PROJECT_ROOT / ".env")
15
+ except ImportError:
16
+ pass
17
+
18
+ DB_PATH = Path(os.getenv("TRACERAG_DB_PATH", PROJECT_ROOT / "memory.lbug"))
19
+ DATASETS_DIR = Path(os.getenv("TRACERAG_DATASETS_DIR", PROJECT_ROOT / "datasets"))
20
+ RESULTS_CSV = Path(os.getenv("TRACERAG_RESULTS_CSV", PROJECT_ROOT / "results.csv"))
21
+
22
+
23
+ EMBED_MODEL = os.getenv("TRACERAG_EMBED_MODEL", "all-MiniLM-L6-v2")
24
+ EMBED_DIM = 384 # must match the FLOAT[384] schema column
25
+
26
+ # Concurrency: LadybugDB parallelizes reads across connections (it releases the
27
+ # GIL in C++), so a small pool of read connections removes the single-connection
28
+ # convoy seen under load. Writes stay on one dedicated connection (single-writer).
29
+ DB_POOL_SIZE = int(os.getenv("TRACERAG_DB_POOL_SIZE", "10"))
30
+ DB_POOL_TIMEOUT = float(os.getenv("TRACERAG_DB_POOL_TIMEOUT", "15")) # s to wait for a free conn
31
+ # Embedding is CPU-heavy; bound concurrent encodes so they can't starve request
32
+ # threads. torch releases the GIL during encode, so threads (not processes) suffice.
33
+ EMBED_WORKERS = int(os.getenv("TRACERAG_EMBED_WORKERS", "4"))
34
+ # Query-side entity extraction (spaCy) is GIL-bound; cache results so repeated
35
+ # queries skip it. 0 disables (ingest path). Bounded LRU — no unbounded growth.
36
+ QUERY_EXTRACT_CACHE = int(os.getenv("TRACERAG_QUERY_EXTRACT_CACHE", "512"))
37
+ # Cap query length before retrieval. spaCy work grows with input size, so an
38
+ # oversized query is a CPU-DoS (a 50 KB query stalled a worker for ~90s in
39
+ # testing). Real questions are short; truncate the rest. Guards embed + extract.
40
+ MAX_QUERY_CHARS = int(os.getenv("TRACERAG_MAX_QUERY_CHARS", "2000"))
41
+
42
+ # CORS allow-list. "*" (default) accepts any origin — fine for local/dev. In
43
+ # production set CORS_ORIGINS to your frontend origin(s), comma-separated,
44
+ # e.g. "https://tracerag.vercel.app".
45
+ CORS_ORIGINS = [
46
+ o.strip() for o in os.getenv("CORS_ORIGINS", "*").split(",") if o.strip()
47
+ ] or ["*"]
48
+
49
+ # Sentry error tracking + performance monitoring. Fully optional: unset DSN =
50
+ # disabled, so local/dev runs are untouched. traces_sample_rate is 1.0 (trace
51
+ # everything) in dev; scale down in production (e.g. 0.1) to cap overhead/cost.
52
+ SENTRY_DSN = os.getenv("SENTRY_DSN")
53
+ SENTRY_ENABLED = bool(SENTRY_DSN)
54
+ SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "development")
55
+ SENTRY_TRACES_SAMPLE_RATE = float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", "1.0"))
56
+
57
+
58
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
59
+ OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
60
+ OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-3-haiku")
61
+ OPENROUTER_SITE_URL = os.getenv("OPENROUTER_SITE_URL",
62
+ "https://github.com/Kcodess2807/TraceRAG")
63
+ OPENROUTER_APP_TITLE = os.getenv("OPENROUTER_APP_TITLE", "TraceRAG")
64
+
65
+ # groq for the latency-critical router intent call; falls back to openrouter if unset
66
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
67
+ GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
68
+ GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
69
+
70
+
71
+ # --- Clerk auth (backend token verification) ---
72
+ # CLERK_ISSUER is your Clerk instance URL, e.g. https://your-app.clerk.accounts.dev
73
+ # (production: your custom Clerk domain). Leave it UNSET for local dev and the API
74
+ # runs in dev-bypass mode — no token required. Set it and auth is enforced.
75
+ CLERK_ISSUER = os.getenv("CLERK_ISSUER")
76
+ # JWKS = Clerk's published public keys. Defaults to the standard well-known path
77
+ # under the issuer; override only if you proxy it somewhere else.
78
+ CLERK_JWKS_URL = os.getenv("CLERK_JWKS_URL") or (
79
+ f"{CLERK_ISSUER.rstrip('/')}/.well-known/jwks.json" if CLERK_ISSUER else None
80
+ )
81
+ # Optional allow-list of authorized parties (the `azp` claim = the frontend
82
+ # origin a token was minted for). Comma-separated, e.g. "http://localhost:5173".
83
+ CLERK_AUTHORIZED_PARTIES = tuple(
84
+ p.strip()
85
+ for p in os.getenv("CLERK_AUTHORIZED_PARTIES", "").split(",")
86
+ if p.strip()
87
+ )
88
+ # Auth is enforced only when an issuer is configured.
89
+ CLERK_ENABLED = bool(CLERK_ISSUER)
90
+ # The user id every request runs as in dev-bypass mode (Clerk unconfigured).
91
+ DEV_USER_ID = os.getenv("TRACERAG_DEV_USER_ID", "dev-user")
92
+
93
+
94
+ # two-tier curation thresholds (cosine sim): >=fast auto-merge, >=deep ask llm, else new node
95
+ FAST_MERGE_THRESHOLD = float(os.getenv("TRACERAG_FAST_MERGE", "0.92"))
96
+ DEEP_MERGE_THRESHOLD = float(os.getenv("TRACERAG_DEEP_MERGE", "0.85"))
97
+ GREY_ZONE_LOW = DEEP_MERGE_THRESHOLD # backward-compat alias
98
+
99
+ CURATION_TOP_K = int(os.getenv("TRACERAG_CURATION_TOP_K", "5"))
100
+
101
+
102
+ # hybrid router weights (vector + graph = 1)
103
+ ROUTER_WEIGHTS_CONCEPTUAL = {"vector": 0.80, "graph": 0.20}
104
+ ROUTER_WEIGHTS_RELATIONAL = {"vector": 0.15, "graph": 0.85}
105
+
106
+ RELATIONAL_QUERY_MARKERS = (
107
+ "who", "whom", "whose", "which", "what caused", "caused by", "because of",
108
+ "related to", "connected to", "depends on", "owns", "owned by", "between",
109
+ "path from", "linked to", "responsible for", "which pr", "which ticket",
110
+ )
111
+
112
+ SEMANTIC_QUERY_MARKERS = (
113
+ "explain", "architecture", "overview", "summary", "summarise", "summarize",
114
+ "describe", "what is", "how does", "concept", "definition", "purpose of",
115
+ )
116
+
117
+ TOP_K_VECTOR = int(os.getenv("TRACERAG_TOP_K_VECTOR", "10"))
118
+ TOP_K_GRAPH = int(os.getenv("TRACERAG_TOP_K_GRAPH", "10"))
119
+
120
+ GRAPH_SEED_TOP_N = int(os.getenv("TRACERAG_GRAPH_SEED_TOP_N", "3"))
121
+ GRAPH_SEED_MIN_SIM = float(os.getenv("TRACERAG_GRAPH_SEED_MIN_SIM", "0.35"))
122
+ GRAPH_NEIGHBOR_K = int(os.getenv("TRACERAG_GRAPH_NEIGHBOR_K", "5"))
123
+ GRAPH_MAX_HOPS = int(os.getenv("TRACERAG_GRAPH_MAX_HOPS", "2"))
124
+ MAX_DEGREE = int(os.getenv("TRACERAG_MAX_DEGREE", "10")) # skip hub nodes above this degree
125
+
126
+
127
+ GLINER_MODEL = os.getenv("TRACERAG_GLINER_MODEL", "urchade/gliner_medium-v2.1")
128
+ SPACY_MODEL = os.getenv("TRACERAG_SPACY_MODEL", "en_core_web_sm")
129
+
130
+ ENTITY_LABELS = ["Person", "Service", "Library", "Ticket", "PR", "Team", "Tool"]
131
+
132
+ # spaCy fallback over-extracts, so allow/block lists keep only high-signal labels
133
+ SPACY_ALLOWED_LABELS = ("PERSON", "ORG", "PRODUCT", "GPE",
134
+ "TICKET", "PULL_REQUEST", "SERVICE")
135
+ SPACY_BLOCKED_LABELS = ("CARDINAL", "DATE", "TIME", "PERCENT", "MONEY",
136
+ "QUANTITY", "ORDINAL")
137
+ MIN_ENTITY_CHARS = int(os.getenv("TRACERAG_MIN_ENTITY_CHARS", "3"))
138
+
139
+ GLINER_THRESHOLD = float(os.getenv("TRACERAG_GLINER_THRESHOLD", "0.55"))
140
+
141
+ # word-based sliding window so long docs don't drop entities past gliner's limit
142
+ GLINER_WINDOW_WORDS = int(os.getenv("TRACERAG_GLINER_WINDOW_WORDS", "300"))
143
+ GLINER_WINDOW_OVERLAP = int(os.getenv("TRACERAG_GLINER_WINDOW_OVERLAP", "50"))
144
+
145
+ CHUNK_SIZE = int(os.getenv("TRACERAG_CHUNK_SIZE", "1200"))
146
+ CHUNK_OVERLAP = int(os.getenv("TRACERAG_CHUNK_OVERLAP", "150"))
147
+
148
+
149
+ # ladybugdb schema (kùzu-compatible cypher ddl)
150
+ NODE_TABLE = "Entity"
151
+ REL_TABLE = "RELATES_TO" # entity -> entity, same-window co-occurrence
152
+ DOC_TABLE = "Document" # source doc node, no embedding
153
+ MENTIONS_TABLE = "MENTIONS" # document -> entity
154
+ VECTOR_INDEX = "idx_entity_embedding"
155
+ VECTOR_METRIC = "cosine"
tracerag/curation.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Two-tier curation: fast-merge by similarity, deep-merge via LLM, else mint new id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ import re
8
+ from dataclasses import dataclass
9
+ from itertools import combinations
10
+
11
+ from . import config
12
+ from .db import TraceDB
13
+ from .extract import ExtractedEntity, sliding_window_words
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class IngestStats:
20
+ docs: int = 0
21
+ entities: int = 0
22
+ created: int = 0
23
+ fast_merged: int = 0
24
+ deep_merged_yes: int = 0
25
+ deep_merged_no: int = 0
26
+ ollama_calls: int = 0
27
+ relates_edges: int = 0
28
+ mentions_edges: int = 0
29
+
30
+ def merge(self, other: "IngestStats") -> None:
31
+ for f in field_names():
32
+ setattr(self, f, getattr(self, f) + getattr(other, f))
33
+
34
+
35
+ def field_names() -> list[str]:
36
+ return [f.name for f in IngestStats.__dataclass_fields__.values()]
37
+
38
+
39
+ def slugify(text: str) -> str:
40
+ """Canonical readable id, e.g. 'PaymentService Service' -> 'paymentservice-service'."""
41
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
42
+ return slug or "entity"
43
+
44
+
45
+ class CurationEngine:
46
+ """Embeds entities, resolves them against the graph, and commits nodes/edges."""
47
+
48
+ def __init__(self, db: TraceDB, embed_model: str = config.EMBED_MODEL) -> None:
49
+ self.db = db
50
+ self.embed_model = embed_model
51
+ self._embedder = None
52
+ self._llm = None
53
+ # in-memory dedup index: the DB's HNSW index can't be queried mid-ingest
54
+ self._ids: list[str] = []
55
+ self._labels: list[str] = []
56
+ self._types: list[str] = []
57
+ self._vecs: list[list[float]] = []
58
+ self._index_loaded = False
59
+
60
+ def _get_embedder(self):
61
+ if self._embedder is None:
62
+ from sentence_transformers import SentenceTransformer
63
+
64
+ logger.info("Loading embedder: %s", self.embed_model)
65
+ self._embedder = SentenceTransformer(self.embed_model)
66
+ return self._embedder
67
+
68
+ def _embed(self, text: str) -> list[float]:
69
+ vec = self._get_embedder().encode(text, normalize_embeddings=True)
70
+ return [float(x) for x in vec]
71
+
72
+ def _get_llm(self):
73
+ if self._llm is None:
74
+ from .llm import make_client
75
+
76
+ self._llm = make_client()
77
+ return self._llm
78
+
79
+ def _ensure_index_loaded(self) -> None:
80
+ if self._index_loaded:
81
+ return
82
+ rows = self.db.execute(
83
+ f"MATCH (e:{config.NODE_TABLE}) RETURN e.id AS id, e.label AS label, "
84
+ f"e.type AS type, e.embedding AS emb;"
85
+ ).get_as_df().to_dict("records")
86
+ for r in rows:
87
+ if r["emb"] is not None:
88
+ self._add_to_index(r["id"], r["label"], r["type"], list(r["emb"]))
89
+ self._index_loaded = True
90
+ if rows:
91
+ logger.info("Pre-loaded %d existing nodes into dedup index", len(rows))
92
+
93
+ def _add_to_index(self, node_id: str, label: str, ntype: str,
94
+ embedding: list[float]) -> None:
95
+ self._ids.append(node_id)
96
+ self._labels.append(label)
97
+ self._types.append(ntype)
98
+ self._vecs.append(embedding)
99
+
100
+ def ingest(
101
+ self,
102
+ doc_id: str,
103
+ text: str,
104
+ entities: list[ExtractedEntity],
105
+ source: str | None = None,
106
+ ) -> IngestStats:
107
+ stats = IngestStats(docs=1, entities=len(entities))
108
+ if not entities:
109
+ return stats # no entities -> nothing worth storing for retrieval
110
+ self._ensure_index_loaded()
111
+
112
+ resolved = [self._resolve(ent, stats) for ent in entities]
113
+ self._build_chunks_and_edges(doc_id, text, entities, resolved, stats, source)
114
+ return stats
115
+
116
+ def _resolve(self, ent: ExtractedEntity, stats: IngestStats) -> str:
117
+ embedding = self._embed(ent.text)
118
+ hits = self._search(embedding)
119
+ top = hits[0] if hits else None
120
+
121
+ if top and top["similarity"] >= config.FAST_MERGE_THRESHOLD:
122
+ stats.fast_merged += 1
123
+ return top["id"]
124
+
125
+ if top and top["similarity"] >= config.DEEP_MERGE_THRESHOLD:
126
+ stats.ollama_calls += 1
127
+ if self._ask_same_entity(ent.text, top["label"]):
128
+ stats.deep_merged_yes += 1
129
+ return top["id"]
130
+ stats.deep_merged_no += 1
131
+
132
+ node_id = self._mint_id(ent)
133
+ self.db.upsert_node(node_id, ent.text, ent.type, embedding)
134
+ self._add_to_index(node_id, ent.text, ent.type, embedding)
135
+ stats.created += 1
136
+ return node_id
137
+
138
+ def _search(self, embedding: list[float]) -> list[dict]:
139
+ """Cosine search over the in-memory index (normalized, so cosine == dot)."""
140
+ if not self._vecs:
141
+ return []
142
+ import numpy as np
143
+
144
+ mat = np.asarray(self._vecs, dtype=np.float32)
145
+ q = np.asarray(embedding, dtype=np.float32)
146
+ sims = mat @ q
147
+ top = np.argsort(-sims)[: config.CURATION_TOP_K]
148
+ return [
149
+ {"id": self._ids[i], "label": self._labels[i],
150
+ "type": self._types[i], "similarity": float(sims[i])}
151
+ for i in top
152
+ ]
153
+
154
+ def _ask_same_entity(self, extracted_text: str, canonical_label: str) -> bool:
155
+ prompt = (
156
+ f"Are '{extracted_text}' and '{canonical_label}' the exact same "
157
+ f"entity? Answer ONLY YES or NO."
158
+ )
159
+ try:
160
+ resp = self._get_llm().chat.completions.create(
161
+ model=config.OPENROUTER_MODEL,
162
+ messages=[{"role": "user", "content": prompt}],
163
+ temperature=0,
164
+ )
165
+ answer = resp.choices[0].message.content.strip().upper()
166
+ except Exception as exc: # noqa: BLE001 — fail safe: do not merge
167
+ logger.warning("Deep-merge LLM failed (%s); treating as NO.", exc)
168
+ return False
169
+ return answer.startswith("YES")
170
+
171
+ def _mint_id(self, ent: ExtractedEntity) -> str:
172
+ base = slugify(f"{ent.text} {ent.type}")
173
+ if not self.db.node_exists(base):
174
+ return base
175
+ # slug collision between distinct entities: disambiguate with a short text hash
176
+ suffix = hashlib.sha1(ent.text.encode()).hexdigest()[:6]
177
+ candidate = f"{base}-{suffix}"
178
+ n = 2
179
+ while self.db.node_exists(candidate):
180
+ candidate = f"{base}-{suffix}-{n}"
181
+ n += 1
182
+ return candidate
183
+
184
+ def _build_chunks_and_edges(
185
+ self,
186
+ doc_id: str,
187
+ text: str,
188
+ entities: list[ExtractedEntity],
189
+ resolved: list[str],
190
+ stats: IngestStats,
191
+ source: str | None = None,
192
+ ) -> None:
193
+ seen_pairs: set[tuple[str, str]] = set()
194
+ for idx, win in enumerate(sliding_window_words(text)):
195
+ win_end = win.offset + len(win.text)
196
+ members = [
197
+ resolved[i]
198
+ for i, e in enumerate(entities)
199
+ if e.start >= win.offset and e.end <= win_end
200
+ ]
201
+ if not members:
202
+ continue # don't store chunks that surface no entities
203
+
204
+ # path keeps source provenance (PR url when given, else doc id) for citation
205
+ chunk_id = f"{doc_id}#w{idx}"
206
+ self.db.upsert_document(chunk_id, content=win.text, path=source or doc_id)
207
+
208
+ for entity_id in members:
209
+ self.db.add_mention(chunk_id, entity_id)
210
+ stats.mentions_edges += 1
211
+
212
+ for a, b in combinations(sorted(set(members)), 2):
213
+ if a == b or (a, b) in seen_pairs:
214
+ continue
215
+ seen_pairs.add((a, b))
216
+ self.db.add_relationship(a, b)
217
+ stats.relates_edges += 1
tracerag/db.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LadybugDB connection and schema management. LadybugDB is a Kuzu fork, same API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import queue
7
+ import threading
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+ from typing import Any, Iterator, Sequence
11
+
12
+ import ladybug as lb
13
+
14
+ from . import config
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class TraceDB:
20
+ """Wrapper over LadybugDB with a read-connection pool.
21
+
22
+ LadybugDB (a Kuzu fork) parallelizes reads across *separate* connections — it
23
+ releases the GIL in C++ — but a single connection serializes everything. So:
24
+
25
+ * reads lease one of N pooled connections (``_fetch`` / ``_lease``), giving
26
+ true read concurrency under load;
27
+ * writes/DDL go through one dedicated connection under a lock, matching the
28
+ engine's single-writer model (ingestion is single-threaded anyway).
29
+ """
30
+
31
+ def __init__(
32
+ self, db_path: str | Path = config.DB_PATH, *, pool_size: int | None = None
33
+ ) -> None:
34
+ self.db_path = str(db_path)
35
+ self._db = lb.Database(self.db_path)
36
+ self._closed = False
37
+
38
+ # dedicated write/DDL connection (single-writer); guarded by a lock
39
+ self._write_lock = threading.Lock()
40
+ self._write_conn = self._new_connection()
41
+
42
+ # bounded pool of read connections; reads parallelize across them
43
+ self._pool_size = max(1, config.DB_POOL_SIZE if pool_size is None else pool_size)
44
+ self._pool: "queue.Queue" = queue.Queue(maxsize=self._pool_size)
45
+ self._read_conns: list = [] # kept for clean shutdown
46
+ for _ in range(self._pool_size):
47
+ conn = self._new_connection()
48
+ self._read_conns.append(conn)
49
+ self._pool.put(conn)
50
+ logger.info(
51
+ "Opened LadybugDB at %s (read pool=%d)", self.db_path, self._pool_size
52
+ )
53
+
54
+ # ---- connection plumbing -------------------------------------------------
55
+
56
+ def _new_connection(self):
57
+ """Create a connection with the VECTOR extension loaded (per-connection)."""
58
+ conn = lb.Connection(self._db)
59
+ self._load_vector_on(conn)
60
+ return conn
61
+
62
+ @staticmethod
63
+ def _load_vector_on(conn) -> None:
64
+ """LOAD the VECTOR extension on a connection; INSTALL once if missing."""
65
+ try:
66
+ conn.execute("LOAD VECTOR;")
67
+ except Exception: # noqa: BLE001 — not installed yet on this machine
68
+ try:
69
+ conn.execute("INSTALL VECTOR;")
70
+ conn.execute("LOAD VECTOR;")
71
+ except Exception as exc: # noqa: BLE001
72
+ logger.warning("VECTOR extension unavailable: %s", exc)
73
+
74
+ def _load_vector_extension(self) -> None:
75
+ """Reload VECTOR on the write connection (used before index rebuilds)."""
76
+ self._load_vector_on(self._write_conn)
77
+
78
+ @contextmanager
79
+ def _lease(self) -> Iterator[Any]:
80
+ """Borrow a read connection from the pool, returning it on exit."""
81
+ if self._closed:
82
+ raise RuntimeError("TraceDB is closed")
83
+ conn = self._pool.get(timeout=config.DB_POOL_TIMEOUT)
84
+ try:
85
+ yield conn
86
+ finally:
87
+ self._pool.put(conn)
88
+
89
+ def execute(self, query: str, params: dict[str, Any] | None = None):
90
+ """Write/DDL path: runs on the single write connection under a lock.
91
+
92
+ Reads should use ``_fetch`` so they fan out across the pool; this is kept
93
+ for schema, upserts and index builds (and any caller that ignores the
94
+ result). The returned result must be consumed before the next write.
95
+ """
96
+ with self._write_lock:
97
+ return self._write_conn.execute(query, params or {})
98
+
99
+ def _fetch(
100
+ self, query: str, params: dict[str, Any] | None = None
101
+ ) -> list[dict[str, Any]]:
102
+ """Read path: lease a pooled connection, run, materialize, release.
103
+
104
+ Materializing (``get_as_df``) inside the lease is required — the result
105
+ is bound to its connection, so it must be consumed before the connection
106
+ goes back to the pool.
107
+ """
108
+ with self._lease() as conn:
109
+ return self._records(conn.execute(query, params or {}))
110
+
111
+ def init_schema(self) -> None:
112
+ self.execute(
113
+ f"CREATE NODE TABLE IF NOT EXISTS {config.NODE_TABLE} ("
114
+ f"id STRING PRIMARY KEY, label STRING, type STRING, "
115
+ f"embedding FLOAT[{config.EMBED_DIM}]);"
116
+ )
117
+ self.execute(
118
+ f"CREATE NODE TABLE IF NOT EXISTS {config.DOC_TABLE} ("
119
+ f"id STRING PRIMARY KEY, path STRING, content STRING);"
120
+ )
121
+ self.execute(
122
+ f"CREATE REL TABLE IF NOT EXISTS {config.REL_TABLE} ("
123
+ f"FROM {config.NODE_TABLE} TO {config.NODE_TABLE}, confidence DOUBLE);"
124
+ )
125
+ self.execute(
126
+ f"CREATE REL TABLE IF NOT EXISTS {config.MENTIONS_TABLE} ("
127
+ f"FROM {config.DOC_TABLE} TO {config.NODE_TABLE});"
128
+ )
129
+ # HNSW index built later via build_vector_index(); a live index blocks embedding writes
130
+ logger.info("Schema ready (node=%s, rel=%s)",
131
+ config.NODE_TABLE, config.REL_TABLE)
132
+
133
+ def build_vector_index(self) -> None:
134
+ """(Re)build the HNSW index over current embeddings; call after ingestion."""
135
+ self._load_vector_extension()
136
+ try:
137
+ self.execute(
138
+ f"CALL DROP_VECTOR_INDEX('{config.NODE_TABLE}', "
139
+ f"'{config.VECTOR_INDEX}');"
140
+ )
141
+ except Exception: # noqa: BLE001 — no index to drop on first build
142
+ pass
143
+ self.execute(
144
+ f"CALL CREATE_VECTOR_INDEX('{config.NODE_TABLE}', "
145
+ f"'{config.VECTOR_INDEX}', 'embedding', metric := '{config.VECTOR_METRIC}');"
146
+ )
147
+ logger.info("Built vector index %s", config.VECTOR_INDEX)
148
+
149
+ def upsert_node(
150
+ self,
151
+ node_id: str,
152
+ label: str,
153
+ node_type: str,
154
+ embedding: Sequence[float],
155
+ ) -> None:
156
+ # no ON MATCH SET: never clobber a canonical node with a noisier surface form
157
+ self._check_dim(embedding)
158
+ self.execute(
159
+ f"MERGE (e:{config.NODE_TABLE} {{id: $id}}) "
160
+ f"ON CREATE SET e.label = $label, e.type = $type, e.embedding = $embedding",
161
+ {"id": node_id, "label": label, "type": node_type,
162
+ "embedding": list(embedding)},
163
+ )
164
+
165
+ def add_relationship(self, from_id: str, to_id: str, confidence: float = 1.0) -> None:
166
+ self.execute(
167
+ f"MATCH (a:{config.NODE_TABLE} {{id: $from_id}}), "
168
+ f"(b:{config.NODE_TABLE} {{id: $to_id}}) "
169
+ f"MERGE (a)-[r:{config.REL_TABLE}]->(b) ON CREATE SET r.confidence = $confidence",
170
+ {"from_id": from_id, "to_id": to_id, "confidence": confidence},
171
+ )
172
+
173
+ def upsert_document(
174
+ self, doc_id: str, content: str | None = None, path: str | None = None
175
+ ) -> None:
176
+ # documents are raw source, not canonical: refresh content on re-ingestion
177
+ self.execute(
178
+ f"MERGE (d:{config.DOC_TABLE} {{id: $id}}) "
179
+ f"ON CREATE SET d.path = $path, d.content = $content "
180
+ f"ON MATCH SET d.path = $path, d.content = $content",
181
+ {"id": doc_id, "path": path or doc_id, "content": content or ""},
182
+ )
183
+
184
+ def add_mention(self, doc_id: str, entity_id: str) -> None:
185
+ self.execute(
186
+ f"MATCH (d:{config.DOC_TABLE} {{id: $doc_id}}), "
187
+ f"(e:{config.NODE_TABLE} {{id: $entity_id}}) "
188
+ f"MERGE (d)-[:{config.MENTIONS_TABLE}]->(e)",
189
+ {"doc_id": doc_id, "entity_id": entity_id},
190
+ )
191
+
192
+ def find_nodes_by_label(self, label: str) -> list[dict[str, Any]]:
193
+ """Exact, case-insensitive match of entities by their label."""
194
+ return self._fetch(
195
+ f"MATCH (e:{config.NODE_TABLE}) WHERE lower(e.label) = lower($label) "
196
+ f"RETURN e.id AS id, e.label AS label, e.type AS type;",
197
+ {"label": label},
198
+ )
199
+
200
+ def node_exists(self, node_id: str) -> bool:
201
+ rows = self._fetch(
202
+ f"MATCH (e:{config.NODE_TABLE} {{id: $id}}) RETURN e.id AS id LIMIT 1;",
203
+ {"id": node_id},
204
+ )
205
+ return len(rows) > 0
206
+
207
+ def vector_search(
208
+ self,
209
+ query_embedding: Sequence[float],
210
+ k: int = config.CURATION_TOP_K,
211
+ ) -> list[dict[str, Any]]:
212
+ """Top-k cosine neighbours via the HNSW index; similarity = 1 - distance."""
213
+ self._check_dim(query_embedding)
214
+ result = self._fetch(
215
+ f"CALL QUERY_VECTOR_INDEX('{config.NODE_TABLE}', "
216
+ f"'{config.VECTOR_INDEX}', $q, {int(k)}) "
217
+ f"RETURN node.id AS id, node.label AS label, node.type AS type, "
218
+ f"distance ORDER BY distance;",
219
+ {"q": list(query_embedding)},
220
+ )
221
+ rows = []
222
+ for r in result:
223
+ distance = float(r["distance"])
224
+ rows.append({
225
+ "id": r["id"], "label": r["label"], "type": r["type"],
226
+ "distance": distance, "similarity": 1.0 - distance,
227
+ })
228
+ return rows
229
+
230
+ def neighbors(self, node_id: str, k: int = config.TOP_K_GRAPH) -> list[dict[str, Any]]:
231
+ """One-hop graph neighbours, strongest edge first (deterministic order)."""
232
+ return self._fetch(
233
+ f"MATCH (a:{config.NODE_TABLE} {{id: $id}})"
234
+ f"-[r:{config.REL_TABLE}]-(b:{config.NODE_TABLE}) "
235
+ f"RETURN b.id AS id, b.label AS label, b.type AS type, "
236
+ f"r.confidence AS confidence "
237
+ f"ORDER BY r.confidence DESC, b.id ASC "
238
+ f"LIMIT $k;",
239
+ {"id": node_id, "k": k},
240
+ )
241
+
242
+ def node_degree(self, node_id: str) -> int:
243
+ """Distinct one-hop neighbour count — used to detect hub super-nodes."""
244
+ rows = self._fetch(
245
+ f"MATCH (a:{config.NODE_TABLE} {{id: $id}})"
246
+ f"-[r:{config.REL_TABLE}]-(b:{config.NODE_TABLE}) "
247
+ f"RETURN count(DISTINCT b.id) AS d;",
248
+ {"id": node_id},
249
+ )
250
+ return int(rows[0]["d"]) if rows else 0
251
+
252
+ def expand_frontier(
253
+ self, node_ids: list[str], k: int, max_degree: int
254
+ ) -> dict[str, list[dict[str, Any]]]:
255
+ """Batched 1-hop expansion of a whole frontier in one query (avoids N+1)."""
256
+ if not node_ids:
257
+ return {}
258
+ rows = self._fetch(
259
+ f"MATCH (a:{config.NODE_TABLE}) WHERE a.id IN $ids "
260
+ f"OPTIONAL MATCH (a)-[r:{config.REL_TABLE}]-(b:{config.NODE_TABLE}) "
261
+ f"RETURN a.id AS from_id, b.id AS to_id, b.label AS label, "
262
+ f"b.type AS type, r.confidence AS confidence;",
263
+ {"ids": list(node_ids)},
264
+ )
265
+ grouped: dict[str, list[dict[str, Any]]] = {}
266
+ for r in rows:
267
+ if r["to_id"] is None: # isolated node
268
+ continue
269
+ grouped.setdefault(r["from_id"], []).append({
270
+ "id": r["to_id"], "label": r["label"], "type": r["type"],
271
+ "confidence": float(r["confidence"])
272
+ if r["confidence"] is not None else 1.0,
273
+ })
274
+ out: dict[str, list[dict[str, Any]]] = {}
275
+ for from_id, nbrs in grouped.items():
276
+ if len({n["id"] for n in nbrs}) > max_degree:
277
+ continue # hub: reached but not traversed through
278
+ nbrs.sort(key=lambda n: (-n["confidence"], n["id"]))
279
+ out[from_id] = nbrs[:k]
280
+ return out
281
+
282
+ def documents_for_entities(self, entity_ids: list[str]) -> dict[str, list[dict]]:
283
+ """Chunk text of every Document mentioning the given entities, keyed by entity id."""
284
+ if not entity_ids:
285
+ return {}
286
+ rows = self._fetch(
287
+ f"MATCH (d:{config.DOC_TABLE})-[:{config.MENTIONS_TABLE}]->"
288
+ f"(e:{config.NODE_TABLE}) "
289
+ f"WHERE e.id IN $ids "
290
+ f"RETURN e.id AS entity_id, d.id AS doc_id, d.path AS path, "
291
+ f"d.content AS content;",
292
+ {"ids": entity_ids},
293
+ )
294
+ grouped: dict[str, list[dict]] = {}
295
+ for r in rows:
296
+ grouped.setdefault(r["entity_id"], []).append(
297
+ {"doc_id": r["doc_id"], "path": r["path"], "content": r["content"]}
298
+ )
299
+ return grouped
300
+
301
+ def subgraph(self, node_ids: list[str]) -> dict:
302
+ """Requested nodes plus their 1-hop neighbors and the edges among that set."""
303
+ if not node_ids:
304
+ return {"nodes": [], "edges": []}
305
+
306
+ # optional match so isolated requested nodes still appear
307
+ rows = self._fetch(
308
+ f"MATCH (a:{config.NODE_TABLE}) WHERE a.id IN $ids "
309
+ f"OPTIONAL MATCH (a)-[:{config.REL_TABLE}]-(b:{config.NODE_TABLE}) "
310
+ f"RETURN a.id AS a_id, a.label AS a_label, a.type AS a_type, "
311
+ f"b.id AS b_id, b.label AS b_label, b.type AS b_type;",
312
+ {"ids": node_ids},
313
+ )
314
+
315
+ requested = set(node_ids)
316
+ nodes: dict[str, dict] = {}
317
+
318
+ def _add(nid, label, ntype):
319
+ if nid is not None and nid not in nodes:
320
+ nodes[nid] = {"id": nid, "label": label, "type": ntype,
321
+ "requested": nid in requested}
322
+
323
+ for r in rows:
324
+ _add(r["a_id"], r["a_label"], r["a_type"])
325
+ _add(r["b_id"], r["b_label"], r["b_type"])
326
+
327
+ # edges strictly between visible nodes
328
+ all_ids = list(nodes)
329
+ edge_rows = self._fetch(
330
+ f"MATCH (a:{config.NODE_TABLE})-[r:{config.REL_TABLE}]->"
331
+ f"(b:{config.NODE_TABLE}) "
332
+ f"WHERE a.id IN $ids AND b.id IN $ids "
333
+ f"RETURN a.id AS source, b.id AS target, r.confidence AS confidence;",
334
+ {"ids": all_ids},
335
+ ) if all_ids else []
336
+ edges = [
337
+ {"source": r["source"], "target": r["target"],
338
+ "confidence": r["confidence"]}
339
+ for r in edge_rows
340
+ ]
341
+ return {"nodes": list(nodes.values()), "edges": edges}
342
+
343
+ def count_nodes(self) -> int:
344
+ rows = self._fetch(
345
+ f"MATCH (e:{config.NODE_TABLE}) RETURN count(e) AS n;")
346
+ return int(rows[0]["n"]) if rows else 0
347
+
348
+ def top_entities(self, limit: int = 12) -> list[dict[str, Any]]:
349
+ """Most-connected entities (highest distinct degree), with type."""
350
+ # aggregate via WITH before ordering; ordering by a.label alongside the aggregate
351
+ # fails in Kuzu ("Variable a is not in scope")
352
+ return self._fetch(
353
+ f"MATCH (a:{config.NODE_TABLE})-[r:{config.REL_TABLE}]-(b:{config.NODE_TABLE}) "
354
+ f"WITH a, count(DISTINCT b) AS degree "
355
+ f"RETURN a.id AS id, a.label AS label, a.type AS type, degree "
356
+ f"ORDER BY degree DESC, a.label ASC "
357
+ f"LIMIT $limit;",
358
+ {"limit": int(limit)},
359
+ )
360
+
361
+ @staticmethod
362
+ def _check_dim(embedding: Sequence[float]) -> None:
363
+ if len(embedding) != config.EMBED_DIM:
364
+ raise ValueError(
365
+ f"Embedding has {len(embedding)} dims, expected {config.EMBED_DIM}."
366
+ )
367
+
368
+ @staticmethod
369
+ def _records(result) -> list[dict[str, Any]]:
370
+ return result.get_as_df().to_dict(orient="records")
371
+
372
+ def close(self) -> None:
373
+ """Drain the read pool and close every connection (idempotent).
374
+
375
+ Safe across the hot-swap path: ``/api/graphs/switch`` builds a new
376
+ TraceDB and closes the old one, so each pool is owned by exactly one
377
+ TraceDB and never outlives it.
378
+ """
379
+ if self._closed:
380
+ return
381
+ self._closed = True
382
+ handles = list(self._read_conns) + [self._write_conn, self._db]
383
+ for handle in handles:
384
+ try:
385
+ handle.close()
386
+ except Exception: # noqa: BLE001
387
+ pass
388
+ logger.info("Closed LadybugDB at %s", self.db_path)
389
+
390
+ def __enter__(self) -> "TraceDB":
391
+ return self
392
+
393
+ def __exit__(self, *_exc: object) -> None:
394
+ self.close()
395
+
396
+
397
+ def get_db(db_path: str | Path = config.DB_PATH, *, init: bool = True) -> TraceDB:
398
+ db = TraceDB(db_path)
399
+ if init:
400
+ db.init_schema()
401
+ return db
tracerag/extract.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entity extraction: GLiNER (zero-shot) with spaCy fallback, over sliding windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import threading
8
+ from collections import OrderedDict
9
+ from dataclasses import dataclass
10
+ from typing import Iterator
11
+
12
+ from . import config
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ExtractedEntity:
19
+ text: str # surface form
20
+ type: str # config.ENTITY_LABELS (or spaCy label in fallback)
21
+ score: float # confidence in [0, 1]
22
+ start: int # absolute char offset
23
+ end: int # absolute char offset (exclusive)
24
+ source: str # "gliner" | "spacy"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class _WordWindow:
29
+ text: str
30
+ offset: int # absolute char offset where this window begins
31
+
32
+
33
+ def sliding_window_words(
34
+ text: str,
35
+ window: int = config.GLINER_WINDOW_WORDS,
36
+ overlap: int = config.GLINER_WINDOW_OVERLAP,
37
+ ) -> Iterator[_WordWindow]:
38
+ """Yield overlapping word windows tagged with their char offset."""
39
+ if overlap >= window:
40
+ raise ValueError("overlap must be smaller than window")
41
+
42
+ tokens = [(m.group(0), m.start()) for m in re.finditer(r"\S+", text)]
43
+ if not tokens:
44
+ return
45
+
46
+ step = window - overlap
47
+ for i in range(0, len(tokens), step):
48
+ chunk = tokens[i : i + window]
49
+ if not chunk:
50
+ break
51
+ start_char = chunk[0][1]
52
+ last_word, last_start = chunk[-1]
53
+ end_char = last_start + len(last_word)
54
+ yield _WordWindow(text=text[start_char:end_char], offset=start_char)
55
+ if i + window >= len(tokens):
56
+ break
57
+
58
+
59
+ # spaCy/EntityRuler label -> domain type; anything not here is dropped
60
+ _SPACY_LABEL_MAP = {
61
+ "PERSON": "Person",
62
+ "ORG": "Service",
63
+ "PRODUCT": "Tool",
64
+ "GPE": "Location",
65
+ "TICKET": "Ticket",
66
+ "PULL_REQUEST": "PR",
67
+ "SERVICE": "Service",
68
+ }
69
+
70
+ # patterns for an EntityRuler placed before the statistical ner, so the ruler wins
71
+ _RULER_LABELS = frozenset({"TICKET", "PULL_REQUEST", "SERVICE"})
72
+ _ENTITY_RULER_PATTERNS = [
73
+ # jira project key: INC-123, OPS-456, SRCTREEWIN-14221
74
+ {"label": "TICKET",
75
+ "pattern": [{"TEXT": {"REGEX": r"(?i)^[A-Z][A-Z0-9]+-\d+$"}}]},
76
+ # PR #402, PR 402
77
+ {"label": "PULL_REQUEST",
78
+ "pattern": [{"LOWER": "pr"}, {"TEXT": "#", "OP": "?"}, {"IS_DIGIT": True}]},
79
+ # payment-service as one token
80
+ {"label": "SERVICE",
81
+ "pattern": [{"TEXT": {"REGEX": r"(?i)^[a-z0-9]+-service$"}}]},
82
+ # payment-service split into ["payment", "-", "service"]
83
+ {"label": "SERVICE",
84
+ "pattern": [{"TEXT": {"REGEX": r"(?i)^[a-z0-9]+$"}},
85
+ {"TEXT": "-"}, {"LOWER": "service"}]},
86
+ ]
87
+
88
+ # leading/trailing markdown & list punctuation to trim off noisy spaCy spans
89
+ _EDGE_JUNK = " \t\r\n-*#•|>:.,;"
90
+
91
+
92
+ def clean_entity_text(raw: str) -> str | None:
93
+ """Sanitize an extracted surface form; return cleaned text or None to drop."""
94
+ if not raw:
95
+ return None
96
+ text = re.sub(r"\s+", " ", raw).strip().strip(_EDGE_JUNK).strip()
97
+ if len(text) < config.MIN_ENTITY_CHARS:
98
+ return None
99
+ if re.fullmatch(r"[\W_]+", text): # only special chars
100
+ return None
101
+ if re.fullmatch(r"[\d.,%$]+", text): # entirely numeric (2026, 1,000, 50%)
102
+ return None
103
+ if not text[0].isalnum(): # starts with a special char (#, -, *)
104
+ return None
105
+ return text
106
+
107
+
108
+ class EntityExtractor:
109
+ """Extracts domain entities. GLiNER first; degrades to spaCy if unavailable."""
110
+
111
+ def __init__(
112
+ self,
113
+ labels: list[str] | None = None,
114
+ threshold: float = config.GLINER_THRESHOLD,
115
+ prefer_gliner: bool = True,
116
+ ) -> None:
117
+ self.labels = labels or config.ENTITY_LABELS
118
+ self.threshold = threshold
119
+ # False at query time: linking only needs the ruler, gliner is ingest-only
120
+ self._prefer_gliner = prefer_gliner
121
+ self._gliner = None
122
+ self._spacy = None
123
+ self._gliner_failed = False
124
+ # Query-side extraction is a pure fn of the text and GIL-bound (spaCy),
125
+ # so cache it: repeated queries skip the pass entirely. Disabled for the
126
+ # ingest path (prefer_gliner=True), where every document text is unique.
127
+ self._cache_cap = 0 if prefer_gliner else config.QUERY_EXTRACT_CACHE
128
+ self._cache: "OrderedDict[str, list[ExtractedEntity]]" = OrderedDict()
129
+ self._cache_lock = threading.Lock()
130
+
131
+ def _get_gliner(self):
132
+ if not self._prefer_gliner:
133
+ return None
134
+ if self._gliner is None and not self._gliner_failed:
135
+ try:
136
+ from gliner import GLiNER
137
+
138
+ logger.info("Loading GLiNER model: %s", config.GLINER_MODEL)
139
+ self._gliner = GLiNER.from_pretrained(config.GLINER_MODEL)
140
+ except Exception as exc: # noqa: BLE001
141
+ logger.warning("GLiNER unavailable (%s); using spaCy.", exc)
142
+ self._gliner_failed = True
143
+ return self._gliner
144
+
145
+ def _get_spacy(self):
146
+ if self._spacy is None:
147
+ import spacy
148
+
149
+ try:
150
+ logger.info("Loading spaCy model: %s", config.SPACY_MODEL)
151
+ # Entity linking needs only tok2vec + ruler + ner. Excluding the
152
+ # tagger/parser/lemmatizer cuts per-call CPU (and GIL-hold), which
153
+ # is what dominates query latency under concurrency.
154
+ nlp = spacy.load(
155
+ config.SPACY_MODEL,
156
+ exclude=["tagger", "parser", "lemmatizer", "attribute_ruler"],
157
+ )
158
+ except OSError as exc:
159
+ raise RuntimeError(
160
+ f"spaCy model '{config.SPACY_MODEL}' not found. "
161
+ f"Run: python -m spacy download {config.SPACY_MODEL}"
162
+ ) from exc
163
+ # ruler runs before ner so deterministic spans take priority
164
+ if "entity_ruler" not in nlp.pipe_names:
165
+ ruler = nlp.add_pipe("entity_ruler", before="ner")
166
+ ruler.add_patterns(_ENTITY_RULER_PATTERNS)
167
+ self._spacy = nlp
168
+ return self._spacy
169
+
170
+ def extract(self, text: str) -> list[ExtractedEntity]:
171
+ if not text or not text.strip():
172
+ return []
173
+ if self._cache_cap:
174
+ with self._cache_lock:
175
+ cached = self._cache.get(text)
176
+ if cached is not None:
177
+ self._cache.move_to_end(text)
178
+ return cached
179
+ model = self._get_gliner()
180
+ if model is not None:
181
+ result = self._extract_gliner(text, model)
182
+ else:
183
+ result = self._extract_spacy(text)
184
+ if self._cache_cap:
185
+ with self._cache_lock:
186
+ self._cache[text] = result
187
+ self._cache.move_to_end(text)
188
+ if len(self._cache) > self._cache_cap:
189
+ self._cache.popitem(last=False)
190
+ return result
191
+
192
+ def _extract_gliner(self, text: str, model) -> list[ExtractedEntity]:
193
+ found: list[ExtractedEntity] = []
194
+ for win in sliding_window_words(text):
195
+ try:
196
+ preds = model.predict_entities(
197
+ win.text, self.labels, threshold=self.threshold
198
+ )
199
+ except Exception as exc: # noqa: BLE001 — skip bad window, keep doc
200
+ logger.warning("GLiNER failed on a window (%s); skipping.", exc)
201
+ continue
202
+ for p in preds:
203
+ # GLiNER's p["text"] corrupts multi-byte chars (José -> Jos�);
204
+ # re-slice the span from the source using its (correct) offsets.
205
+ raw = win.text[p["start"]:p["end"]] or p["text"]
206
+ clean = clean_entity_text(raw)
207
+ if clean is None:
208
+ continue
209
+ found.append(ExtractedEntity(
210
+ text=clean,
211
+ type=p["label"],
212
+ score=float(p.get("score", 1.0)),
213
+ start=win.offset + p["start"],
214
+ end=win.offset + p["end"],
215
+ source="gliner",
216
+ ))
217
+ return self._dedupe(found)
218
+
219
+ def _extract_spacy(self, text: str) -> list[ExtractedEntity]:
220
+ nlp = self._get_spacy()
221
+ found: list[ExtractedEntity] = []
222
+ for win in sliding_window_words(text):
223
+ for ent in nlp(win.text).ents:
224
+ # whitelist high-signal labels, drop date/number/quantity noise
225
+ if ent.label_ not in config.SPACY_ALLOWED_LABELS:
226
+ continue
227
+ if ent.label_ in config.SPACY_BLOCKED_LABELS:
228
+ continue
229
+ if ent.label_ in _RULER_LABELS:
230
+ clean = ent.text.strip()
231
+ if not clean:
232
+ continue
233
+ # canonicalize ticket keys so case variants resolve to one node
234
+ if ent.label_ == "TICKET":
235
+ clean = clean.upper()
236
+ else:
237
+ clean = clean_entity_text(ent.text)
238
+ if clean is None:
239
+ continue
240
+ found.append(ExtractedEntity(
241
+ text=clean,
242
+ type=_SPACY_LABEL_MAP.get(ent.label_, ent.label_),
243
+ score=1.0,
244
+ start=win.offset + ent.start_char,
245
+ end=win.offset + ent.end_char,
246
+ source="spacy",
247
+ ))
248
+ return self._dedupe(found)
249
+
250
+ @staticmethod
251
+ def _dedupe(entities: list[ExtractedEntity]) -> list[ExtractedEntity]:
252
+ # surface-level only (best score per text+type); semantic merge is curation's job
253
+ best: dict[tuple[str, str], ExtractedEntity] = {}
254
+ for e in entities:
255
+ if not e.text:
256
+ continue
257
+ key = (e.text.lower(), e.type)
258
+ cur = best.get(key)
259
+ if cur is None or e.score > cur.score:
260
+ best[key] = e
261
+ return sorted(best.values(), key=lambda e: (e.start, e.text))
tracerag/integrations/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Framework integrations."""
tracerag/integrations/langchain.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangChain BaseRetriever backed by the TraceRouter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun
6
+ from langchain_core.documents import Document
7
+ from langchain_core.retrievers import BaseRetriever
8
+
9
+ from .. import config
10
+ from ..db import TraceDB
11
+ from ..router import RoutedNode, TraceRouter
12
+
13
+
14
+ def format_page_content(node: RoutedNode, seen_chunk_ids: set[str] | None = None) -> str:
15
+ """Entity line + its chunk text, globally deduped by chunk id."""
16
+ seen = seen_chunk_ids if seen_chunk_ids is not None else set()
17
+ label, ntype = node.label or node.id, node.type or "Unknown"
18
+ content = f"Entity: {label} ({ntype})"
19
+ parts: list[str] = []
20
+ for d in node.documents:
21
+ cid = d.get("doc_id")
22
+ text = (d.get("content") or "").strip()
23
+ if not text:
24
+ continue
25
+ if cid not in seen:
26
+ seen.add(cid)
27
+ parts.append(text)
28
+ else:
29
+ parts.append(f"[Trace: {label} ({ntype}) -> {cid}]")
30
+ if parts:
31
+ content += "\n\nContext:\n" + "\n---\n".join(parts)
32
+ return content
33
+
34
+
35
+ class TraceRAGRetriever(BaseRetriever):
36
+ """Retriever that wraps TraceRouter over the .lbug graph."""
37
+
38
+ model_config = {"arbitrary_types_allowed": True}
39
+
40
+ router: TraceRouter
41
+ k: int = config.TOP_K_VECTOR
42
+
43
+ @classmethod
44
+ def from_db(cls, db: TraceDB, k: int = config.TOP_K_VECTOR) -> "TraceRAGRetriever":
45
+ return cls(router=TraceRouter(db), k=k)
46
+
47
+ def _get_relevant_documents(
48
+ self, query: str, *, run_manager: CallbackManagerForRetrieverRun
49
+ ) -> list[Document]:
50
+ response = self.router.route(query, top_k=self.k)
51
+ docs: list[Document] = []
52
+ seen_chunk_ids: set[str] = set()
53
+ for node in response.results:
54
+ docs.append(Document(
55
+ page_content=format_page_content(node, seen_chunk_ids),
56
+ metadata={
57
+ "id": node.id,
58
+ "score_total": node.score_total,
59
+ "score_vector": node.score_vector,
60
+ "score_graph": node.score_graph,
61
+ "trace_log": response.trace_log,
62
+ },
63
+ ))
64
+ return docs
tracerag/llm.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared OpenRouter chat client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import config
6
+
7
+
8
+ def make_client():
9
+ """Return an OpenAI client pointed at OpenRouter."""
10
+ from openai import OpenAI
11
+
12
+ return OpenAI(
13
+ base_url=config.OPENROUTER_BASE_URL,
14
+ api_key=config.OPENROUTER_API_KEY,
15
+ default_headers={
16
+ "HTTP-Referer": config.OPENROUTER_SITE_URL,
17
+ "X-Title": config.OPENROUTER_APP_TITLE,
18
+ },
19
+ )
20
+
21
+
22
+ def make_intent_client() -> tuple[object, str]:
23
+ """Return (client, model) for intent; prefers Groq, falls back to OpenRouter."""
24
+ if config.GROQ_API_KEY:
25
+ from openai import OpenAI
26
+
27
+ client = OpenAI(
28
+ base_url=config.GROQ_BASE_URL,
29
+ api_key=config.GROQ_API_KEY,
30
+ )
31
+ return client, config.GROQ_MODEL
32
+ return make_client(), config.OPENROUTER_MODEL
tracerag/router.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Intent-based hybrid router: vector recall + graph traversal, fused by intent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from dataclasses import dataclass, field
10
+
11
+ from . import config
12
+ from .db import TraceDB
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Dedicated, bounded pool for the CPU-heavy embedding step. torch releases the
17
+ # GIL during encode, so threads parallelize; bounding the workers keeps a burst
18
+ # of embeds from starving the request threads (or the FastAPI threadpool).
19
+ # Module-level + lazy so it is shared across requests and survives graph swaps.
20
+ _EMBED_EXECUTOR = ThreadPoolExecutor(
21
+ max_workers=config.EMBED_WORKERS, thread_name_prefix="embed"
22
+ )
23
+
24
+
25
+ def shutdown_embed_executor(wait: bool = True) -> None:
26
+ """Stop the shared embed executor, letting in-flight encodes finish first.
27
+
28
+ Called from the FastAPI lifespan shutdown so worker threads are joined
29
+ cleanly on Ctrl+C instead of being abandoned at interpreter exit.
30
+ """
31
+ _EMBED_EXECUTOR.shutdown(wait=wait)
32
+
33
+
34
+ @dataclass
35
+ class RoutedNode:
36
+ id: str
37
+ label: str | None
38
+ type: str | None
39
+ score_total: float
40
+ score_vector: float
41
+ score_graph: float
42
+ documents: list[dict] = field(default_factory=list)
43
+
44
+
45
+ @dataclass
46
+ class RouterResponse:
47
+ query: str
48
+ results: list[RoutedNode]
49
+ trace_log: dict
50
+
51
+
52
+ class TraceRouter:
53
+ """Hybrid retriever: vector + graph, fused by query intent."""
54
+
55
+ def __init__(self, db: TraceDB, embed_model: str = config.EMBED_MODEL) -> None:
56
+ self.db = db
57
+ self.embed_model = embed_model
58
+ self._embedder = None
59
+ self._llm = None
60
+ self._intent_llm = None
61
+ self._extractor = None
62
+
63
+ def warm(self) -> None:
64
+ """Cold-boot warmup: load model weights into RAM and spin up the worker
65
+ threads, so the first real query skips the cold-start latency spike.
66
+
67
+ Routes through ``embed_query`` (not the raw embedder) so the bounded embed
68
+ executor's threads are created and the torch graph is traced; and through
69
+ the extractor so the spaCy pipeline is loaded into memory.
70
+ """
71
+ self.embed_query("warmup query") # warms embedder + executor threads
72
+ try:
73
+ self._get_extractor().extract("warmup query") # warms spaCy pipeline
74
+ except Exception as exc: # noqa: BLE001 — never let warmup break boot
75
+ logger.debug("extractor warmup skipped (%s)", exc)
76
+
77
+ def _get_embedder(self):
78
+ if self._embedder is None:
79
+ from sentence_transformers import SentenceTransformer
80
+
81
+ logger.info("Loading embedder: %s", self.embed_model)
82
+ self._embedder = SentenceTransformer(self.embed_model)
83
+ return self._embedder
84
+
85
+ def _get_extractor(self):
86
+ if self._extractor is None:
87
+ from .extract import EntityExtractor
88
+
89
+ # query-side linking only needs the spaCy ruler; gliner is ingest-time
90
+ self._extractor = EntityExtractor(prefer_gliner=False)
91
+ return self._extractor
92
+
93
+ def _link_query_entities(self, query: str, meta: dict[str, dict]) -> list[str]:
94
+ """Extract query entities and match them to graph nodes by exact label."""
95
+ ids: list[str] = []
96
+ try:
97
+ entities = self._get_extractor().extract(query)
98
+ except Exception as exc: # noqa: BLE001 — never let linking break a query
99
+ logger.debug("query entity extraction failed (%s)", exc)
100
+ return ids
101
+ for ent in entities:
102
+ for node in self.db.find_nodes_by_label(ent.text):
103
+ nid = node["id"]
104
+ meta.setdefault(nid, {"label": node["label"], "type": node["type"]})
105
+ ids.append(nid)
106
+ return list(dict.fromkeys(ids))
107
+
108
+ def _encode(self, text: str) -> list[float]:
109
+ vec = self._get_embedder().encode(text, normalize_embeddings=True)
110
+ return [float(x) for x in vec]
111
+
112
+ def embed_query(self, text: str) -> list[float]:
113
+ # Offload the encode to the dedicated bounded pool: concurrent embeds are
114
+ # isolated and capped, so a burst can't monopolize the request threads.
115
+ # .result() blocks the calling worker thread, never the event loop.
116
+ return _EMBED_EXECUTOR.submit(self._encode, text).result()
117
+
118
+ async def aroute(self, query: str, top_k: int | None = None) -> "RouterResponse":
119
+ """Async entry point: run the (blocking) hybrid route off the event loop.
120
+
121
+ The retrieval itself stays synchronous — its concurrency now comes from
122
+ the DB read pool (parallel graph/vector reads) and the bounded embed
123
+ pool. This wrapper just keeps the asyncio loop free so the API can serve
124
+ many requests at once without blocking.
125
+ """
126
+ loop = asyncio.get_running_loop()
127
+ return await loop.run_in_executor(None, self.route, query, top_k)
128
+
129
+ def _get_llm(self):
130
+ if self._llm is None:
131
+ from .llm import make_client
132
+
133
+ self._llm = make_client()
134
+ return self._llm
135
+
136
+ def _get_intent_llm(self) -> tuple[object, str]:
137
+ if self._intent_llm is None:
138
+ from .llm import make_intent_client
139
+
140
+ self._intent_llm = make_intent_client()
141
+ return self._intent_llm
142
+
143
+ def _classify_intent(self, query: str) -> tuple[float, float, str]:
144
+ q = query.lower()
145
+ if any(m in q for m in config.RELATIONAL_QUERY_MARKERS):
146
+ w = config.ROUTER_WEIGHTS_RELATIONAL
147
+ return w["vector"], w["graph"], "relational"
148
+ if any(m in q for m in config.SEMANTIC_QUERY_MARKERS):
149
+ w = config.ROUTER_WEIGHTS_CONCEPTUAL
150
+ return w["vector"], w["graph"], "semantic"
151
+ return self._classify_intent_llm(query)
152
+
153
+ def _classify_intent_llm(self, query: str) -> tuple[float, float, str]:
154
+ prompt = (
155
+ "Reply with ONE word — RELATIONAL if the query traces a sequence of "
156
+ "events/people/links, else SEMANTIC.\n\n" + query
157
+ )
158
+ try:
159
+ # one token, short timeout: fail fast to SEMANTIC, never dominate latency
160
+ client, model = self._get_intent_llm()
161
+ resp = client.chat.completions.create(
162
+ model=model,
163
+ messages=[{"role": "user", "content": prompt}],
164
+ temperature=0,
165
+ max_tokens=4,
166
+ timeout=6,
167
+ )
168
+ answer = (resp.choices[0].message.content or "").strip().upper()
169
+ except Exception as exc: # noqa: BLE001 — default to semantic on failure
170
+ logger.warning("Intent LLM failed (%s); defaulting to semantic.", exc)
171
+ answer = "SEMANTIC"
172
+ if "RELATIONAL" in answer:
173
+ w = config.ROUTER_WEIGHTS_RELATIONAL
174
+ return w["vector"], w["graph"], "relational"
175
+ w = config.ROUTER_WEIGHTS_CONCEPTUAL
176
+ return w["vector"], w["graph"], "semantic"
177
+
178
+ def _vector_hits(
179
+ self, query: str, k: int, meta: dict[str, dict]
180
+ ) -> list[tuple[str, float]]:
181
+ """Top-k vector hits as an ordered (id, similarity) list, best first."""
182
+ embedding = self.embed_query(query)
183
+ try:
184
+ hits = self.db.vector_search(embedding, k=k)
185
+ except Exception as exc: # noqa: BLE001 — empty index, etc.
186
+ logger.debug("vector_search returned nothing (%s)", exc)
187
+ return []
188
+ out: list[tuple[str, float]] = []
189
+ for h in hits:
190
+ nid = h["id"]
191
+ if nid is None:
192
+ continue
193
+ meta.setdefault(nid, {"label": h.get("label"), "type": h.get("type")})
194
+ out.append((nid, float(h["similarity"])))
195
+ return out
196
+
197
+ def _graph_stream(
198
+ self, seeds: list[str], meta: dict[str, dict]
199
+ ) -> tuple[dict[str, float], list[dict]]:
200
+ s_g: dict[str, float] = {seed: 1.0 for seed in seeds}
201
+ hops: list[dict] = []
202
+
203
+ # multiplicative BFS: path score = product of edge confidences
204
+ frontier: dict[str, float] = {seed: 1.0 for seed in seeds}
205
+ visited: set[str] = set(seeds)
206
+ for _ in range(config.GRAPH_MAX_HOPS):
207
+ if not frontier:
208
+ break
209
+ expanded = self.db.expand_frontier(
210
+ list(frontier), config.GRAPH_NEIGHBOR_K, config.MAX_DEGREE
211
+ )
212
+ next_frontier: dict[str, float] = {}
213
+ for node_id, acc in frontier.items():
214
+ for nb in expanded.get(node_id, []):
215
+ to_id = nb["id"]
216
+ conf = nb["confidence"]
217
+ path_score = acc * conf
218
+ hops.append({"from_id": node_id, "to_id": to_id, "confidence": conf})
219
+ meta.setdefault(to_id, {"label": nb["label"], "type": nb["type"]})
220
+ if path_score > s_g.get(to_id, 0.0):
221
+ s_g[to_id] = path_score
222
+ if to_id not in visited:
223
+ visited.add(to_id)
224
+ if path_score > next_frontier.get(to_id, 0.0):
225
+ next_frontier[to_id] = path_score
226
+ frontier = next_frontier
227
+ return s_g, hops
228
+
229
+ def _attach_documents(self, results: list[RoutedNode]) -> None:
230
+ docs_by_entity = self.db.documents_for_entities([r.id for r in results])
231
+ for r in results:
232
+ r.documents = docs_by_entity.get(r.id, [])
233
+
234
+ def build_context(self, results: list[RoutedNode]) -> str:
235
+ """Assemble deduplicated context: cheap graph traces first, heavy text last."""
236
+ seen_chunk_ids: set[str] = set()
237
+ graph_traces: list[str] = []
238
+ text_chunks: list[str] = []
239
+
240
+ for node in results:
241
+ label = node.label or node.id
242
+ ntype = node.type or "Unknown"
243
+ graph_traces.append(f"- {label} ({ntype})")
244
+ for d in node.documents:
245
+ cid = d.get("doc_id")
246
+ text = (d.get("content") or "").strip()
247
+ if not text:
248
+ continue
249
+ if cid not in seen_chunk_ids:
250
+ seen_chunk_ids.add(cid)
251
+ text_chunks.append(f"[{label}] {text}")
252
+ else:
253
+ # duplicate chunk: keep the relationship fact, drop the text
254
+ graph_traces.append(f" [Trace: {label} ({ntype}) -> {cid}]")
255
+
256
+ sections: list[str] = []
257
+ if graph_traces:
258
+ sections.append("SYSTEM TRACES:\n" + "\n".join(graph_traces))
259
+ if text_chunks:
260
+ sections.append("DOCUMENT CHUNKS:\n" + "\n\n".join(text_chunks))
261
+ return "\n\n".join(sections)
262
+
263
+ def route(self, query: str, top_k: int | None = None) -> RouterResponse:
264
+ # Bound input size: spaCy/embedding cost scales with length, so an
265
+ # oversized query is a CPU-DoS. Real questions are short — truncate.
266
+ query = (query or "")[: config.MAX_QUERY_CHARS]
267
+ t0 = time.perf_counter()
268
+ alpha, beta, intent = self._classify_intent(query)
269
+ t_intent = time.perf_counter()
270
+ total_k = top_k if top_k is not None else config.TOP_K_VECTOR
271
+ meta: dict[str, dict] = {}
272
+
273
+ pool = self._vector_hits(query, config.TOP_K_VECTOR, meta)
274
+ t_vector = time.perf_counter()
275
+
276
+ # seeds: deterministic entity links first, then fuzzy cosine seeds
277
+ linked_seeds = self._link_query_entities(query, meta)
278
+ fuzzy_seeds = [
279
+ nid for nid, sv in sorted(pool, key=lambda kv: -kv[1])
280
+ if sv >= config.GRAPH_SEED_MIN_SIM
281
+ ][: config.GRAPH_SEED_TOP_N]
282
+ seeds = list(dict.fromkeys(linked_seeds + fuzzy_seeds))
283
+
284
+ # fallback: anchor on top vector hits so a relational query never idles at 0 hops
285
+ if not seeds and pool:
286
+ seeds = [
287
+ nid for nid, _ in sorted(pool, key=lambda kv: -kv[1])
288
+ ][: config.GRAPH_SEED_TOP_N]
289
+ t_seeds = time.perf_counter()
290
+
291
+ graph_scores, hops = self._graph_stream(seeds, meta)
292
+ t_graph = time.perf_counter()
293
+ seed_set = set(seeds)
294
+ graph_hits = sum(1 for nid in graph_scores if nid not in seed_set)
295
+
296
+ # dynamic throttle: more graph hits -> fewer bulky vector chunks
297
+ vector_k = max(2, total_k - graph_hits)
298
+ vector_scores = dict(pool[:vector_k])
299
+
300
+ results: list[RoutedNode] = []
301
+ for nid in set(vector_scores) | set(graph_scores):
302
+ sv = vector_scores.get(nid, 0.0)
303
+ sg = graph_scores.get(nid, 0.0)
304
+ info = meta.get(nid, {})
305
+ results.append(RoutedNode(
306
+ id=nid,
307
+ label=info.get("label"),
308
+ type=info.get("type"),
309
+ score_total=alpha * sv + beta * sg,
310
+ score_vector=sv,
311
+ score_graph=sg,
312
+ ))
313
+ results.sort(key=lambda r: r.score_total, reverse=True)
314
+ results = results[:total_k]
315
+
316
+ self._attach_documents(results)
317
+ t_docs = time.perf_counter()
318
+
319
+ logger.info(
320
+ "route timings (ms): intent=%.0f vector=%.0f seeds=%.0f graph=%.0f "
321
+ "assemble=%.0f | TOTAL=%.0f [intent=%s seeds=%d hops=%d]",
322
+ (t_intent - t0) * 1000, (t_vector - t_intent) * 1000,
323
+ (t_seeds - t_vector) * 1000, (t_graph - t_seeds) * 1000,
324
+ (t_docs - t_graph) * 1000, (t_docs - t0) * 1000,
325
+ intent, len(seeds), len(hops),
326
+ )
327
+
328
+ trace_log = {
329
+ "intent": {"alpha": alpha, "beta": beta, "type": intent},
330
+ "execution_path": {
331
+ "linked_seeds": linked_seeds,
332
+ "vector_seeds": seeds,
333
+ "graph_hops": hops,
334
+ },
335
+ "metrics": {
336
+ "graph_hits": graph_hits,
337
+ "vector_k": vector_k,
338
+ "total_nodes_evaluated": len(vector_scores) + len(graph_scores),
339
+ },
340
+ }
341
+ return RouterResponse(query=query, results=results, trace_log=trace_log)