"""RAG Knowledge API — LangChain + ChromaDB + Docling + LLM with persistent document registry.""" from __future__ import annotations import hashlib import json import os import re import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any import httpx from fastapi import FastAPI, File, Form, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma from langchain_core.documents import Document from langchain_core.messages import HumanMessage, SystemMessage from langchain_openai import ChatOpenAI from langchain_text_splitters import RecursiveCharacterTextSplitter import chromadb from pydantic import BaseModel CHROMA_HOST = os.getenv("CHROMA_HOST", "chromadb") CHROMA_PORT = int(os.getenv("CHROMA_PORT", "8000")) CHROMA_URL = f"http://{CHROMA_HOST}:{CHROMA_PORT}" DOCLING_URL = os.getenv("DOCLING_URL", "http://docling-serve:5001").rstrip("/") LLM_URL = os.getenv("LLM_URL", "http://10.0.20.106:8001/v1") LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o") LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local") DATA_DIR = Path(os.getenv("RAG_DATA_DIR", "/data")) UPLOADS_DIR = DATA_DIR / "uploads" REGISTRY_PATH = DATA_DIR / "document_registry.json" EMBED_MODEL = os.getenv("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2") # Use placeholder images in markdown — embedded base64 destroys RAG quality. DOCLING_IMAGE_MODE = os.getenv("DOCLING_IMAGE_MODE", "placeholder") app = FastAPI(title="ATC RAG Knowledge API", version="1.2.0") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) _splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=120) _embeddings: HuggingFaceEmbeddings | None = None _BASE64_BLOB = re.compile(r"[A-Za-z0-9+/]{120,}={0,2}") _BASE64_IMG = re.compile(r"!\[[^\]]*\]\(data:image/[^)]+\)", re.IGNORECASE) _IMAGE_REF = re.compile(r"!\[Image\]\([^)]+\)") def _ensure_dirs() -> None: UPLOADS_DIR.mkdir(parents=True, exist_ok=True) if not REGISTRY_PATH.exists(): REGISTRY_PATH.write_text(json.dumps({"documents": []}, indent=2)) def _load_registry() -> list[dict[str, Any]]: _ensure_dirs() try: data = json.loads(REGISTRY_PATH.read_text()) return data.get("documents", []) except Exception: return [] def _save_registry(docs: list[dict[str, Any]]) -> None: _ensure_dirs() REGISTRY_PATH.write_text( json.dumps({"documents": docs, "updated_at": datetime.now(timezone.utc).isoformat()}, indent=2, default=str) ) def _file_hash(content: bytes) -> str: return hashlib.sha256(content).hexdigest() def _safe_collection(name: str) -> str: return "".join(c if c.isalnum() or c in "-_" else "_" for c in name.strip())[:64] or "default" def clean_text_for_rag(text: str) -> str: """Strip embedded images and base64 blobs that pollute vector search.""" text = _BASE64_IMG.sub("", text) text = _IMAGE_REF.sub("", text) text = _BASE64_BLOB.sub("", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def is_garbage_chunk(text: str) -> bool: """Detect chunks that are mostly binary/base64 noise.""" if not text or len(text) < 20: return True if "data:image" in text: return True if _BASE64_BLOB.search(text): return True alpha = sum(1 for c in text if c.isalpha() or c.isspace()) if alpha / max(len(text), 1) < 0.35: return True return False def get_embeddings() -> HuggingFaceEmbeddings: global _embeddings if _embeddings is None: _embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL) return _embeddings def get_chroma_client() -> chromadb.HttpClient: return chromadb.HttpClient(host=CHROMA_HOST, port=CHROMA_PORT) def get_vectorstore(collection: str) -> Chroma: return Chroma( client=get_chroma_client(), collection_name=collection, embedding_function=get_embeddings(), ) def get_llm(temperature: float = 0.2) -> ChatOpenAI: return ChatOpenAI( base_url=LLM_URL, api_key=LLM_API_KEY, model=LLM_MODEL, temperature=temperature, ) def _delete_doc_vectors(collection: str, doc_id: str) -> None: try: col = get_chroma_client().get_collection(collection) col.delete(where={"doc_id": doc_id}) except Exception: pass async def extract_text(content: bytes, filename: str) -> str: ext = Path(filename).suffix.lower() if ext in {".txt", ".md", ".csv", ".json"}: try: raw = content.decode("utf-8") except UnicodeDecodeError: raw = content.decode("latin-1", errors="replace") return clean_text_for_rag(raw) async with httpx.AsyncClient(timeout=300.0) as client: r = await client.post( f"{DOCLING_URL}/v1/convert/file", files={"files": (filename, content, "application/octet-stream")}, data={ "to_formats": ["md"], "image_export_mode": DOCLING_IMAGE_MODE, "do_ocr": "true", "table_mode": "accurate", }, ) if r.status_code >= 400: raise ValueError(f"Docling failed: {r.text[:300]}") doc = r.json().get("document") or {} md = doc.get("md_content") or doc.get("text_content") or "" md = clean_text_for_rag(md) if len(md) < 50: raise ValueError("No readable text extracted from document") return md def _find_duplicate(content_hash: str, collection: str) -> dict[str, Any] | None: for d in _load_registry(): if d.get("content_hash") == content_hash and d.get("collection") == collection: return d return None def _find_by_id(doc_id: str) -> dict[str, Any] | None: for d in _load_registry(): if d.get("id") == doc_id: return d return None async def _ingest_bytes( content: bytes, filename: str, collection: str, source: str = "upload", *, force_reindex: bool = False, ) -> dict[str, Any]: col = _safe_collection(collection) content_hash = _file_hash(content) existing = _find_duplicate(content_hash, col) if existing and not force_reindex: return { "ok": True, "duplicate": True, "skipped": True, "message": f"Document already indexed as '{existing['filename']}' — chat immediately, no re-upload needed.", **{k: existing[k] for k in ("id", "filename", "collection", "chunks", "characters", "ingested_at") if k in existing}, } if existing and force_reindex: doc_id = existing["id"] _delete_doc_vectors(col, doc_id) stored_path = Path(existing.get("stored_path", "")) if stored_path.exists(): stored_path.write_bytes(content) else: stored_name = f"{doc_id}_{Path(filename).name}" stored_path = UPLOADS_DIR / stored_name stored_path.write_bytes(content) else: doc_id = uuid.uuid4().hex[:12] stored_name = f"{doc_id}_{Path(filename).name}" stored_path = UPLOADS_DIR / stored_name stored_path.write_bytes(content) text = await extract_text(content, filename) chunks = [c for c in _splitter.split_text(text) if not is_garbage_chunk(c)] if not chunks: raise ValueError("No usable text chunks after cleaning — document may be image-only") docs = [ Document( page_content=chunk, metadata={ "source": filename, "doc_id": doc_id, "chunk": i, "content_hash": content_hash, "ingested_at": datetime.now(timezone.utc).isoformat(), }, ) for i, chunk in enumerate(chunks) ] vs = get_vectorstore(col) vs.add_documents(docs) record = { "id": doc_id, "filename": filename, "collection": col, "content_hash": content_hash, "stored_path": str(stored_path), "chunks": len(chunks), "characters": len(text), "bytes": len(content), "source": source, "ingested_at": datetime.now(timezone.utc).isoformat(), } registry = _load_registry() registry = [d for d in registry if not (d.get("id") == doc_id and d.get("collection") == col)] registry.insert(0, record) _save_registry(registry) return {"ok": True, "duplicate": False, "reindexed": force_reindex, **record} async def _summarize_text(text: str, filename: str) -> str: """Summarize document text using LLM with map-reduce for long docs.""" llm = get_llm(temperature=0.1) max_chunk = 12000 if len(text) <= max_chunk: prompt = ( f"Summarize this document ({filename}) clearly in English. " "Include: main topic, key sections, important technologies/products mentioned, and target audience. " "Use bullet points and short paragraphs.\n\nDocument:\n{text}" ) resp = llm.invoke([HumanMessage(content=prompt.format(text=text[:max_chunk]))]) return resp.content if hasattr(resp, "content") else str(resp) # Map-reduce for long documents parts = [text[i : i + max_chunk] for i in range(0, min(len(text), 60000), max_chunk)] partials: list[str] = [] for i, part in enumerate(parts[:5]): resp = llm.invoke([ HumanMessage(content=( f"Summarize part {i + 1}/{min(len(parts), 5)} of '{filename}'. " f"List key topics, products, and technical points:\n\n{part}" )) ]) partials.append(resp.content if hasattr(resp, "content") else str(resp)) combined = "\n\n".join(partials) final = llm.invoke([ HumanMessage(content=( f"Create a clear executive summary of '{filename}' from these section summaries. " "Structure: Overview, Main Topics, Key Technologies, Audience. Use bullet points.\n\n" f"{combined}" )) ]) return final.content if hasattr(final, "content") else str(final) class ChatRequest(BaseModel): message: str collection: str = "default" top_k: int = 5 class IngestTextRequest(BaseModel): text: str collection: str = "default" source: str = "manual" class SummarizeRequest(BaseModel): collection: str = "default" doc_id: str | None = None filename: str | None = None @app.get("/health") async def health(): chroma_ok = docling_ok = llm_ok = False doc_count = len(_load_registry()) try: async with httpx.AsyncClient(timeout=5.0) as c: cr = await c.get(f"{CHROMA_URL}/api/v1/heartbeat") chroma_ok = cr.status_code < 400 dr = await c.get(f"{DOCLING_URL}/health") docling_ok = dr.status_code < 400 and dr.json().get("status") == "ok" lr = await c.get(f"{LLM_URL.rstrip('/')}/models") llm_ok = lr.status_code < 400 except Exception: pass return { "ok": chroma_ok, "chroma": chroma_ok, "docling": docling_ok, "llm": llm_ok, "embed_model": EMBED_MODEL, "stored_documents": doc_count, "persistent": True, "docling_image_mode": DOCLING_IMAGE_MODE, } @app.get("/documents") async def list_documents(collection: str | None = None): docs = _load_registry() if collection: col = _safe_collection(collection) docs = [d for d in docs if d.get("collection") == col] return {"documents": docs, "total": len(docs)} @app.get("/documents/{doc_id}") async def get_document(doc_id: str): doc = _find_by_id(doc_id) if doc: return doc return JSONResponse({"error": "not found"}, status_code=404) @app.get("/documents/{doc_id}/file") async def download_document(doc_id: str): doc = _find_by_id(doc_id) if doc: path = Path(doc.get("stored_path", "")) if path.exists(): return FileResponse(path, filename=doc.get("filename", path.name)) return JSONResponse({"error": "not found"}, status_code=404) @app.post("/documents/{doc_id}/reindex") async def reindex_document(doc_id: str): doc = _find_by_id(doc_id) if not doc: return JSONResponse({"error": "not found"}, status_code=404) path = Path(doc.get("stored_path", "")) if not path.exists(): return JSONResponse({"error": "stored file missing"}, status_code=404) try: content = path.read_bytes() result = await _ingest_bytes( content, doc["filename"], doc["collection"], source=doc.get("source", "reindex"), force_reindex=True, ) return result except ValueError as exc: return JSONResponse({"ok": False, "error": str(exc)}, status_code=422) @app.get("/collections") async def list_collections(): try: client = get_chroma_client() cols = client.list_collections() registry = _load_registry() items = [] for col in cols: files = {d["filename"] for d in registry if d.get("collection") == col.name} items.append({ "name": col.name, "documents": col.count(), "files": len(files), "filenames": sorted(files)[:20], }) return {"collections": items} except Exception as exc: return JSONResponse({"error": str(exc), "collections": []}, status_code=502) @app.post("/collections") async def create_collection(name: str = Form(...)): safe = _safe_collection(name) get_vectorstore(safe) return {"ok": True, "collection": safe} @app.post("/ingest") async def ingest_file( file: UploadFile = File(...), collection: str = Form("default"), force_reindex: bool = Form(False), ): _ensure_dirs() safe_name = file.filename or "upload.txt" content = await file.read() try: result = await _ingest_bytes(content, safe_name, collection, force_reindex=force_reindex) if not result.get("ok"): return JSONResponse(result, status_code=422) return result except ValueError as exc: return JSONResponse({"ok": False, "error": str(exc)}, status_code=422) @app.post("/ingest/text") async def ingest_text(body: IngestTextRequest): col = _safe_collection(body.collection) content = body.text.encode("utf-8") filename = f"{body.source}.txt" return await _ingest_bytes(content, filename, col, source=body.source) @app.post("/summarize") async def summarize(body: SummarizeRequest): col = _safe_collection(body.collection) registry = _load_registry() doc: dict[str, Any] | None = None if body.doc_id: doc = _find_by_id(body.doc_id) elif body.filename: for d in registry: if d.get("filename") == body.filename and d.get("collection") == col: doc = d break else: docs_in_col = [d for d in registry if d.get("collection") == col] if len(docs_in_col) == 1: doc = docs_in_col[0] if not doc: return JSONResponse({"ok": False, "error": "Document not found — specify doc_id or filename"}, status_code=404) path = Path(doc.get("stored_path", "")) if not path.exists(): return JSONResponse({"ok": False, "error": "Stored file missing"}, status_code=404) try: content = path.read_bytes() text = await extract_text(content, doc["filename"]) summary = await _summarize_text(text, doc["filename"]) return { "ok": True, "summary": summary, "filename": doc["filename"], "doc_id": doc["id"], "collection": col, "characters": len(text), } except ValueError as exc: return JSONResponse({"ok": False, "error": str(exc)}, status_code=422) except Exception as exc: return JSONResponse({"ok": False, "error": f"Summarize failed: {exc}"}, status_code=502) @app.post("/chat") async def chat(body: ChatRequest): col = _safe_collection(body.collection) try: vs = get_vectorstore(col) count = get_chroma_client().get_collection(col).count() except Exception as exc: return JSONResponse({"ok": False, "error": f"Collection unavailable: {exc}"}, status_code=404) if count == 0: stored = [d for d in _load_registry() if d.get("collection") == col] if stored: return JSONResponse({ "ok": False, "error": "Vectors missing but files exist — click Re-index on the document in the library.", "stored_documents": len(stored), }, status_code=400) return JSONResponse({"ok": False, "error": "Collection is empty — upload documents first."}, status_code=400) retriever = vs.as_retriever(search_kwargs={"k": min(body.top_k * 3, 20)}) raw_docs = retriever.invoke(body.message) docs = [d for d in raw_docs if not is_garbage_chunk(d.page_content)][: body.top_k] if not docs: return JSONResponse({ "ok": False, "error": "Retrieved chunks are corrupted (old base64 index). Click Re-index on the document.", }, status_code=400) context = "\n\n---\n\n".join( f"[Source: {d.metadata.get('source', '?')} | chunk {d.metadata.get('chunk', '?')}]\n{d.page_content}" for d in docs ) system = ( "You are a helpful data assistant for the Dell ATC platform. " "Answer ONLY based on the provided context. If the context does not contain the answer, say so clearly. " "Cite sources by filename when relevant. Be concise and technical." ) user = f"Context:\n{context}\n\nQuestion: {body.message}" try: llm = get_llm() resp = llm.invoke([SystemMessage(content=system), HumanMessage(content=user)]) answer = resp.content if hasattr(resp, "content") else str(resp) except Exception as exc: return JSONResponse({"ok": False, "error": f"LLM error: {exc}"}, status_code=502) sources = [ {"source": d.metadata.get("source"), "chunk": d.metadata.get("chunk"), "preview": d.page_content[:200]} for d in docs ] return {"ok": True, "answer": answer, "sources": sources, "collection": col, "context_chunks": len(docs)} @app.get("/") async def root(): return { "service": "ATC RAG Knowledge API", "persistent_storage": "ChromaDB + document registry on disk", "endpoints": ["/health", "/documents", "/collections", "/ingest", "/chat", "/summarize", "/docs"], }