Spaces:
Running
Running
Karush2807 commited on
Commit ·
db284b9
1
Parent(s): 6662c95
TraceRAG backend + demo graphs
Browse files- .dockerignore +23 -0
- .env.example +16 -0
- .gitattributes +1 -0
- Dockerfile +37 -0
- README.md +13 -5
- api.py +487 -0
- auth.py +180 -0
- cache.py +67 -0
- database.py +54 -0
- graphs/apache__kafka.lbug +3 -0
- graphs/encode__httpx.lbug +3 -0
- graphs/encode__starlette.lbug +3 -0
- graphs/encode__uvicorn.lbug +3 -0
- graphs/fastapi__fastapi.lbug +3 -0
- graphs/huggingface__transformers.lbug +3 -0
- graphs/langchain-ai__langchain.lbug +3 -0
- graphs/openai__openai-python.lbug +3 -0
- graphs/pallets__click.lbug +3 -0
- graphs/pallets__flask.lbug +3 -0
- graphs/psf__requests.lbug +3 -0
- graphs/pydantic__pydantic.lbug +3 -0
- graphs/tiangolo__typer.lbug +3 -0
- models.py +47 -0
- ratelimit.py +54 -0
- requirements.txt +55 -0
- routers/__init__.py +0 -0
- routers/history.py +170 -0
- scripts/audit_data.py +136 -0
- scripts/benchmark.py +216 -0
- scripts/ingest.py +182 -0
- scripts/ingest_github.py +195 -0
- scripts/ingest_jira_api.py +201 -0
- scripts/ingest_kaggle_jira.py +163 -0
- scripts/profile_route.py +74 -0
- scripts/ratelimit_test.py +186 -0
- scripts/stress_graph.py +225 -0
- scripts/stress_test.py +93 -0
- tracerag/__init__.py +3 -0
- tracerag/config.py +155 -0
- tracerag/curation.py +217 -0
- tracerag/db.py +401 -0
- tracerag/extract.py +261 -0
- tracerag/integrations/__init__.py +1 -0
- tracerag/integrations/langchain.py +64 -0
- tracerag/llm.py +32 -0
- tracerag/router.py +341 -0
.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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
short_description: observability tool using knowledge graph RAG
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|