| """Milestone 5 — Grounded generation + Gradio interface. |
| |
| Final stage of the pipeline (see planning.md architecture diagram): |
| |
| Ingestion -> Chunking -> Embedding + Vector Store -> Retrieval -> [Generation -> UI] |
| ^^^^^^^^^^^^^^^^^^ |
| this module |
| |
| Flow per question: |
| 1. retrieve() pulls the top-k chunks from ChromaDB (Milestone 4). |
| 2. Those chunks — and only those — are passed to the LLM as context. |
| 3. A grounding system prompt forces the model to answer ONLY from that |
| context and to say so when the answer isn't present. |
| 4. The response is returned as: an answer, followed by a Sources list of |
| the documents the answer was drawn from. |
| |
| Run the UI: python src/app.py |
| Ask in code: from app import answer_question; answer_question("...") |
| |
| Requires GROQ_API_KEY in .env (see .env.example). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
|
|
| import gradio as gr |
| from dotenv import load_dotenv |
| from groq import Groq |
|
|
| from vector_store import TOP_K, retrieve |
|
|
| load_dotenv() |
|
|
| |
| GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile") |
|
|
| |
|
|
| SYSTEM_PROMPT = """\ |
| You are "The Unofficial Guide" to Brooklyn College's Computer Science program. |
| You answer questions about CS courses and professors using ONLY the numbered |
| context passages provided with each question. |
| |
| Rules: |
| - Use only facts found in the context. Do not use outside knowledge. |
| - If the context does not contain the answer, reply exactly: |
| "I don't have enough information in my sources to answer that." |
| - Do not guess, infer beyond the text, or invent ratings, names, or numbers. |
| - When you use a fact, attribute it to its source in-line, e.g. (Sokol.txt). |
| - Be concise and answer the question directly.""" |
|
|
| NO_ANSWER = "I don't have enough information in my sources to answer that." |
|
|
|
|
| def format_context(hits: list[dict]) -> str: |
| """Render retrieved chunks as a numbered, source-labeled context block.""" |
| blocks = [] |
| for i, hit in enumerate(hits, 1): |
| blocks.append(f"[{i}] (source: {hit['source']})\n{hit['text']}") |
| return "\n\n".join(blocks) |
|
|
|
|
| def build_messages(query: str, hits: list[dict]) -> list[dict]: |
| """Assemble the chat messages: grounding system prompt + context + question.""" |
| context = format_context(hits) |
| user_content = ( |
| f"Context passages:\n\n{context}\n\n" |
| f"Question: {query}\n\n" |
| "Answer using only the context above." |
| ) |
| return [ |
| {"role": "system", "content": SYSTEM_PROMPT}, |
| {"role": "user", "content": user_content}, |
| ] |
|
|
|
|
| |
|
|
| _client: Groq | None = None |
|
|
|
|
| def get_client() -> Groq: |
| """Lazily create the Groq client; fail loudly if the key is missing.""" |
| global _client |
| if _client is None: |
| if not os.getenv("GROQ_API_KEY"): |
| raise RuntimeError( |
| "GROQ_API_KEY is not set. Copy .env.example to .env and add your key." |
| ) |
| _client = Groq() |
| return _client |
|
|
|
|
| def answer_question(query: str, top_k: int = TOP_K) -> dict: |
| """Retrieve, generate a grounded answer, and collect source attribution. |
| |
| Returns {"answer": str, "sources": list[str], "hits": list[dict]}. |
| Sources are the distinct documents that were actually fed to the model, |
| in retrieval-rank order. |
| """ |
| query = (query or "").strip() |
| if not query: |
| return {"answer": "Please enter a question.", "sources": [], "hits": []} |
|
|
| hits = retrieve(query, top_k=top_k) |
| if not hits: |
| return {"answer": NO_ANSWER, "sources": [], "hits": []} |
|
|
| response = get_client().chat.completions.create( |
| model=GROQ_MODEL, |
| messages=build_messages(query, hits), |
| temperature=0, |
| ) |
| answer = response.choices[0].message.content.strip() |
|
|
| |
| sources = list(dict.fromkeys(h["source"] for h in hits)) |
| return {"answer": answer, "sources": sources, "hits": hits} |
|
|
|
|
| def format_response(result: dict) -> str: |
| """Format as: answer + a Markdown Sources list (the output format spec).""" |
| answer = result["answer"] |
| if not result["sources"] or answer == NO_ANSWER: |
| return answer |
| source_lines = "\n".join(f"- {s}" for s in result["sources"]) |
| return f"{answer}\n\n**Sources:**\n{source_lines}" |
|
|
|
|
| |
|
|
| EXAMPLE_QUESTIONS = [ |
| "What is Prof. Dina Sokol's overall rating?", |
| "What is Prof. Deborah Elefant's level of difficulty?", |
| "How does Brooklyn College's CS program compare to NYU and Columbia?", |
| ] |
|
|
|
|
| def _ui_handler(query: str) -> str: |
| """Gradio callback: question string -> formatted answer + sources.""" |
| return format_response(answer_question(query)) |
|
|
|
|
| def build_interface() -> gr.Blocks: |
| """Construct the Gradio app skeleton (no network calls at build time).""" |
| with gr.Blocks(title="The Unofficial Guide") as demo: |
| gr.Markdown( |
| "# The Unofficial College Guide — Brooklyn College CS\n" |
| "Ask about CS courses and professors. Answers come **only** from the " |
| "retrieved source documents, with attribution." |
| ) |
| with gr.Row(): |
| query_box = gr.Textbox( |
| label="Your question", |
| placeholder="e.g. What is Prof. Sokol's overall rating?", |
| lines=2, |
| scale=4, |
| ) |
| ask_btn = gr.Button("Ask", variant="primary", scale=1) |
|
|
| answer_box = gr.Markdown(label="Answer") |
|
|
| gr.Examples(examples=EXAMPLE_QUESTIONS, inputs=query_box) |
|
|
| ask_btn.click(fn=_ui_handler, inputs=query_box, outputs=answer_box) |
| query_box.submit(fn=_ui_handler, inputs=query_box, outputs=answer_box) |
|
|
| return demo |
|
|
|
|
| def main() -> None: |
| build_interface().launch(share=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|