1 line
19 KiB
JSON
1 line
19 KiB
JSON
{"repo": "alaarafiek/rag", "files": {"app.py": {"224db809e820516f2b80b305aa7d22485fe18fd4": {"t": 1785674393.8306022, "n": 15726, "h": "623aed10d52c243f", "c": "\"\"\"\nRAG Backend — Flask + Groq (Llama 3.3 70B)\n==========================================\nEndpoints:\n POST /documents — add a document (JSON or file upload)\n GET /documents — list all documents\n DELETE /documents/<id> — remove a document\n POST /query — run the full RAG pipeline (retrieve + generate)\n POST /transcribe — transcribe audio via Groq Whisper\n GET /health — health check\n\"\"\"\n\nimport os\nimport math\nimport re\nimport uuid\nimport logging\nfrom collections import Counter\nfrom typing import Optional\n\nfrom flask import Flask, request, jsonify\nfrom flask_cors import CORS\nimport requests\n\n# ── Logging ──────────────────────────────────────────────────────────────────\nlogging.basicConfig(\n level=logging.INFO,\n format=\"%(asctime)s %(levelname)-8s %(message)s\",\n datefmt=\"%H:%M:%S\",\n)\nlog = logging.getLogger(__name__)\n\n# ── App ───────────────────────────────────────────────────────────────────────\napp = Flask(__name__)\nCORS(app) # Allow requests from any frontend origin\n\n# ── Config ────────────────────────────────────────────────────────────────────\nGROQ_CHAT_URL = \"https://api.groq.com/openai/v1/chat/completions\"\nGROQ_WHISPER_URL = \"https://api.groq.com/openai/v1/audio/transcriptions\"\nGROQ_CHAT_MODEL = \"llama-3.3-70b-versatile\"\nGROQ_WHISPER_MODEL = \"whisper-large-v3\"\nTOP_K = 3 # number of chunks to retrieve per query\nMAX_TOKENS = 1024 # max tokens for LLM response\n\n# ── In-memory knowledge base ──────────────────────────────────────────────────\n# Each document: { id, title, content }\nknowledge_base: list[dict] = []\n\n# Seed with default documents so the system works out of the box\n_DEFAULT_DOCS = [\n {\n \"title\": \"RAG Overview\",\n \"content\": (\n \"Retrieval-Augmented Generation (RAG) is an AI framework that enhances large language \"\n \"models by retrieving relevant information from external knowledge sources before \"\n \"generating responses. RAG was introduced by Facebook AI Research in 2020. The pipeline \"\n \"consists of: (1) an indexing phase where documents are chunked and stored; (2) a \"\n \"retrieval phase where a query finds top-k relevant chunks using similarity search; \"\n \"(3) an augmentation phase where retrieved context is prepended to the prompt; \"\n \"(4) a generation phase where the LLM produces an answer grounded in retrieved context. \"\n \"RAG reduces hallucination by grounding responses in factual sources.\"\n ),\n },\n {\n \"title\": \"Vector Embeddings\",\n \"content\": (\n \"Vector embeddings are numerical representations of text in high-dimensional space. \"\n \"Similar texts have vectors that are geometrically close together. Embedding models \"\n \"like text-embedding-ada-002, BGE, and E5 convert text chunks into dense vectors. \"\n \"Cosine similarity is the most common metric for measuring similarity between two \"\n \"vectors. Semantic search using embeddings allows retrieval of contextually relevant \"\n \"documents even without exact keyword matches. Common vector databases include \"\n \"Pinecone, Weaviate, Qdrant, Chroma, and FAISS.\"\n ),\n },\n {\n \"title\": \"Chunking Strategies\",\n \"content\": (\n \"Chunking is the process of splitting documents into smaller pieces for indexing. \"\n \"Fixed-size chunking splits text every N tokens with optional overlap to preserve \"\n \"context. Semantic chunking splits based on sentence boundaries or topic shifts. \"\n \"Hierarchical chunking creates parent-child relationships between sections and smaller \"\n \"chunks. Too small chunks lose context; too large chunks introduce noise. The right \"\n \"chunk size depends on the embedding model and content type.\"\n ),\n },\n {\n \"title\": \"LLM Hallucination\",\n \"content\": (\n \"Hallucination in large language models refers to the generation of plausible-sounding \"\n \"but factually incorrect information. LLMs hallucinate because they generate tokens \"\n \"based on statistical patterns rather than factual memory. RAG mitigates hallucination \"\n \"by injecting verified source material into the prompt context, constraining the model \"\n \"to answer from that context. Techniques to reduce hallucination include citing sources, \"\n \"confidence thresholds, and self-consistency checking across multiple answers.\"\n ),\n },\n]\n\nfor _doc in _DEFAULT_DOCS:\n knowledge_base.append({\"id\": str(uuid.uuid4()), **_doc})\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# RETRIEVAL — TF-IDF cosine similarity (no external dependencies)\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _tokenize(text: str) -> list[str]:\n \"\"\"Lowercase, strip punctuation, split on whitespace.\"\"\"\n return re.sub(r\"[^a-z0-9\\s]\", \"\", text.lower()).split()\n\n\ndef _tf(tokens: list[str]) -> dict[str, float]:\n \"\"\"Term-frequency vector (normalised by document length).\"\"\"\n if not tokens:\n return {}\n counts = Counter(tokens)\n length = len(tokens)\n return {term: count / length for term, count in counts.items()}\n\n\ndef _cosine_similarity(tf_a: dict, tf_b: dict) -> float:\n \"\"\"Cosine similarity between two TF dicts.\"\"\"\n all_terms = set(tf_a) | set(tf_b)\n dot = sum(tf_a.get(t, 0.0) * tf_b.get(t, 0.0) for t in all_terms)\n mag_a = math.sqrt(sum(v ** 2 for v in tf_a.values()))\n mag_b = math.sqrt(sum(v ** 2 for v in tf_b.values()))\n if mag_a == 0 or mag_b == 0:\n return 0.0\n return dot / (mag_a * mag_b)\n\n\ndef retrieve_top_k(query: str, k: int = TOP_K) -> list[dict]:\n \"\"\"\n Return the top-k most relevant documents for a given query.\n Each result dict: { doc, score }\n \"\"\"\n if not knowledge_base:\n return []\n q_tf = _tf(_tokenize(query))\n scored = [\n {\"doc\": doc, \"score\": _cosine_similarity(q_tf, _tf(_tokenize(doc[\"content\"])))}\n for doc in knowledge_base\n ]\n scored.sort(key=lambda x: x[\"score\"], reverse=True)\n return [r for r in scored[:k] if r[\"score\"] > 0]\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# GROQ HELPERS\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _groq_headers(api_key: str) -> dict:\n return {\"Authorization\": f\"Bearer {api_key}\", \"Content-Type\": \"application/json\"}\n\n\ndef _call_groq_chat(api_key: str, system_prompt: str, user_message: str) -> str:\n \"\"\"Call Groq chat completion and return the assistant text.\"\"\"\n payload = {\n \"model\": GROQ_CHAT_MODEL,\n \"max_tokens\": MAX_TOKENS,\n \"messages\": [\n {\"role\": \"system\", \"content\": system_prompt},\n {\"role\": \"user\", \"content\": user_message},\n ],\n }\n resp = requests.post(GROQ_CHAT_URL, json=payload, headers=_groq_headers(api_key), timeout=30)\n resp.raise_for_status()\n return resp.json()[\"choices\"][0][\"message\"][\"content\"]\n\n\ndef _call_groq_whisper(api_key: str, audio_bytes: bytes, filename: str, language: str = \"en\") -> str:\n \"\"\"Transcribe audio via Groq Whisper. Returns transcript text.\"\"\"\n files = {\"file\": (filename, audio_bytes, \"audio/webm\")}\n data = {\"model\": GROQ_WHISPER_MODEL, \"language\": language, \"response_format\": \"text\"}\n headers = {\"Authorization\": f\"Bearer {api_key}\"}\n resp = requests.post(GROQ_WHISPER_URL, files=files, data=data, headers=headers, timeout=60)\n resp.raise_for_status()\n # response_format=text returns a plain string\n return resp.text.strip()\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# ROUTES\n# ─────────────────────────────────────────────────────────────────────────────\n\n@app.route(\"/health\", methods=[\"GET\"])\ndef health():\n \"\"\"Simple health check.\"\"\"\n return jsonify({\"status\": \"ok\", \"documents\": len(knowledge_base)})\n\n\n# ── Knowledge Base ────────────────────────────────────────────────────────────\n\n@app.route(\"/documents\", methods=[\"GET\"])\ndef list_documents():\n \"\"\"Return all documents in the knowledge base (without full content for brevity).\"\"\"\n docs = [\n {\"id\": d[\"id\"], \"title\": d[\"title\"], \"chars\": len(d[\"content\"])}\n for d in knowledge_base\n ]\n return jsonify({\"documents\": docs, \"total\": len(docs)})\n\n\n@app.route(\"/documents\", methods=[\"POST\"])\ndef add_document():\n \"\"\"\n Add a document to the knowledge base.\n\n Accepts two modes:\n 1. JSON body: { \"title\": \"...\", \"content\": \"...\" }\n 2. File upload: multipart/form-data with field 'file'\n Optional form field 'title' overrides the filename.\n \"\"\"\n title: Optional[str] = None\n content: Optional[str] = None\n\n # ── file upload mode ──────────────────────────────────────\n if request.files.get(\"file\"):\n f = request.files[\"file\"]\n try:\n content = f.read().decode(\"utf-8\", errors=\"replace\")\n except Exception as exc:\n return jsonify({\"error\": f\"Could not read file: {exc}\"}), 400\n title = request.form.get(\"title\") or f.filename.rsplit(\".\", 1)[0]\n\n # ── JSON mode ─────────────────────────────────────────────\n else:\n body = request.get_json(silent=True) or {}\n content = body.get(\"content\", \"\").strip()\n title = body.get(\"title\", \"\").strip()\n\n if not content:\n return jsonify({\"error\": \"No content provided.\"}), 400\n\n # Auto-generate title from first line if missing\n if not title:\n title = content.split(\"\\n\")[0][:60] or f\"Document {len(knowledge_base) + 1}\"\n\n doc = {\"id\": str(uuid.uuid4()), \"title\": title.strip(), \"content\": content}\n knowledge_base.append(doc)\n log.info(\"Document added: '%s' (%d chars)\", doc[\"title\"], len(doc[\"content\"]))\n return jsonify({\"id\": doc[\"id\"], \"title\": doc[\"title\"], \"chars\": len(doc[\"content\"])}), 201\n\n\n@app.route(\"/documents/<doc_id>\", methods=[\"DELETE\"])\ndef delete_document(doc_id: str):\n \"\"\"Remove a document by ID.\"\"\"\n global knowledge_base\n before = len(knowledge_base)\n knowledge_base = [d for d in knowledge_base if d[\"id\"] != doc_id]\n if len(knowledge_base) == before:\n return jsonify({\"error\": \"Document not found.\"}), 404\n log.info(\"Document deleted: %s\", doc_id)\n return jsonify({\"deleted\": doc_id})\n\n\n# ── RAG Query ─────────────────────────────────────────────────────────────────\n\n@app.route(\"/query\", methods=[\"POST\"])\ndef query():\n \"\"\"\n Full RAG pipeline:\n 1. Retrieve top-k relevant chunks\n 2. Build augmented system prompt with retrieved context\n 3. Call Groq LLM and return the answer\n\n Request JSON:\n {\n \"query\": \"What is RAG?\",\n \"api_key\": \"gsk_...\",\n \"top_k\": 3 (optional, default 3)\n }\n\n Response JSON:\n {\n \"answer\": \"...\",\n \"sources\": [\"RAG Overview\", ...],\n \"scores\": [0.42, 0.31, ...],\n \"retrieved_count\": 2\n }\n \"\"\"\n body = request.get_json(silent=True) or {}\n query_text = body.get(\"query\", \"\").strip()\n api_key = body.get(\"api_key\", \"\").strip()\n top_k = int(body.get(\"top_k\", TOP_K))\n\n if not query_text:\n return jsonify({\"error\": \"Missing 'query' field.\"}), 400\n if not api_key.startswith(\"gsk_\"):\n return jsonify({\"error\": \"Missing or invalid 'api_key'. Must start with gsk_.\"}), 401\n if not knowledge_base:\n return jsonify({\"error\": \"Knowledge base is empty. Add documents first.\"}), 400\n\n # ── Step 1: Retrieve ──────────────────────────────────────\n results = retrieve_top_k(query_text, k=top_k)\n\n if not results:\n return jsonify({\n \"answer\": \"I could not find any relevant documents to answer your question.\",\n \"sources\": [], \"scores\": [], \"retrieved_count\": 0,\n })\n\n log.info(\"Query: '%s' — retrieved %d chunks\", query_text, len(results))\n\n # ── Step 2: Build context ─────────────────────────────────\n context_blocks = \"\\n\\n\".join(\n f'[Source {i+1}: \"{r[\"doc\"][\"title\"]}\"]\\n{r[\"doc\"][\"content\"]}'\n for i, r in enumerate(results)\n )\n system_prompt = (\n \"You are a precise RAG assistant. Answer the user's question using ONLY the context \"\n \"provided below. If the answer is not in the context, say so clearly and honestly. \"\n \"Never fabricate or invent information.\\n\\n\"\n \"=== RETRIEVED CONTEXT ===\\n\"\n f\"{context_blocks}\\n\"\n \"=== END CONTEXT ===\"\n )\n\n # ── Step 3: Generate ──────────────────────────────────────\n try:\n answer = _call_groq_chat(api_key, system_prompt, query_text)\n except requests.HTTPError as exc:\n status = exc.response.status_code if exc.response else 500\n detail = exc.response.text if exc.response else str(exc)\n log.error(\"Groq API error %d: %s\", status, detail)\n return jsonify({\"error\": f\"Groq API error {status}: {detail}\"}), status\n except requests.RequestException as exc:\n log.error(\"Network error calling Groq: %s\", exc)\n return jsonify({\"error\": f\"Network error: {exc}\"}), 502\n\n return jsonify({\n \"answer\": answer,\n \"sources\": [r[\"doc\"][\"title\"] for r in results],\n \"scores\": [round(r[\"score\"], 4) for r in results],\n \"retrieved_count\": len(results),\n })\n\n\n# ── Audio Transcription ───────────────────────────────────────────────────────\n\n@app.route(\"/transcribe\", methods=[\"POST\"])\ndef transcribe():\n \"\"\"\n Transcribe audio using Groq Whisper.\n\n Accepts multipart/form-data:\n - file: audio file (.webm, .mp3, .wav, .m4a, .ogg, .flac)\n - api_key: Groq API key (gsk_...)\n - language: ISO-639-1 language code (optional, default 'en')\n\n Response JSON:\n { \"transcript\": \"What is retrieval augmented generation?\" }\n \"\"\"\n api_key = request.form.get(\"api_key\", \"\").strip()\n language = request.form.get(\"language\", \"en\").strip()\n audio_f = request.files.get(\"file\")\n\n if not api_key.startswith(\"gsk_\"):\n return jsonify({\"error\": \"Missing or invalid 'api_key'.\"}), 401\n if not audio_f:\n return jsonify({\"error\": \"No audio file provided. Send as multipart field 'file'.\"}), 400\n\n audio_bytes = audio_f.read()\n filename = audio_f.filename or \"audio.webm\"\n\n log.info(\"Transcribing audio: %s (%d bytes)\", filename, len(audio_bytes))\n\n try:\n transcript = _call_groq_whisper(api_key, audio_bytes, filename, language)\n except requests.HTTPError as exc:\n status = exc.response.status_code if exc.response else 500\n detail = exc.response.text if exc.response else str(exc)\n log.error(\"Whisper API error %d: %s\", status, detail)\n return jsonify({\"error\": f\"Whisper API error {status}: {detail}\"}), status\n except requests.RequestException as exc:\n log.error(\"Network error calling Whisper: %s\", exc)\n return jsonify({\"error\": f\"Network error: {exc}\"}), 502\n\n log.info(\"Transcript: '%s'\", transcript[:80])\n return jsonify({\"transcript\": transcript})\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# ENTRY POINT\n# ─────────────────────────────────────────────────────────────────────────────\n\nif __name__ == \"__main__\":\n port = int(os.environ.get(\"PORT\", 5000))\n log.info(\"Starting RAG backend on http://localhost:%d\", port)\n log.info(\"Loaded %d default documents into knowledge base.\", len(knowledge_base))\n app.run(host=\"0.0.0.0\", port=port, debug=True)\n"}}}} |