Spaces:
Running on Zero
Running on Zero
feat: DoChat - ChatGPT/Claude-style chat tab with visible PII masking
Browse files- 'Ask Questions' tab renamed to '🤖 DoChat': gr.Chatbot (messages type)
with user/assistant bubbles, avatars, copy buttons, Send button +
Enter-to-send, 'New chat' reset, and a Sources-accordion under the
thread (answer footer cites confidence + source count).
- New 🛡️ PII masking checkbox (default ON). Per answer the UI shows a
visible status banner: masking ON prints how many identifiers were
hidden and the exact placeholder view the agent received; OFF warns
that the agent saw raw text. mcp answer_question_async returns pii
metadata; generative_tool.answer_question gained mask_pii=False
opt-out path (skips mask/restore for that call only).
- 17/17 tests; chat flows verified locally for both banner states
- app.py +109 -33
- mcp_tools/generative_tool.py +24 -11
app.py
CHANGED
|
@@ -297,13 +297,22 @@ class ContentOrganizerMCPServer:
|
|
| 297 |
logger.error(f"Podcast generation failed: {str(e)}")
|
| 298 |
return {"success": False, "error": str(e)}
|
| 299 |
|
| 300 |
-
async def answer_question_async(
|
|
|
|
|
|
|
|
|
|
| 301 |
try:
|
| 302 |
search_results = await self.search_tool.search(question, top_k=5, filters=context_filter)
|
| 303 |
if not search_results:
|
| 304 |
return {"success": False, "error": "No relevant context found in your documents. Please make sure you have uploaded relevant documents.", "question": question}
|
| 305 |
-
answer = await self.generative_tool.answer_question(question, search_results)
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
except Exception as e:
|
| 308 |
logger.error(f"Question answering failed: {str(e)}")
|
| 309 |
return {"success": False, "error": str(e), "question": question}
|
|
@@ -745,26 +754,60 @@ def generate_tags_for_document(doc_choice, custom_text, max_tags):
|
|
| 745 |
logger.error(f"Tag generation error: {str(e)}")
|
| 746 |
return f"❌ Error: {str(e)}"
|
| 747 |
|
| 748 |
-
def
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
|
| 769 |
def delete_document_from_library(document_id):
|
| 770 |
if not document_id:
|
|
@@ -1669,16 +1712,34 @@ def create_gradio_interface():
|
|
| 1669 |
]
|
| 1670 |
)
|
| 1671 |
|
| 1672 |
-
with gr.Tab("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1673 |
with gr.Row():
|
| 1674 |
-
|
| 1675 |
-
|
| 1676 |
-
|
| 1677 |
-
|
| 1678 |
-
|
| 1679 |
-
|
| 1680 |
-
|
| 1681 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1682 |
|
| 1683 |
all_dropdowns_to_update = [delete_doc_dropdown_visible, doc_dropdown_content, doc_dropdown_tag_visible, pii_doc_dropdown, podcast_doc_selector]
|
| 1684 |
|
|
@@ -1707,7 +1768,22 @@ def create_gradio_interface():
|
|
| 1707 |
|
| 1708 |
search_btn_action.click(perform_search, inputs=[search_query_input, search_top_k_slider], outputs=[search_output_display])
|
| 1709 |
tag_btn_action.click(generate_tags_for_document, inputs=[doc_dropdown_tag_visible, tag_text_input, max_tags_slider], outputs=[tag_output_display])
|
| 1710 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1711 |
|
| 1712 |
# ---- PII Protection tab ----
|
| 1713 |
pii_btn_detect.click(
|
|
|
|
| 297 |
logger.error(f"Podcast generation failed: {str(e)}")
|
| 298 |
return {"success": False, "error": str(e)}
|
| 299 |
|
| 300 |
+
async def answer_question_async(
|
| 301 |
+
self, question: str, context_filter: Optional[Dict] = None,
|
| 302 |
+
mask_pii: bool = True,
|
| 303 |
+
) -> Dict[str, Any]:
|
| 304 |
try:
|
| 305 |
search_results = await self.search_tool.search(question, top_k=5, filters=context_filter)
|
| 306 |
if not search_results:
|
| 307 |
return {"success": False, "error": "No relevant context found in your documents. Please make sure you have uploaded relevant documents.", "question": question}
|
| 308 |
+
answer = await self.generative_tool.answer_question(question, search_results, mask_pii=mask_pii)
|
| 309 |
+
# DoChat PII display: what the agent actually received for this turn
|
| 310 |
+
if mask_pii:
|
| 311 |
+
masked = self.pii_service.mask_for_agent(question, namespace=f"ui:chatview:{uuid.uuid4().hex}", register=False)
|
| 312 |
+
pii_meta = {"on": True, "masked_view": masked.masked_text, "mask_count": masked.masked_count}
|
| 313 |
+
else:
|
| 314 |
+
pii_meta = {"on": False, "masked_view": question, "mask_count": 0}
|
| 315 |
+
return {"success": True, "question": question, "answer": answer, "sources": [result.to_dict() for result in search_results], "confidence": "high" if len(search_results) >= 3 else "medium", "pii": pii_meta}
|
| 316 |
except Exception as e:
|
| 317 |
logger.error(f"Question answering failed: {str(e)}")
|
| 318 |
return {"success": False, "error": str(e), "question": question}
|
|
|
|
| 754 |
logger.error(f"Tag generation error: {str(e)}")
|
| 755 |
return f"❌ Error: {str(e)}"
|
| 756 |
|
| 757 |
+
def _dochat_pii_banner(pii_meta):
|
| 758 |
+
"""Visible-in-output PII status line shown after every DoChat reply."""
|
| 759 |
+
if not pii_meta:
|
| 760 |
+
return "🆕 New conversation."
|
| 761 |
+
if pii_meta.get("on"):
|
| 762 |
+
masked = pii_meta.get("masked_view") or ""
|
| 763 |
+
count = pii_meta.get("mask_count") or 0
|
| 764 |
+
if count:
|
| 765 |
+
return (
|
| 766 |
+
f"🛡️ **PII masking: ON** — {count} identifier(s) hidden from the agent this turn.\n"
|
| 767 |
+
f"- Agent received: `{masked}`\n"
|
| 768 |
+
f"- Your answer above shows the real values (restored in memory only)."
|
| 769 |
+
)
|
| 770 |
+
return "🛡️ **PII masking: ON** — no sensitive identifiers were found in this turn."
|
| 771 |
+
return ("⚠️ **PII masking: OFF** — the agent saw your raw text this turn. "
|
| 772 |
+
"Tick 🛡️ back on to hide personal identifiers.")
|
| 773 |
+
|
| 774 |
+
def _dochat_sources_text(sources):
|
| 775 |
+
lines = []
|
| 776 |
+
for i, source_item in enumerate(sources or [], 1):
|
| 777 |
+
filename = source_item.get('metadata', {}).get('document_filename', 'Unknown')
|
| 778 |
+
lines.append(f"{i}. 📄 {filename}")
|
| 779 |
+
lines.append(f" 📝 Excerpt: {str(source_item.get('content', ''))[:200]}…")
|
| 780 |
+
score = source_item.get('score')
|
| 781 |
+
if score is not None:
|
| 782 |
+
lines.append(f" 📊 Relevance: {score:.3f}")
|
| 783 |
+
return "\n".join(lines) or "No sources used."
|
| 784 |
+
|
| 785 |
+
def dochat_send(question, history, mask_pii_on):
|
| 786 |
+
"""ChatGPT/Claude-style turn: user bubble + assistant bubble, PII visible."""
|
| 787 |
+
history = list(history or [])
|
| 788 |
+
question = (question or "").strip()
|
| 789 |
+
if not question:
|
| 790 |
+
return history, gr.update(), "🆕 Type a question to start.", _dochat_sources_text([])
|
| 791 |
+
history.append({"role": "user", "content": question})
|
| 792 |
+
result = mcp_server.run_async(mcp_server.answer_question_async(question, mask_pii=bool(mask_pii_on)))
|
| 793 |
+
sources_text = _dochat_sources_text(result.get("sources", []))
|
| 794 |
+
if not result.get("success"):
|
| 795 |
+
meta = result.get("pii") or {"on": bool(mask_pii_on), "masked_view": "", "mask_count": 0}
|
| 796 |
+
history.append({"role": "assistant", "content": f"❌ {result.get('error', 'Failed to answer question')}"})
|
| 797 |
+
else:
|
| 798 |
+
meta = result.get("pii") or {"on": bool(mask_pii_on), "masked_view": "", "mask_count": 0}
|
| 799 |
+
answer = result.get("answer", "")
|
| 800 |
+
assistant_content = (
|
| 801 |
+
f"{answer}\n\n"
|
| 802 |
+
f"—\n🎯 Confidence: {result.get('confidence', 'medium')} · "
|
| 803 |
+
f"📚 {len(result.get('sources', []))} source(s) — see the *Sources used* panel below."
|
| 804 |
+
)
|
| 805 |
+
history.append({"role": "assistant", "content": assistant_content})
|
| 806 |
+
return history, "", _dochat_pii_banner(meta), sources_text
|
| 807 |
+
|
| 808 |
+
def dochat_clear():
|
| 809 |
+
return [], "🆕 New conversation started. Ask anything about your library — 🛡️ PII masking status shows here after every reply.", ""
|
| 810 |
+
|
| 811 |
|
| 812 |
def delete_document_from_library(document_id):
|
| 813 |
if not document_id:
|
|
|
|
| 1712 |
]
|
| 1713 |
)
|
| 1714 |
|
| 1715 |
+
with gr.Tab("🤖 DoChat"):
|
| 1716 |
+
gr.Markdown("""### 🤖 DoChat — your library, ChatGPT-style
|
| 1717 |
+
The assistant searches all your uploaded documents, answers with sources, and keeps the chat flowing.
|
| 1718 |
+
🛡️ By default PII masking is **on**: the agent only ever sees placeholders — your reply shows real values.""")
|
| 1719 |
+
chat_display = gr.Chatbot(
|
| 1720 |
+
type="messages",
|
| 1721 |
+
label="",
|
| 1722 |
+
height=520,
|
| 1723 |
+
show_copy_button=True,
|
| 1724 |
+
avatar_images=("👤", "🤖"),
|
| 1725 |
+
placeholder="Ask anything about your documents… e.g. “Explain the attention mechanism from the paper you have”",
|
| 1726 |
+
render_markdown=True,
|
| 1727 |
+
)
|
| 1728 |
with gr.Row():
|
| 1729 |
+
qa_question_input = gr.Textbox(
|
| 1730 |
+
placeholder="Type your question and press Enter, or click Send…",
|
| 1731 |
+
show_label=False, lines=1, scale=9, container=False, autofocus=True
|
| 1732 |
+
)
|
| 1733 |
+
qa_btn_action = gr.Button("➤ Send", variant="primary", scale=1)
|
| 1734 |
+
dochat_pii_toggle = gr.Checkbox(
|
| 1735 |
+
label="🛡️ PII masking ON (agent sees [EMAIL_1]/[AADHAAR_1] placeholders; your answer shows real values) — visible below after every reply",
|
| 1736 |
+
value=True
|
| 1737 |
+
)
|
| 1738 |
+
dochat_pii_meta = gr.Markdown("🆕 New conversation. Ask anything about your library.")
|
| 1739 |
+
with gr.Accordion("📚 Sources used in the last answer", open=False):
|
| 1740 |
+
qa_sources_display = gr.Textbox(label="", lines=8, show_label=False, show_copy_button=True)
|
| 1741 |
+
dochat_clear_btn = gr.Button("🗑️ New chat", variant="secondary", size="sm")
|
| 1742 |
+
chat_state = gr.State([])
|
| 1743 |
|
| 1744 |
all_dropdowns_to_update = [delete_doc_dropdown_visible, doc_dropdown_content, doc_dropdown_tag_visible, pii_doc_dropdown, podcast_doc_selector]
|
| 1745 |
|
|
|
|
| 1768 |
|
| 1769 |
search_btn_action.click(perform_search, inputs=[search_query_input, search_top_k_slider], outputs=[search_output_display])
|
| 1770 |
tag_btn_action.click(generate_tags_for_document, inputs=[doc_dropdown_tag_visible, tag_text_input, max_tags_slider], outputs=[tag_output_display])
|
| 1771 |
+
|
| 1772 |
+
# ---- DoChat tab (ChatGPT/Claude-style, PII visible per turn) ----
|
| 1773 |
+
_dochat_outputs = [chat_display, qa_question_input, dochat_pii_meta, qa_sources_display]
|
| 1774 |
+
qa_btn_action.click(
|
| 1775 |
+
dochat_send,
|
| 1776 |
+
inputs=[qa_question_input, chat_state, dochat_pii_toggle],
|
| 1777 |
+
outputs=_dochat_outputs,
|
| 1778 |
+
).then(lambda h: h, inputs=[chat_display], outputs=[chat_state])
|
| 1779 |
+
qa_question_input.submit(
|
| 1780 |
+
dochat_send,
|
| 1781 |
+
inputs=[qa_question_input, chat_state, dochat_pii_toggle],
|
| 1782 |
+
outputs=_dochat_outputs,
|
| 1783 |
+
).then(lambda h: h, inputs=[chat_display], outputs=[chat_state])
|
| 1784 |
+
dochat_clear_btn.click(dochat_clear, outputs=_dochat_outputs).then(
|
| 1785 |
+
lambda: [], None, [chat_state]
|
| 1786 |
+
)
|
| 1787 |
|
| 1788 |
# ---- PII Protection tab ----
|
| 1789 |
pii_btn_detect.click(
|
mcp_tools/generative_tool.py
CHANGED
|
@@ -104,41 +104,54 @@ class GenerativeTool:
|
|
| 104 |
logger.error(f"Error categorizing content: {str(e)}")
|
| 105 |
return "Uncategorized"
|
| 106 |
|
| 107 |
-
async def answer_question(
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
try:
|
| 110 |
if not question.strip():
|
| 111 |
return "No question provided."
|
| 112 |
-
|
| 113 |
logger.info(f"Answering question: {question[:100]}...")
|
| 114 |
-
|
| 115 |
# If no context provided and search tool is available, search for relevant context
|
| 116 |
if not context_results and self.search_tool:
|
| 117 |
logger.info("No context provided, searching for relevant information")
|
| 118 |
context_results = await self.search_tool.search(question, top_k=5)
|
| 119 |
-
|
| 120 |
# Prepare context from search results
|
| 121 |
if context_results:
|
| 122 |
context_texts = []
|
| 123 |
for result in context_results:
|
| 124 |
context_texts.append(f"Source: {result.document_id}\nContent: {result.content}\n")
|
| 125 |
-
|
| 126 |
context = "\n---\n".join(context_texts)
|
| 127 |
logger.info(f"Using context from {len(context_results)} sources")
|
| 128 |
else:
|
| 129 |
context = ""
|
| 130 |
logger.info("No context available for answering question")
|
| 131 |
-
|
| 132 |
# PII boundary: question and context are masked before the LLM sees them
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
# Generate answer
|
| 137 |
answer = await self.llm_service.answer_question(masked_question, masked_context)
|
| 138 |
|
| 139 |
# Give the user back real values in place of placeholders
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 142 |
|
| 143 |
logger.info(f"Generated answer of length {len(answer)}")
|
| 144 |
return answer
|
|
|
|
| 104 |
logger.error(f"Error categorizing content: {str(e)}")
|
| 105 |
return "Uncategorized"
|
| 106 |
|
| 107 |
+
async def answer_question(
|
| 108 |
+
self, question: str, context_results: List[SearchResult] = None,
|
| 109 |
+
mask_pii: bool = True,
|
| 110 |
+
) -> str:
|
| 111 |
+
"""Answer a question using the provided context or RAG.
|
| 112 |
+
|
| 113 |
+
mask_pii=True (default) hides identifiers from the LLM and restores
|
| 114 |
+
real values in the reply. mask_pii=False is the user's explicit opt-out
|
| 115 |
+
(DoChat checkbox) - the agent then sees raw text.
|
| 116 |
+
"""
|
| 117 |
try:
|
| 118 |
if not question.strip():
|
| 119 |
return "No question provided."
|
| 120 |
+
|
| 121 |
logger.info(f"Answering question: {question[:100]}...")
|
| 122 |
+
|
| 123 |
# If no context provided and search tool is available, search for relevant context
|
| 124 |
if not context_results and self.search_tool:
|
| 125 |
logger.info("No context provided, searching for relevant information")
|
| 126 |
context_results = await self.search_tool.search(question, top_k=5)
|
| 127 |
+
|
| 128 |
# Prepare context from search results
|
| 129 |
if context_results:
|
| 130 |
context_texts = []
|
| 131 |
for result in context_results:
|
| 132 |
context_texts.append(f"Source: {result.document_id}\nContent: {result.content}\n")
|
| 133 |
+
|
| 134 |
context = "\n---\n".join(context_texts)
|
| 135 |
logger.info(f"Using context from {len(context_results)} sources")
|
| 136 |
else:
|
| 137 |
context = ""
|
| 138 |
logger.info("No context available for answering question")
|
| 139 |
+
|
| 140 |
# PII boundary: question and context are masked before the LLM sees them
|
| 141 |
+
question_namespace = context_namespace = None
|
| 142 |
+
if mask_pii:
|
| 143 |
+
masked_question, question_namespace = self._mask(question)
|
| 144 |
+
masked_context, context_namespace = self._mask(context)
|
| 145 |
+
else:
|
| 146 |
+
masked_question, masked_context = question, context
|
| 147 |
|
| 148 |
# Generate answer
|
| 149 |
answer = await self.llm_service.answer_question(masked_question, masked_context)
|
| 150 |
|
| 151 |
# Give the user back real values in place of placeholders
|
| 152 |
+
if mask_pii:
|
| 153 |
+
answer = self._restore(answer, question_namespace)
|
| 154 |
+
answer = self._restore(answer, context_namespace)
|
| 155 |
|
| 156 |
logger.info(f"Generated answer of length {len(answer)}")
|
| 157 |
return answer
|