File size: 11,004 Bytes
2604671
0b87551
38b974d
 
2604671
 
 
38b974d
 
41e79c8
 
0b87551
79e127c
41e79c8
 
8f6bb93
 
 
 
 
 
 
 
 
 
 
 
41e79c8
 
 
 
 
 
 
 
 
8f6bb93
2b63102
25ec7ce
41e79c8
 
25ec7ce
2b63102
 
41e79c8
 
 
 
 
 
 
 
 
 
 
 
 
8f6bb93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b63102
8f6bb93
 
 
 
 
 
 
 
 
79e127c
8f6bb93
 
 
 
2b63102
8f6bb93
2604671
8f6bb93
 
 
 
 
 
 
 
 
2b63102
8f6bb93
 
 
2b63102
8f6bb93
 
2b63102
8f6bb93
 
 
 
 
 
0b87551
8f6bb93
 
41e79c8
8f6bb93
2604671
0b87551
8f6bb93
 
 
 
 
 
 
 
 
 
41e79c8
8f6bb93
 
 
 
0b87551
41e79c8
8f6bb93
0b87551
 
 
 
38b974d
0b87551
8f6bb93
 
 
 
 
 
 
2b63102
8f6bb93
38b974d
41e79c8
38b974d
41e79c8
8f6bb93
41e79c8
2604671
0b87551
 
 
 
 
 
8f6bb93
2604671
41e79c8
2ca006c
2b63102
0b87551
41e79c8
 
8f6bb93
 
0b87551
8f6bb93
41e79c8
 
 
8f6bb93
 
2b63102
8f6bb93
 
 
 
 
 
 
 
 
 
 
 
2b63102
 
8f6bb93
 
41e79c8
8f6bb93
41e79c8
8f6bb93
 
41e79c8
 
 
2604671
8f6bb93
 
 
2604671
8f6bb93
 
 
41e79c8
 
8f6bb93
41e79c8
8f6bb93
 
 
 
 
 
 
 
41e79c8
8f6bb93
 
 
 
 
 
 
 
 
 
2b63102
8f6bb93
 
 
 
 
 
 
41e79c8
0b87551
 
41e79c8
 
 
2604671
 
0b87551
8f6bb93
41e79c8
 
2604671
 
0b87551
8f6bb93
41e79c8
 
0b87551
2604671
 
8f6bb93
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
import os
import uuid
import gradio as gr
import requests

os.environ["TOKENIZERS_PARALLELISM"] = "false"

FASTAPI_URL = os.getenv("FASTAPI_URL", "http://127.0.0.1:8000")
CHAT_ENDPOINT = f"{FASTAPI_URL}/chat"
UPLOAD_ENDPOINT = f"{FASTAPI_URL}/upload"
RESET_ENDPOINT = f"{FASTAPI_URL}/reset"
CLEAR_MEMORY_ENDPOINT = f"{FASTAPI_URL}/clear_memory"

SUPPORTED_TYPES = [".pdf", ".docx", ".txt", ".md", ".csv"]

CSS = """
#submit-btn { margin-top: auto; margin-bottom: auto; height: 80px; }
#input-row  { align-items: center; }
"""

SOURCE_LABELS = {
    "rag":     "Uploaded Documents (RAG)",
    "web":     "Web Search",
    "rag+web": "Documents + Web Search",
    "unknown": "Unknown",
}


def upload_files(files) -> str:
    if not files:
        return "No files selected."
    try:
        multipart = []
        for f in files:
            path = f if isinstance(f, str) else f.name
            with open(path, "rb") as fp:
                multipart.append(("files", (os.path.basename(path), fp.read(), "application/octet-stream")))
        # Large PDFs can take minutes to embed on CPU, so allow a generous timeout.
        resp = requests.post(UPLOAD_ENDPOINT, files=multipart, timeout=900)
        resp.raise_for_status()
        return resp.json().get("status", "Files processed.")
    except requests.exceptions.ReadTimeout:
        return ("Still indexing. This file is large and CPU embedding is slow; "
                "wait a moment and try your question, it may already be indexed.")
    except requests.exceptions.RequestException as e:
        return f"Upload failed: {e}"


def reset_documents() -> str:
    try:
        resp = requests.post(RESET_ENDPOINT, timeout=10)
        resp.raise_for_status()
        return resp.json().get("status", "Documents cleared.")
    except requests.exceptions.RequestException as e:
        return f"Reset failed: {e}"


