Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| FastAPI endpoint for JUUL Vectorless PageIndex RAG. | |
| Run: uv run uvicorn rag.api:app --reload | |
| """ | |
| import os | |
| import re | |
| import json | |
| import uuid | |
| import logging | |
| from pathlib import Path | |
| from datetime import datetime | |
| from typing import List, Dict, Optional | |
| from collections import Counter | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(message)s", | |
| datefmt="%H:%M:%S", | |
| ) | |
| log = logging.getLogger("casbinder") | |
| import fitz | |
| from openai import OpenAI | |
| from rank_bm25 import BM25Okapi | |
| from dotenv import load_dotenv | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.responses import StreamingResponse | |
| from pydantic import BaseModel | |
| from qdrant_client import QdrantClient | |
| load_dotenv(Path(__file__).parent.parent / ".env") | |
| INDEX_PATH = Path(os.environ.get("INDEX_PATH", Path(__file__).parent / "page_index.json")) | |
| DOWNLOADS_DIR = Path(os.environ.get("DOWNLOADS_DIR", Path(__file__).parent.parent / "downloads")) | |
| SESSIONS_DIR = Path(os.environ.get("SESSIONS_DIR", Path(__file__).parent / "sessions")) | |
| MODEL = "gpt-4o" | |
| EMBED_MODEL = "text-embedding-3-small" | |
| QDRANT_COLLECTION = "juul_mdl_chunks" | |
| _MASTER_CONTEXT_PATH = Path(os.environ.get( | |
| "MASTER_CONTEXT_PATH", | |
| Path(__file__).parent.parent / "rag" / "prompt.txt" | |
| )) | |
| JUUL_MASTER_CONTEXT = ( | |
| _MASTER_CONTEXT_PATH.read_text(encoding="utf-8") | |
| if _MASTER_CONTEXT_PATH.exists() | |
| else "" | |
| ) | |
| # ββ Musk v. Altman config βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MUSK_COLLECTION = "musk_altman_chunks" | |
| MUSK_DOWNLOADS = Path(__file__).parent.parent / "downloads" / "musk_v_altman" | |
| _MUSK_CONTEXT_PATH = Path(__file__).parent.parent / "sam-musk.txt" | |
| MUSK_MASTER_CONTEXT = ( | |
| _MUSK_CONTEXT_PATH.read_text(encoding="utf-8") | |
| if _MUSK_CONTEXT_PATH.exists() | |
| else "" | |
| ) | |
| MUSK_SYSTEM = """You are a knowledgeable legal assistant on the Musk v. Altman case (Elon Musk and X.AI Corp. v. Samuel Altman, Gregory Brockman, OpenAI Inc., Microsoft Corp. et al., N.D. Cal., Case No. 3:24-cv-02022). | |
| Reply in 1-2 sentences. Short, confident, natural β like a colleague giving a quick answer. | |
| Never mention sources, documents, searches, filenames, or anything internal. | |
| If unsure, make a reasonable guess. Never refuse to answer. | |
| Only output NO_DOCUMENTS_FOUND if the question has nothing to do with this case.""" | |
| MUSK_TOOLS = [ | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "vector_search", | |
| "description": ( | |
| "Semantic vector search over all Musk v. Altman court document chunks. " | |
| "Use for any question about parties, claims, filings, orders, or case facts." | |
| ), | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "query": {"type": "string", "description": "Natural language question or topic"}, | |
| "n": {"type": "integer", "description": "Number of chunks to return (default 8)"}, | |
| }, | |
| "required": ["query"], | |
| }, | |
| }, | |
| }, | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "read_doc", | |
| "description": "Read full text of a specific document PDF by filename.", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "filename": {"type": "string"}, | |
| "pages": {"type": "string", "description": "Page range e.g. '1-5' (default: all, max 20)"}, | |
| }, | |
| "required": ["filename"], | |
| }, | |
| }, | |
| }, | |
| ] | |
| FALLBACK_SYSTEM = """You are a knowledgeable legal assistant on the JUUL MDL case. | |
| Reply in 1-2 sentences. Short, confident, natural β like a colleague giving a quick answer. | |
| Never mention sources, documents, searches, or anything internal. | |
| If unsure, give a reasonable guess. Never refuse. | |
| Only output NO_DOCUMENTS_FOUND if the question has nothing to do with the case. | |
| {context}""" | |
| SYSTEM = """You are a knowledgeable legal assistant on the JUUL MDL case (MDL 2913, N.D. Cal.). | |
| Reply in 1-2 sentences. Short, confident, natural β like a colleague giving a quick answer. | |
| Never mention sources, documents, searches, filenames, or anything internal. | |
| If unsure, make a reasonable guess. Never refuse to answer. | |
| Only output NO_DOCUMENTS_FOUND if the question has nothing to do with this case.""" | |
| TOOLS = [ | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "search", | |
| "description": "BM25 keyword search over document titles in the PageIndex.", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "query": {"type": "string"}, | |
| "n": {"type": "integer", "description": "Max results (default 15)"}, | |
| "category": {"type": "string", "description": "Optional category filter"}, | |
| }, | |
| "required": ["query"], | |
| }, | |
| }, | |
| }, | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "filter", | |
| "description": "Filter PageIndex by category and/or importance.", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "category": {"type": "string"}, | |
| "importance": {"type": "string", "description": "critical | high | medium | low"}, | |
| }, | |
| "required": ["category"], | |
| }, | |
| }, | |
| }, | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "read_doc", | |
| "description": "Read full text of a document from its downloaded PDF.", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "filename": {"type": "string"}, | |
| "pages": {"type": "string", "description": "Page range e.g. '1-5' (default: all, max 20)"}, | |
| }, | |
| "required": ["filename"], | |
| }, | |
| }, | |
| }, | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "categories", | |
| "description": "List all document categories and counts.", | |
| "parameters": {"type": "object", "properties": {}}, | |
| }, | |
| }, | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "vector_search", | |
| "description": ( | |
| "Semantic vector search over all document chunks. " | |
| "Use this for meaning-based queries that keyword search may miss β " | |
| "e.g. 'upcoming trial date', 'settlement amount', 'expert witness opinions'. " | |
| "Returns actual passage text from the most relevant chunks." | |
| ), | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "query": {"type": "string", "description": "Natural language question or topic"}, | |
| "n": {"type": "integer", "description": "Number of chunks to return (default 8)"}, | |
| }, | |
| "required": ["query"], | |
| }, | |
| }, | |
| }, | |
| ] | |
| # ββ Session store βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _session_path(session_id: str) -> Path: | |
| return SESSIONS_DIR / f"{session_id}.json" | |
| def load_session(session_id: str) -> dict: | |
| path = _session_path(session_id) | |
| if path.exists(): | |
| with open(path) as f: | |
| return json.load(f) | |
| return {"session_id": session_id, "created_at": datetime.utcnow().isoformat(), "messages": []} | |
| def save_session(session: dict): | |
| SESSIONS_DIR.mkdir(parents=True, exist_ok=True) | |
| session["updated_at"] = datetime.utcnow().isoformat() | |
| with open(_session_path(session["session_id"]), "w") as f: | |
| json.dump(session, f, indent=2) | |
| # ββ PageIndex βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class PageIndex: | |
| def __init__(self): | |
| with open(INDEX_PATH) as f: | |
| self.entries: List[Dict] = json.load(f) | |
| # Search over title + body if available, else just title | |
| tokenized = [self._tok(self._search_text(e)) for e in self.entries] | |
| self.bm25 = BM25Okapi(tokenized) | |
| def _tok(self, text: str) -> List[str]: | |
| return re.findall(r'\w+', text.lower()) | |
| def _search_text(self, e: Dict) -> str: | |
| return e["title"] + " " + e.get("body", "") | |
| def search(self, query: str, n: int = 15, category: Optional[str] = None) -> List[Dict]: | |
| pool = self.entries | |
| if category: | |
| pool = [e for e in self.entries if e["category"].lower() == category.lower()] or self.entries | |
| tok = [self._tok(self._search_text(e)) for e in pool] | |
| scores = BM25Okapi(tok).get_scores(self._tok(query)) | |
| ranked = sorted(enumerate(scores), key=lambda x: -x[1]) | |
| return [{**pool[i], "score": round(float(s), 3)} for i, s in ranked[:n] if s > 0] | |
| def multi_search(self, queries: List[str], n: int = 15, category: Optional[str] = None) -> List[Dict]: | |
| """Run BM25 on multiple queries, merge by best score per document.""" | |
| best: Dict[str, Dict] = {} | |
| for q in queries: | |
| for entry in self.search(q, n=n, category=category): | |
| key = entry["filename"] | |
| if key not in best or entry["score"] > best[key]["score"]: | |
| best[key] = entry | |
| return sorted(best.values(), key=lambda x: -x["score"])[:n] | |
| def filter(self, category: str, importance: Optional[str] = None) -> List[Dict]: | |
| r = [e for e in self.entries if e["category"].lower() == category.lower()] | |
| if importance: | |
| r = [e for e in r if e["importance"] == importance] | |
| return sorted(r, key=lambda e: (e["doc_num"], e["attach_num"])) | |
| def categories(self) -> Dict[str, int]: | |
| return dict(Counter(e["category"] for e in self.entries).most_common()) | |
| def read_pdf(filename: str, pages: Optional[str] = None, | |
| base_dir: Optional[Path] = None) -> str: | |
| path = (base_dir or DOWNLOADS_DIR) / filename | |
| if not path.exists(): | |
| return f"FILE_NOT_FOUND:{filename}" | |
| try: | |
| doc = fitz.open(str(path)) | |
| total = doc.page_count | |
| if pages: | |
| parts = pages.split("-") | |
| start = int(parts[0]) - 1 | |
| end = int(parts[1]) - 1 if len(parts) > 1 else start | |
| else: | |
| start, end = 0, min(total - 1, 19) | |
| texts = [] | |
| for i in range(start, end + 1): | |
| if 0 <= i < total: | |
| text = re.sub(r'\n{3,}', '\n\n', doc[i].get_text("text")).strip() | |
| if text: | |
| texts.append(f"--- Page {i+1} ---\n{text}") | |
| doc.close() | |
| return (f"[{filename} | {total} pages | p.{start+1}β{end+1}]\n\n" + "\n\n".join(texts)) if texts else f"No text in {filename}" | |
| except Exception as ex: | |
| return f"Error reading {filename}: {ex}" | |
| def fmt(e: Dict) -> str: | |
| score = f" score={e['score']:.2f}" if "score" in e else "" | |
| return f"Doc #{e['doc_num']} | {e['category']} | {e['importance'].upper()}{score} | {e['title'][:150]} | file: {e['filename']}" | |
| def run_tool(idx: PageIndex, name: str, args: Dict): | |
| """Returns (result_text, had_results, sources).""" | |
| sources = [] | |
| if name == "search": | |
| results = idx.search(args["query"], args.get("n", 15), args.get("category")) | |
| if not results: | |
| return f"No results for: '{args['query']}'", False, [] | |
| sources = [{"doc_num": e["doc_num"], "filename": e["filename"], "category": e["category"], "title": e["title"][:200]} for e in results] | |
| return f"Results for '{args['query']}':\n" + "\n".join(fmt(e) for e in results), True, sources | |
| elif name == "filter": | |
| results = idx.filter(args["category"], args.get("importance")) | |
| if not results: | |
| return f"No documents in category: {args['category']}", False, [] | |
| sources = [{"doc_num": e["doc_num"], "filename": e["filename"], "category": e["category"], "title": e["title"][:200]} for e in results] | |
| return f"{len(results)} docs in '{args['category']}':\n" + "\n".join(fmt(e) for e in results), True, sources | |
| elif name == "read_doc": | |
| text = read_pdf(args["filename"], args.get("pages")) | |
| has_result = not text.startswith("FILE_NOT_FOUND") and not text.startswith("Error") | |
| return text, has_result, [] | |
| elif name == "categories": | |
| cats = idx.categories() | |
| return "Categories:\n" + "\n".join(f"{c}: {n}" for c, n in cats.items()), True, [] | |
| return f"Unknown tool: {name}", False, [] | |
| HEDGE_PHRASES = [ | |
| "no document", | |
| "no documents", | |
| "does not provide", | |
| "does not mention", | |
| "does not contain", | |
| "no information", | |
| "no specific", | |
| "not explicitly", | |
| "no record", | |
| "cannot find", | |
| "could not find", | |
| "unable to find", | |
| "not found", | |
| "no filing", | |
| "no filings", | |
| "not available", | |
| "no evidence", | |
| "search result", | |
| "search results", | |
| "provided context", | |
| "provided document", | |
| "provided information", | |
| "given information", | |
| "given context", | |
| "retrieved", | |
| "indexed", | |
| "case document", | |
| "case file", | |
| "available document", | |
| "available information", | |
| "the context", | |
| "no indication", | |
| "no explicit", | |
| "cannot confirm", | |
| "can't confirm", | |
| "further investigation", | |
| "detailed search", | |
| "if you have more", | |
| "please provide more", | |
| "further clarification", | |
| "master document", | |
| "cannot confirm or deny", | |
| "i cannot confirm", | |
| "i can't confirm", | |
| "do not mention", | |
| "does not mention", | |
| "not mentioned", | |
| "no mention", | |
| ] | |
| def is_hedging_answer(answer: str) -> bool: | |
| low = answer.lower() | |
| return any(p in low for p in HEDGE_PHRASES) | |
| def expand_queries(client: OpenAI, question: str) -> List[str]: | |
| """Generate 2-3 BM25-optimized keyword search queries from the user's question.""" | |
| resp = client.chat.completions.create( | |
| model="gpt-4o-mini", | |
| messages=[ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "You are a search query generator for a legal document index. " | |
| "Given a user's question about the JUUL MDL case, output 2-3 short " | |
| "keyword search queries that would best retrieve relevant court filings " | |
| "using BM25 keyword matching. Focus on legal terms, names, and filing types. " | |
| "Output ONLY the queries, one per line, no numbering, no explanation." | |
| ), | |
| }, | |
| {"role": "user", "content": question}, | |
| ], | |
| max_tokens=100, | |
| temperature=0, | |
| ) | |
| raw = resp.choices[0].message.content or "" | |
| queries = [q.strip() for q in raw.strip().splitlines() if q.strip()] | |
| # Always include the original question as a fallback | |
| if question not in queries: | |
| queries.insert(0, question) | |
| return queries[:4] | |
| def try_master_context_fallback(client: OpenAI, question: str, | |
| retrieved_chunks: Optional[str] = None) -> Optional[str]: | |
| """ | |
| Fallback: answer using master context + any retrieved chunks combined. | |
| This gives the model every scrap of relevant information before giving up. | |
| Returns the answer string, or None if still can't answer. | |
| """ | |
| now = datetime.utcnow().strftime("Current date and time (UTC): %A, %B %d, %Y %H:%M") | |
| combined_context = JUUL_MASTER_CONTEXT | |
| if retrieved_chunks: | |
| combined_context = ( | |
| "## RETRIEVED DOCUMENT PASSAGES\n\n" | |
| + retrieved_chunks | |
| + "\n\n---\n\n" | |
| + "## AUTHORITATIVE CASE SUMMARY\n\n" | |
| + JUUL_MASTER_CONTEXT | |
| ) | |
| fallback_system = """You are a legal expert on the JUUL Labs MDL case. Answer short and direct. | |
| RULES β no exceptions: | |
| 1. NEVER say: "provided context", "given information", "based on documents", "retrieved", | |
| "I cannot confirm", "no explicit mention", "available information", or anything that | |
| hedges or reveals internal workings. | |
| 2. If unsure, make a confident intelligent guess. That is expected and acceptable. | |
| 3. Never refuse. Always give an answer. | |
| 4. Only output NO_DOCUMENTS_FOUND if the question has zero connection to this case. | |
| {context}""" | |
| resp = client.chat.completions.create( | |
| model=MODEL, | |
| messages=[ | |
| {"role": "system", "content": fallback_system.format(context=combined_context)}, | |
| {"role": "user", "content": f"[{now}]\n\n{question}"}, | |
| ], | |
| ) | |
| answer = resp.choices[0].message.content or "" | |
| if "NO_DOCUMENTS_FOUND" in answer or is_hedging_answer(answer): | |
| return None | |
| return answer | |
| def embed_query(client: OpenAI, text: str) -> List[float]: | |
| resp = client.embeddings.create(model=EMBED_MODEL, input=[text]) | |
| return resp.data[0].embedding | |
| def qdrant_search(client: OpenAI, qdrant: QdrantClient, query: str, n: int = 15, | |
| collection: str = QDRANT_COLLECTION) -> List[Dict]: | |
| """Embed query and search Qdrant for top-n most relevant chunks.""" | |
| vector = embed_query(client, query) | |
| response = qdrant.query_points( | |
| collection_name=collection, | |
| query=vector, | |
| limit=n, | |
| with_payload=True, | |
| ) | |
| hits = response.points | |
| results = [] | |
| for hit in hits: | |
| p = hit.payload | |
| results.append({ | |
| "doc_num": p.get("doc_num"), | |
| "filename": p.get("filename"), | |
| "category": p.get("category"), | |
| "importance": p.get("importance"), | |
| "title": p.get("title", "")[:150], | |
| "summary": p.get("summary", ""), | |
| "chunk_index": p.get("chunk_index"), | |
| "approx_page": p.get("approx_page"), | |
| "chunk_text": p.get("chunk_text", ""), | |
| "score": round(hit.score, 3), | |
| }) | |
| return results | |
| def qdrant_multi_search(client: OpenAI, qdrant: QdrantClient, queries: List[str], n: int = 15, | |
| collection: str = QDRANT_COLLECTION) -> List[Dict]: | |
| """Run vector search on multiple query variants, merge by best score, deduplicate by doc.""" | |
| best: Dict[str, Dict] = {} # chunk_key -> best result | |
| for query in queries: | |
| try: | |
| for r in qdrant_search(client, qdrant, query, n=n, collection=collection): | |
| # deduplicate: one chunk per (filename, chunk_index) | |
| key = f"{r['filename']}::{r['chunk_index']}" | |
| if key not in best or r["score"] > best[key]["score"]: | |
| best[key] = r | |
| except Exception: | |
| continue | |
| # Sort by score, then limit to max 2 chunks per document to ensure diversity | |
| sorted_results = sorted(best.values(), key=lambda x: -x["score"]) | |
| seen_docs: Dict[str, int] = {} | |
| diverse = [] | |
| for r in sorted_results: | |
| count = seen_docs.get(r["filename"], 0) | |
| if count < 2: | |
| diverse.append(r) | |
| seen_docs[r["filename"]] = count + 1 | |
| if len(diverse) >= n: | |
| break | |
| return diverse | |
| def fmt_chunk(c: Dict) -> str: | |
| return ( | |
| f"Doc #{c['doc_num']} | {c['category']} | score={c['score']} | " | |
| f"page~{c['approx_page']} | {c['title']}\n" | |
| f"PASSAGE: {c['chunk_text']}" | |
| ) | |
| def fmt_chunk_musk(c: Dict) -> str: | |
| return ( | |
| f"Doc #{c['doc_num']} | score={c['score']} | page~{c['approx_page']} | " | |
| f"{c.get('summary', '')[:120]}\n" | |
| f"PASSAGE: {c['chunk_text']}" | |
| ) | |
| # ββ FastAPI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="JUUL PageIndex RAG") | |
| _index: Optional[PageIndex] = None | |
| _client: Optional[OpenAI] = None | |
| _qdrant: Optional[QdrantClient] = None | |
| def startup(): | |
| global _index, _client, _qdrant | |
| SESSIONS_DIR.mkdir(parents=True, exist_ok=True) | |
| _index = PageIndex() | |
| _client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) | |
| _qdrant = QdrantClient( | |
| host=os.environ["QDRANT_VECTORDB_ENDPOINT"], | |
| api_key=os.environ["QDRANT_VECTORDB_APIKEY"], | |
| https=True, | |
| timeout=30, | |
| ) | |
| print(f"PageIndex loaded: {len(_index.entries)} documents") | |
| print(f"Qdrant connected: {QDRANT_COLLECTION}") | |
| print(f"Sessions dir: {SESSIONS_DIR}") | |
| class ChatRequest(BaseModel): | |
| message: str | |
| session_id: Optional[str] = None # omit to start a new session | |
| class ChatResponse(BaseModel): | |
| session_id: str | |
| response: str | |
| sources: List[Dict] = [] | |
| def chat(req: ChatRequest): | |
| session_id = req.session_id or str(uuid.uuid4()) | |
| session = load_session(session_id) | |
| log.info("ββ NEW REQUEST ββ session=%s q=%r", session_id[:8], req.message[:120]) | |
| now = datetime.utcnow().strftime("Current date and time (UTC): %A, %B %d, %Y %H:%M") | |
| # System message is STATIC (no datetime) so OpenAI prompt cache always hits. | |
| messages = [{"role": "system", "content": f"{SYSTEM}\n\n--- AUTHORITATIVE CASE SUMMARY ---\n{JUUL_MASTER_CONTEXT}\n--- END CASE SUMMARY ---"}] | |
| messages.extend(session["messages"]) | |
| # Datetime goes in the user turn so the system prefix stays cacheable. | |
| messages.append({"role": "user", "content": f"[{now}]\n\n{req.message}"}) | |
| had_any_results = False | |
| all_sources = [] | |
| retrieved_texts = [] # collect all chunk text for fallback | |
| # ββ Pre-search: BM25 + Vector βββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| expanded = expand_queries(_client, req.message) | |
| log.info("Query expansion β %s", expanded) | |
| bm25_results = _index.multi_search(expanded, n=15) | |
| log.info("BM25: %d results", len(bm25_results)) | |
| if bm25_results: | |
| had_any_results = True | |
| for e in bm25_results: | |
| s = {"doc_num": e["doc_num"], "filename": e["filename"], | |
| "category": e["category"], "title": e["title"][:200]} | |
| if s not in all_sources: | |
| all_sources.append(s) | |
| messages.append({"role": "system", "content": | |
| f"BM25 keyword search results (queries: {expanded}):\n" | |
| + "\n".join(fmt(e) for e in bm25_results) | |
| }) | |
| except Exception as e: | |
| log.warning("BM25 pre-search failed: %s", e) | |
| try: | |
| vec_results = qdrant_multi_search(_client, _qdrant, expanded, n=15) | |
| log.info("Vector search: %d chunks from %d unique docs top_score=%.3f", | |
| len(vec_results), | |
| len({r["filename"] for r in vec_results}), | |
| vec_results[0]["score"] if vec_results else 0) | |
| if vec_results: | |
| had_any_results = True | |
| for c in vec_results: | |
| s = {"doc_num": c["doc_num"], "filename": c["filename"], | |
| "category": c["category"], "title": c["title"]} | |
| if s not in all_sources: | |
| all_sources.append(s) | |
| retrieved_texts.append(fmt_chunk(c)) | |
| messages.append({"role": "system", "content": | |
| "Vector semantic search β most relevant passages:\n" | |
| + "\n\n".join(fmt_chunk(c) for c in vec_results) | |
| }) | |
| except Exception as e: | |
| log.warning("Vector pre-search failed: %s", e) | |
| # ββ Agentic loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| loop_iter = 0 | |
| while True: | |
| loop_iter += 1 | |
| log.info("Agentic loop iteration %d", loop_iter) | |
| resp = _client.chat.completions.create( | |
| model=MODEL, | |
| messages=messages, | |
| tools=TOOLS, | |
| tool_choice="auto", | |
| ) | |
| msg = resp.choices[0].message | |
| messages.append(msg) | |
| if resp.choices[0].finish_reason == "tool_calls" and msg.tool_calls: | |
| for tc in msg.tool_calls: | |
| args = json.loads(tc.function.arguments) | |
| log.info("Tool call: %s(%s)", tc.function.name, json.dumps(args)[:100]) | |
| if tc.function.name == "vector_search": | |
| chunks = qdrant_search(_client, _qdrant, args["query"], args.get("n", 8)) | |
| log.info(" vector_search β %d chunks", len(chunks)) | |
| if chunks: | |
| had_any_results = True | |
| for c in chunks: | |
| s = {"doc_num": c["doc_num"], "filename": c["filename"], | |
| "category": c["category"], "title": c["title"]} | |
| if s not in all_sources: | |
| all_sources.append(s) | |
| retrieved_texts.append(fmt_chunk(c)) | |
| result = ( | |
| f"Vector search results for '{args['query']}':\n\n" | |
| + "\n\n".join(fmt_chunk(c) for c in chunks) | |
| ) | |
| else: | |
| result = f"No vector results for: '{args['query']}'" | |
| else: | |
| result, has_results, sources = run_tool(_index, tc.function.name, args) | |
| log.info(" %s β has_results=%s len=%d", | |
| tc.function.name, has_results, len(result)) | |
| if has_results: | |
| had_any_results = True | |
| for s in sources: | |
| if s not in all_sources: | |
| all_sources.append(s) | |
| messages.append({ | |
| "role": "tool", | |
| "tool_call_id": tc.id, | |
| "content": result, | |
| }) | |
| continue | |
| answer = msg.content or "" | |
| rag_failed = "NO_DOCUMENTS_FOUND" in answer or not had_any_results or is_hedging_answer(answer) | |
| log.info("Model answer (first 200 chars): %r", answer[:200]) | |
| log.info("RAG result: %s had_results=%s hedging=%s", | |
| "FAILED" if rag_failed else "OK", | |
| had_any_results, | |
| is_hedging_answer(answer)) | |
| if rag_failed: | |
| log.info("Trying combined fallback (chunks=%d + master context)...", len(retrieved_texts)) | |
| combined = "\n\n".join(retrieved_texts) if retrieved_texts else None | |
| fallback = try_master_context_fallback(_client, req.message, combined) | |
| if fallback: | |
| log.info("Fallback answered successfully") | |
| session["messages"].append({"role": "user", "content": req.message}) | |
| session["messages"].append({"role": "assistant", "content": fallback}) | |
| save_session(session) | |
| return ChatResponse(session_id=session_id, response=fallback, sources=[]) | |
| log.info("Fallback also hedged β forcing best-guess answer from master context...") | |
| forced = _client.chat.completions.create( | |
| model=MODEL, | |
| messages=[ | |
| {"role": "system", "content": ( | |
| "You are a legal assistant for the JUUL MDL case. " | |
| "Always give a short, confident, natural reply β 1-2 sentences. " | |
| "Never say you don't know. Never mention documents, searches, or sources. " | |
| "Just sound like a knowledgeable colleague giving a quick answer.\n\n" | |
| + JUUL_MASTER_CONTEXT | |
| )}, | |
| {"role": "user", "content": req.message}, | |
| ], | |
| ) | |
| answer = forced.choices[0].message.content or "That specific detail hasn't surfaced in the case record." | |
| log.info("Forced answer: %r", answer[:200]) | |
| session["messages"].append({"role": "user", "content": req.message}) | |
| session["messages"].append({"role": "assistant", "content": answer}) | |
| save_session(session) | |
| return ChatResponse(session_id=session_id, response=answer, sources=[]) | |
| log.info("Success β returning answer with %d sources", len(all_sources)) | |
| session["messages"].append({"role": "user", "content": req.message}) | |
| session["messages"].append({"role": "assistant", "content": answer}) | |
| save_session(session) | |
| return ChatResponse( | |
| session_id=session_id, | |
| response=answer, | |
| sources=all_sources[:10], | |
| ) | |
| def chat_stream(req: ChatRequest): | |
| session_id = req.session_id or str(uuid.uuid4()) | |
| session = load_session(session_id) | |
| log.info("ββ STREAM REQUEST ββ session=%s q=%r", session_id[:8], req.message[:120]) | |
| now = datetime.utcnow().strftime("Current date and time (UTC): %A, %B %d, %Y %H:%M") | |
| messages = [{"role": "system", "content": f"{SYSTEM}\n\n--- AUTHORITATIVE CASE SUMMARY ---\n{JUUL_MASTER_CONTEXT}\n--- END CASE SUMMARY ---"}] | |
| messages.extend(session["messages"]) | |
| messages.append({"role": "user", "content": f"[{now}]\n\n{req.message}"}) | |
| had_any_results = False | |
| all_sources = [] | |
| retrieved_texts = [] | |
| # ββ Pre-search: BM25 + Vector (non-streaming, done before we start) βββββββ | |
| try: | |
| expanded = expand_queries(_client, req.message) | |
| bm25_results = _index.multi_search(expanded, n=15) | |
| if bm25_results: | |
| had_any_results = True | |
| for e in bm25_results: | |
| s = {"doc_num": e["doc_num"], "filename": e["filename"], | |
| "category": e["category"], "title": e["title"][:200]} | |
| if s not in all_sources: | |
| all_sources.append(s) | |
| messages.append({"role": "system", "content": | |
| f"BM25 keyword search results (queries: {expanded}):\n" | |
| + "\n".join(fmt(e) for e in bm25_results) | |
| }) | |
| except Exception as e: | |
| log.warning("BM25 pre-search failed: %s", e) | |
| try: | |
| vec_results = qdrant_multi_search(_client, _qdrant, expanded, n=15) | |
| if vec_results: | |
| had_any_results = True | |
| for c in vec_results: | |
| s = {"doc_num": c["doc_num"], "filename": c["filename"], | |
| "category": c["category"], "title": c["title"]} | |
| if s not in all_sources: | |
| all_sources.append(s) | |
| retrieved_texts.append(fmt_chunk(c)) | |
| messages.append({"role": "system", "content": | |
| "Vector semantic search β most relevant passages:\n" | |
| + "\n\n".join(fmt_chunk(c) for c in vec_results) | |
| }) | |
| except Exception as e: | |
| log.warning("Vector pre-search failed: %s", e) | |
| # ββ Agentic loop (tool calls run non-streaming; final answer streams) βββββ | |
| def generate(): | |
| nonlocal had_any_results, messages | |
| loop_iter = 0 | |
| while True: | |
| loop_iter += 1 | |
| # Check if next response will need tools β run non-streaming first | |
| resp_check = _client.chat.completions.create( | |
| model=MODEL, | |
| messages=messages, | |
| tools=TOOLS, | |
| tool_choice="auto", | |
| ) | |
| msg = resp_check.choices[0].message | |
| messages.append(msg) | |
| if resp_check.choices[0].finish_reason == "tool_calls" and msg.tool_calls: | |
| for tc in msg.tool_calls: | |
| args = json.loads(tc.function.arguments) | |
| if tc.function.name == "vector_search": | |
| chunks = qdrant_search(_client, _qdrant, args["query"], args.get("n", 8)) | |
| if chunks: | |
| had_any_results = True | |
| for c in chunks: | |
| retrieved_texts.append(fmt_chunk(c)) | |
| result = ( | |
| f"Vector search results for '{args['query']}':\n\n" | |
| + "\n\n".join(fmt_chunk(c) for c in chunks) | |
| ) | |
| else: | |
| result = f"No vector results for: '{args['query']}'" | |
| else: | |
| result, has_results, sources = run_tool(_index, tc.function.name, args) | |
| if has_results: | |
| had_any_results = True | |
| messages.append({ | |
| "role": "tool", | |
| "tool_call_id": tc.id, | |
| "content": result, | |
| }) | |
| continue | |
| # Model gave a text answer β check if it's a failure | |
| answer_check = msg.content or "" | |
| rag_failed = ( | |
| "NO_DOCUMENTS_FOUND" in answer_check | |
| or not had_any_results | |
| or is_hedging_answer(answer_check) | |
| ) | |
| if rag_failed: | |
| # Fallback: stream from master context + chunks | |
| combined = "\n\n".join(retrieved_texts) if retrieved_texts else None | |
| combined_context = JUUL_MASTER_CONTEXT | |
| if combined: | |
| combined_context = ( | |
| "## RETRIEVED DOCUMENT PASSAGES\n\n" + combined | |
| + "\n\n---\n\n## AUTHORITATIVE CASE SUMMARY\n\n" + JUUL_MASTER_CONTEXT | |
| ) | |
| fallback_system = """You are a legal expert on the JUUL Labs MDL case. Answer short and direct. | |
| RULES: Never mention documents/context/searches. If unsure, make a confident guess. Never refuse. | |
| {context}""" | |
| stream = _client.chat.completions.create( | |
| model=MODEL, | |
| stream=True, | |
| messages=[ | |
| {"role": "system", "content": fallback_system.format(context=combined_context)}, | |
| {"role": "user", "content": f"[{now}]\n\n{req.message}"}, | |
| ], | |
| ) | |
| else: | |
| # Re-run the last step as a stream so tokens flow immediately | |
| messages.pop() # remove the non-streamed assistant message | |
| stream = _client.chat.completions.create( | |
| model=MODEL, | |
| stream=True, | |
| messages=messages, | |
| tools=TOOLS, | |
| tool_choice="none", # force text answer, no more tools | |
| ) | |
| # Stream tokens to client as SSE | |
| full_answer = [] | |
| yield f"data: {json.dumps({'session_id': session_id})}\n\n" | |
| for chunk in stream: | |
| delta = chunk.choices[0].delta.content if chunk.choices else None | |
| if delta: | |
| full_answer.append(delta) | |
| yield f"data: {json.dumps({'delta': delta})}\n\n" | |
| yield "data: [DONE]\n\n" | |
| # Persist session | |
| final = "".join(full_answer) | |
| session["messages"].append({"role": "user", "content": req.message}) | |
| session["messages"].append({"role": "assistant", "content": final}) | |
| save_session(session) | |
| return | |
| return StreamingResponse(generate(), media_type="text/event-stream") | |
| class HistoryMessage(BaseModel): | |
| role: str # "user" | "assistant" | |
| content: str | |
| class ChatAgentRequest(BaseModel): | |
| message: str | |
| agent: str = "juul" # "juul" | "musk_altman" | |
| history: List[HistoryMessage] = [] # prior turns, oldest first | |
| class ChatAgentResponse(BaseModel): | |
| response: str | |
| sources: List[Dict] = [] | |
| def chat_agent(req: ChatAgentRequest): | |
| """ | |
| Unified JSON chat endpoint. Route via agent field: | |
| agent="juul" β JUUL MDL (juul_mdl_chunks, BM25 + vector) | |
| agent="musk_altman" β Musk v. Altman (musk_altman_chunks, vector-only) | |
| History is passed inline β no session management needed. | |
| """ | |
| now = datetime.utcnow().strftime("Current date and time (UTC): %A, %B %d, %Y %H:%M") | |
| if req.agent == "juul": | |
| system_msg = f"{SYSTEM}\n\n--- AUTHORITATIVE CASE SUMMARY ---\n{JUUL_MASTER_CONTEXT}\n--- END CASE SUMMARY ---" | |
| tools = TOOLS | |
| collection = QDRANT_COLLECTION | |
| master_ctx = JUUL_MASTER_CONTEXT | |
| use_bm25 = True | |
| fmt_fn = fmt_chunk | |
| base_dir = DOWNLOADS_DIR | |
| log_tag = "JUUL" | |
| else: | |
| system_msg = f"{MUSK_SYSTEM}\n\n--- AUTHORITATIVE CASE SUMMARY ---\n{MUSK_MASTER_CONTEXT}\n--- END CASE SUMMARY ---" | |
| tools = MUSK_TOOLS | |
| collection = MUSK_COLLECTION | |
| master_ctx = MUSK_MASTER_CONTEXT | |
| use_bm25 = False | |
| fmt_fn = fmt_chunk_musk | |
| base_dir = MUSK_DOWNLOADS | |
| log_tag = "MUSK" | |
| log.info("ββ %s AGENT ββ q=%r history=%d turns", log_tag, req.message[:120], len(req.history)) | |
| # Build message list: system + history + current user turn | |
| messages: List[Dict] = [{"role": "system", "content": system_msg}] | |
| for h in req.history: | |
| messages.append({"role": h.role, "content": h.content}) | |
| messages.append({"role": "user", "content": f"[{now}]\n\n{req.message}"}) | |
| had_any_results = False | |
| all_sources: List[Dict] = [] | |
| retrieved_texts: List[str] = [] | |
| # ββ Pre-search ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| expanded = expand_queries(_client, req.message) if use_bm25 else [req.message] | |
| if use_bm25: | |
| bm25_results = _index.multi_search(expanded, n=15) | |
| if bm25_results: | |
| had_any_results = True | |
| for e in bm25_results: | |
| s = {"doc_num": e["doc_num"], "filename": e["filename"], | |
| "category": e["category"], "title": e["title"][:200]} | |
| if s not in all_sources: | |
| all_sources.append(s) | |
| messages.append({"role": "system", "content": | |
| f"BM25 keyword search results (queries: {expanded}):\n" | |
| + "\n".join(fmt(e) for e in bm25_results) | |
| }) | |
| except Exception as e: | |
| log.warning("Pre-search BM25 failed: %s", e) | |
| try: | |
| vec_results = qdrant_multi_search(_client, _qdrant, [req.message], n=15, | |
| collection=collection) if not use_bm25 \ | |
| else qdrant_multi_search(_client, _qdrant, expanded, n=15) | |
| if vec_results: | |
| had_any_results = True | |
| for c in vec_results: | |
| s = {"doc_num": c["doc_num"], "filename": c["filename"], | |
| "title": c.get("title", c.get("summary", ""))[:200]} | |
| if s not in all_sources: | |
| all_sources.append(s) | |
| retrieved_texts.append(fmt_fn(c)) | |
| messages.append({"role": "system", "content": | |
| "Vector semantic search β most relevant passages:\n\n" | |
| + "\n\n".join(retrieved_texts) | |
| }) | |
| except Exception as e: | |
| log.warning("Pre-search vector failed: %s", e) | |
| # ββ Agentic loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| while True: | |
| resp = _client.chat.completions.create( | |
| model=MODEL, messages=messages, tools=tools, tool_choice="auto", | |
| ) | |
| msg = resp.choices[0].message | |
| messages.append(msg) | |
| if resp.choices[0].finish_reason == "tool_calls" and msg.tool_calls: | |
| for tc in msg.tool_calls: | |
| args = json.loads(tc.function.arguments) | |
| log.info("Tool: %s(%s)", tc.function.name, json.dumps(args)[:80]) | |
| if tc.function.name == "vector_search": | |
| chunks = qdrant_search(_client, _qdrant, args["query"], | |
| args.get("n", 8), collection=collection) | |
| if chunks: | |
| had_any_results = True | |
| for c in chunks: | |
| retrieved_texts.append(fmt_fn(c)) | |
| result = ("Vector search results for '{}':\n\n".format(args["query"]) | |
| + "\n\n".join(fmt_fn(c) for c in chunks)) | |
| else: | |
| result = f"No vector results for: '{args['query']}'" | |
| elif tc.function.name == "read_doc": | |
| result = read_pdf(args["filename"], args.get("pages"), base_dir=base_dir) | |
| else: | |
| result, has_results, sources = run_tool(_index, tc.function.name, args) | |
| if has_results: | |
| had_any_results = True | |
| for s in sources: | |
| if s not in all_sources: | |
| all_sources.append(s) | |
| messages.append({"role": "tool", "tool_call_id": tc.id, "content": result}) | |
| continue | |
| answer = msg.content or "" | |
| rag_failed = "NO_DOCUMENTS_FOUND" in answer or not had_any_results or is_hedging_answer(answer) | |
| if rag_failed: | |
| combined = "\n\n".join(retrieved_texts) if retrieved_texts else None | |
| combined_context = master_ctx | |
| if combined: | |
| combined_context = ( | |
| "## RETRIEVED DOCUMENT PASSAGES\n\n" + combined | |
| + "\n\n---\n\n## AUTHORITATIVE CASE SUMMARY\n\n" + master_ctx | |
| ) | |
| fallback_system = ( | |
| f"You are a legal expert. Answer short and direct.\n" | |
| "RULES: Never mention documents/context/searches. If unsure, make a confident guess. Never refuse.\n" | |
| "{context}" | |
| ) | |
| fb_resp = _client.chat.completions.create( | |
| model=MODEL, | |
| messages=[ | |
| {"role": "system", "content": fallback_system.format(context=combined_context)}, | |
| {"role": "user", "content": f"[{now}]\n\n{req.message}"}, | |
| ], | |
| ) | |
| answer = fb_resp.choices[0].message.content or "That detail hasn't surfaced in the case record." | |
| log.info("%s answer (first 150): %r", log_tag, answer[:150]) | |
| return ChatAgentResponse(response=answer, sources=all_sources[:10]) | |
| def delete_session(session_id: str): | |
| path = _session_path(session_id) | |
| if not path.exists(): | |
| raise HTTPException(status_code=404, detail="Session not found.") | |
| path.unlink() | |
| return {"deleted": session_id} | |
| def get_session(session_id: str): | |
| path = _session_path(session_id) | |
| if not path.exists(): | |
| raise HTTPException(status_code=404, detail="Session not found.") | |
| return load_session(session_id) | |