Nihal2000 commited on
Commit
bdcf37e
·
1 Parent(s): 364c2a2

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

Files changed (2) hide show
  1. app.py +109 -33
  2. 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(self, question: str, context_filter: Optional[Dict] = None) -> Dict[str, Any]:
 
 
 
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
- 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"}
 
 
 
 
 
 
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 ask_question(question):
749
- if not question.strip():
750
- return "Please enter a question"
751
- try:
752
- result = mcp_server.run_async(mcp_server.answer_question_async(question))
753
- if result["success"]:
754
- output_str = f"❓ Question: {result['question']}\n\n"
755
- output_str += f"💡 Answer:\n{result['answer']}\n\n"
756
- output_str += f"🎯 Confidence: {result['confidence']}\n\n"
757
- output_str += f"📚 Sources Used ({len(result['sources'])}):\n"
758
- for i, source_item in enumerate(result['sources'], 1):
759
- filename = source_item.get('metadata', {}).get('document_filename', 'Unknown')
760
- output_str += f"\n{i}. 📄 {filename}\n"
761
- output_str += f" 📝 Excerpt: {source_item['content'][:150]}...\n"
762
- output_str += f" 📊 Relevance: {source_item['score']:.3f}\n"
763
- return output_str
764
- else:
765
- return f"❌ {result.get('error', 'Failed to answer question')}"
766
- except Exception as e:
767
- return f"❌ Error: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("❓ Ask Questions"):
 
 
 
 
 
 
 
 
 
 
 
 
1673
  with gr.Row():
1674
- with gr.Column():
1675
- gr.Markdown("""### Ask Questions About Your Documents
1676
- The AI will search through all your uploaded documents to find relevant information
1677
- and provide comprehensive answers with sources.""")
1678
- qa_question_input = gr.Textbox(label="Your Question", placeholder="Ask anything about your documents...", lines=3)
1679
- qa_btn_action = gr.Button("❓ Get Answer", variant="primary", size="lg")
1680
- with gr.Column():
1681
- qa_output_display = gr.Textbox(label="AI Answer", lines=20, placeholder="Answer will appear here with sources...")
 
 
 
 
 
 
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
- qa_btn_action.click(ask_question, inputs=[qa_question_input], outputs=[qa_output_display])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(self, question: str, context_results: List[SearchResult] = None) -> str:
108
- """Answer a question using the provided context or RAG"""
 
 
 
 
 
 
 
 
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
- masked_question, question_namespace = self._mask(question)
134
- masked_context, context_namespace = self._mask(context)
 
 
 
 
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
- answer = self._restore(answer, question_namespace)
141
- answer = self._restore(answer, context_namespace)
 
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