def _bar(value: float, width: int = 12) -> str:
    filled = round(value * width)
    return "█" * filled + "░" * (width - filled)


def _fmt(label: str, value: float, hint: str) -> str:
    return f"**{label}**  \n`{_bar(value)}  {value:.2f} / 1.0`  \n_{hint}_\n"


def _faith_hint(score: float, source: str) -> str:
    what = "web results" if source == "web" else "retrieved documents"
    if score >= 0.75:
        return f"Answer is well-grounded in the {what}"
    if score >= 0.50:
        return f"Answer is mostly grounded in the {what}, minor unsupported details possible"
    return f"Low grounding: answer may contain content not present in the {what}"


def _relevance_hint(score: float) -> str:
    if score >= 0.70:
        return "Answer directly addresses the question"
    if score >= 0.45:
        return "Answer is related but may not fully address the question"
    return "Answer may be off-topic or incomplete"


def _accuracy_hint(score: float) -> str:
    if score >= 0.75:
        return "Strong match with your reference answer"
    if score >= 0.40:
        return "Partial match: some key points differ from your reference"
    return "Low overlap with your reference answer"


def _format_metrics(source: str, faithfulness, answer_relevance, accuracy) -> str:
    if faithfulness is None and answer_relevance is None:
        return ""

    src_label = SOURCE_LABELS.get(source, source)
    lines = [f"**Answer source: {src_label}**", "---"]

    if faithfulness is not None:
        faith_label = "Faithfulness (grounded in documents)" if source != "web" else "Faithfulness (grounded in web results)"
        lines.append(_fmt(faith_label, faithfulness, _faith_hint(faithfulness, source)))

    if answer_relevance is not None:
        lines.append(_fmt("Answer Relevance (addresses the question)", answer_relevance, _relevance_hint(answer_relevance)))

    if accuracy is not None:
        lines.append(_fmt("Accuracy (matches your reference)", accuracy, _accuracy_hint(accuracy)))
    else:
        lines.append("_Accuracy: provide a reference answer above to see this score._")

    return "\n".join(lines)


def process_query(message: str, api_key: str, reference: str, session_id: str, chat_history: list) -> tuple[list, str, str]:
    if not api_key.strip():
        return chat_history, "Enter your Google Gemini API key above to start.", ""
    if not message.strip():
        return chat_history, "Please enter a question.", ""
    try:
        payload = {"query": message, "api_key": api_key.strip(), "session_id": session_id}
        if reference.strip():
            payload["reference"] = reference.strip()

        resp = requests.post(CHAT_ENDPOINT, json=payload, timeout=180)

        if resp.status_code in (400, 401, 429):
            detail = resp.json().get("detail", "Request could not be completed.")
            chat_history.append({"role": "user", "content": message})
            chat_history.append({"role": "assistant", "content": f"Sorry, I can't respond right now:\n\n{detail}"})
            return chat_history, detail, ""

        resp.raise_for_status()
        data = resp.json()
        response_text = data.get("response", "")
        source = data.get("source", "unknown")
        citations = data.get("citations", [])

        src_label = SOURCE_LABELS.get(source, source)
        footer = f"\n\n--- Source: {src_label} ---"
        if citations:
            footer += "\n📎 Sources used: " + "; ".join(citations)

        chat_history.append({"role": "user", "content": message})
        chat_history.append({"role": "assistant", "content": f"{response_text}{footer}"})

        metrics_md = _format_metrics(
            source,
            data.get("faithfulness"),
            data.get("answer_relevance"),
            data.get("accuracy"),
        )
        return chat_history, f"Done. Answered via {src_label}", metrics_md

    except requests.exceptions.RequestException as e:
        error = f"Request failed: {e}"
        chat_history.append({"role": "user", "content": message})
        chat_history.append({"role": "assistant", "content": f"ERROR: {error}"})
        return chat_history, error, ""


def clear_chat(session_id: str) -> tuple[list, str, str]:
    """Clear the UI and the server-side conversation memory for this session."""
    try:
        requests.post(CLEAR_MEMORY_ENDPOINT, params={"session_id": session_id}, timeout=10)
    except requests.exceptions.RequestException:
        pass
    return [], "", ""


with gr.Blocks(title="Agentic RAG Knowledge Search") as demo:
    session_id = gr.State()   # per-browser id for server-side memory

    gr.Markdown("# Agentic RAG Knowledge Search")
    gr.Markdown(
        "Upload your own documents and ask questions. "
        "The agent searches your files via RAG first, then falls back to web search if needed. "
        "It remembers the conversation, so follow-up questions work. "
        "**No files uploaded?** The agent uses a built-in legal/policy document as the default knowledge base."
    )

    with gr.Group():
        gr.Markdown(
            "### Your Gemini API Key (required)\n"
            "This app uses **your own** Google Gemini key; it is sent only with your requests and never stored. "
            "Get a free key at [Google AI Studio](https://aistudio.google.com/apikey)."
        )
        api_key_input = gr.Textbox(
            label="Google Gemini API Key",
            placeholder="Paste your API key here (starts with AIza...)",
            type="password",
            lines=1,
        )

    with gr.Group():
        gr.Markdown(
            f"### Upload Documents\n"
            f"Supported: **{', '.join(SUPPORTED_TYPES)}**. Multiple files allowed; new uploads add to the index.  \n"
            "_Large PDFs (100s of pages) can take a few minutes to index on the free CPU, so please be patient._"
        )
        file_input = gr.File(label="Select Files", file_count="multiple", file_types=SUPPORTED_TYPES)
        with gr.Row():
            upload_btn = gr.Button("Process & Index Files", variant="primary")
            reset_btn = gr.Button("Clear Uploaded Documents", variant="secondary")
        upload_status = gr.Textbox(label="Upload Status", interactive=False, lines=2,
                                   placeholder="Upload status will appear here...")

    gr.Markdown("---")

    with gr.Group():
        gr.Markdown(
            "### Ask a Question\n"
            "> Press **Enter** or click **Submit**. The agent decides whether to search your documents, the web, or both."
        )
        chatbot = gr.Chatbot(label="Conversation", height=420)

        with gr.Row(elem_id="input-row"):
            user_input = gr.Textbox(
                placeholder="Ask anything about your documents or the web...",
                lines=2, scale=5, show_label=False, container=False,
            )
            submit_btn = gr.Button("Submit", variant="primary", scale=1, elem_id="submit-btn")

        reference_input = gr.Textbox(
            label="Reference Answer (optional)",
            placeholder="Paste an expected correct answer here to also see an Accuracy score...",
            lines=2,
        )

        with gr.Row():
            clear_btn = gr.Button("Clear Chat", variant="secondary", scale=1)

        status_output = gr.Textbox(label="Status", interactive=False, lines=1,
                                   placeholder="Status will appear here...")

    gr.Markdown("---")

    with gr.Group():
        gr.Markdown(
            "### Evaluation Metrics\n"
            "Computed automatically after every response, with **no extra API calls and no reference needed** for the first two.\n\n"
            "| Metric | Always shown? | What it measures |\n"
            "|---|---|---|\n"
            "| **Faithfulness** | Yes | Is the answer grounded in the source it used? (docs or web results) |\n"
            "| **Answer Relevance** | Yes | Does the answer actually address your question? |\n"
            "| **Accuracy** | Only with reference | Does the answer match an expected correct answer? |"
        )
        metrics_output = gr.Markdown()

    demo.load(fn=lambda: str(uuid.uuid4()), inputs=[], outputs=[session_id])

    upload_btn.click(fn=upload_files, inputs=[file_input], outputs=[upload_status])
    reset_btn.click(fn=reset_documents, inputs=[], outputs=[upload_status])

    submit_btn.click(
        fn=process_query,
        inputs=[user_input, api_key_input, reference_input, session_id, chatbot],
        outputs=[chatbot, status_output, metrics_output],
    ).then(fn=lambda: "", inputs=[], outputs=[user_input])

    user_input.submit(
        fn=process_query,
        inputs=[user_input, api_key_input, reference_input, session_id, chatbot],
        outputs=[chatbot, status_output, metrics_output],
    ).then(fn=lambda: "", inputs=[], outputs=[user_input])

    clear_btn.click(fn=clear_chat, inputs=[session_id], outputs=[chatbot, status_output, metrics_output])

if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", server_port=7860, share=False, theme=gr.themes.Soft(), css=CSS)