| |
| """Streaming QA chat panel.""" |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import tempfile |
| import threading |
| from typing import Any, Dict, List, Optional, Tuple |
|
|
| import gradio as gr |
|
|
| from core.bootstrap import get_chat_store, get_db, get_qa_service |
| from gradio_ui.renderers import render_chat_transcript |
| from gradio_ui.state import SearchMode, footer_hint |
| from utils.response_cleaner import clean_model_response |
|
|
| ChatMessage = Dict[str, Any] |
| ChatHistory = List[ChatMessage] |
|
|
| _THINKING = "⏳ *Thinking…*" |
|
|
|
|
| def _friendly_error(exc: BaseException) -> str: |
| msg = str(exc).strip() |
| lower = msg.lower() |
| if "504" in msg or "timeout" in lower or "serviceerror" in lower: |
| return ( |
| "The AI service timed out while loading or generating a reply. " |
| "This often happens on a cold start — wait a minute and try again." |
| ) |
| if "preemption" in lower or "preempt" in lower: |
| return "The AI worker was restarted. Please send your question again." |
| if not msg: |
| return "Something went wrong while generating a reply. Please try again." |
| if len(msg) > 220: |
| return msg[:217] + "…" |
| return msg |
|
|
|
|
| def _history_to_api(history: ChatHistory) -> List[Dict[str, str]]: |
| api: List[Dict[str, str]] = [] |
| for msg in history or []: |
| role = msg.get("role") |
| content = msg.get("content") or "" |
| if role in ("user", "assistant") and content and content != _THINKING: |
| cleaned = clean_model_response(content) if role == "assistant" else content |
| if cleaned: |
| api.append({"role": role, "content": cleaned}) |
| return api |
|
|
|
|
| def _transcript(history: ChatHistory) -> str: |
| return render_chat_transcript(history or []) |
|
|
|
|
| def _start_exchange(history: ChatHistory, message: str) -> ChatHistory: |
| updated = list(history or []) |
| updated.append({"role": "user", "content": message}) |
| updated.append({"role": "assistant", "content": _THINKING}) |
| return updated |
|
|
|
|
| def _patch_assistant( |
| history: ChatHistory, |
| *, |
| content: str | None = None, |
| sources: List[Dict[str, Any]] | None = None, |
| confidence: float | None = None, |
| ) -> ChatHistory: |
| updated = list(history or []) |
| if not updated or updated[-1].get("role") != "assistant": |
| updated.append({"role": "assistant", "content": content or _THINKING}) |
| msg = dict(updated[-1]) |
| if content is not None: |
| msg["content"] = content |
| if sources is not None: |
| msg["sources"] = sources |
| if confidence is not None: |
| msg["confidence"] = float(confidence) |
| updated[-1] = msg |
| return updated |
|
|
|
|
| def _streaming_content(raw: str) -> str: |
| cleaned = clean_model_response(raw) |
| if cleaned: |
| return cleaned |
| return _THINKING |
|
|
|
|
| def _persist_message( |
| session_id: str, |
| query: str, |
| answer: str, |
| sources: List[Dict[str, Any]], |
| confidence: Optional[float], |
| ) -> None: |
| store = get_chat_store() |
| store.add_message(session_id, "user", query) |
| store.add_message( |
| session_id, |
| "assistant", |
| answer, |
| sources=sources, |
| confidence=confidence, |
| ) |
|
|
|
|
| async def stream_chat( |
| message: str, |
| history: ChatHistory, |
| search_mode: SearchMode, |
| selected_doc_ids: List[str], |
| session_id: Optional[str], |
| ): |
| if not message or not message.strip(): |
| yield history, _transcript(history) |
| return |
|
|
| qa = get_qa_service() |
| history = _start_exchange(history, message) |
| yield history, _transcript(history) |
|
|
| prior = _history_to_api(history[:-2]) |
| doc_ids = selected_doc_ids if selected_doc_ids else None |
| sources: List[Dict[str, Any]] = [] |
| confidence: Optional[float] = None |
| tokens: List[str] = [] |
|
|
| loop = asyncio.get_running_loop() |
| item_queue: asyncio.Queue[Any] = asyncio.Queue() |
| stream_error: List[BaseException] = [] |
|
|
| def _produce_stream() -> None: |
| try: |
| for stream_item in qa.stream_answer( |
| message, |
| prior, |
| document_ids=doc_ids, |
| mode=search_mode, |
| ): |
| loop.call_soon_threadsafe(item_queue.put_nowait, stream_item) |
| except BaseException as exc: |
| stream_error.append(exc) |
| finally: |
| loop.call_soon_threadsafe(item_queue.put_nowait, None) |
|
|
| threading.Thread(target=_produce_stream, daemon=True).start() |
|
|
| while True: |
| item = await item_queue.get() |
| if item is None: |
| break |
| if isinstance(item, dict): |
| if item.get("type") == "sources": |
| sources = item.get("data") or [] |
| history = _patch_assistant(history, sources=sources) |
| yield history, _transcript(history) |
| elif item.get("type") == "confidence": |
| confidence = item.get("data") |
| history = _patch_assistant( |
| history, |
| confidence=float(confidence) if confidence is not None else None, |
| ) |
| yield history, _transcript(history) |
| else: |
| tokens.append(str(item)) |
| history = _patch_assistant( |
| history, |
| content=_streaming_content("".join(tokens)), |
| ) |
| yield history, _transcript(history) |
|
|
| if stream_error: |
| history = _patch_assistant( |
| history, |
| content=f"**Error:** {_friendly_error(stream_error[0])}", |
| sources=sources, |
| confidence=float(confidence) if confidence is not None else None, |
| ) |
| yield history, _transcript(history) |
| return |
|
|
| answer = clean_model_response("".join(tokens)) |
| if not answer: |
| answer = "Sorry, I couldn't generate a response. Please try again." |
| history = _patch_assistant( |
| history, |
| content=answer, |
| sources=sources, |
| confidence=float(confidence) if confidence is not None else None, |
| ) |
|
|
| if session_id: |
| _persist_message(session_id, message, answer, sources, confidence) |
|
|
| yield history, _transcript(history) |
|
|
|
|
| def create_new_session( |
| search_mode: SearchMode, |
| selected_doc_ids: List[str], |
| ) -> Tuple[Optional[str], ChatHistory, str, str, gr.update]: |
| store = get_chat_store() |
| doc_ids = selected_doc_ids if search_mode == "single" else None |
| session = store.create_session("New Chat", doc_ids) |
| session_id = session["id"] |
| choices = _session_choices() |
| doc_count = len(get_db().list_documents()) |
| return ( |
| session_id, |
| [], |
| _transcript([]), |
| footer_hint(search_mode, selected_doc_ids, doc_count), |
| gr.update(choices=choices, value=session_id), |
| ) |
|
|
|
|
| def load_session(session_id: Optional[str]) -> Tuple[ChatHistory, str]: |
| if not session_id: |
| return [], _transcript([]) |
| store = get_chat_store() |
| messages = store.get_messages(session_id) |
| history: ChatHistory = [] |
|
|
| for msg in messages: |
| role = msg.get("role") |
| content = msg.get("content") or "" |
| if role not in ("user", "assistant"): |
| continue |
| entry: ChatMessage = {"role": role, "content": content} |
| if role == "assistant": |
| entry["sources"] = msg.get("sources") or [] |
| conf = msg.get("confidence") |
| if conf is not None: |
| entry["confidence"] = float(conf) |
| history.append(entry) |
|
|
| return history, _transcript(history) |
|
|
|
|
| def export_session_file(session_id: Optional[str]) -> Optional[str]: |
| if not session_id: |
| return None |
| store = get_chat_store() |
| content = store.export_session(session_id) |
| if not content: |
| return None |
| tmp = tempfile.NamedTemporaryFile( |
| mode="w", |
| suffix=".txt", |
| prefix=f"finsight_session_{session_id[:8]}_", |
| delete=False, |
| encoding="utf-8", |
| ) |
| tmp.write(content) |
| tmp.close() |
| return tmp.name |
|
|
|
|
| def _session_choices() -> List[Tuple[str, str]]: |
| store = get_chat_store() |
| sessions = store.list_sessions() |
| return [(f"{s['title']} ({s['id'][:8]})", s["id"]) for s in sessions] |
|
|
|
|
| def on_load_sessions() -> Tuple[Optional[str], gr.update, ChatHistory, str]: |
| choices = _session_choices() |
| if choices: |
| session_id = choices[0][1] |
| history, transcript = load_session(session_id) |
| return session_id, gr.update(choices=choices, value=session_id), history, transcript |
| session = get_chat_store().create_session("New Chat") |
| sid = session["id"] |
| choices = _session_choices() |
| return sid, gr.update(choices=choices, value=sid), [], _transcript([]) |
|
|
|
|
| def build_chat_panel() -> dict: |
| chat_transcript = gr.HTML( |
| render_chat_transcript([]), |
| padding=False, |
| container=False, |
| autoscroll=False, |
| elem_id="fs-chat-transcript", |
| elem_classes=["fs-chat-transcript"], |
| ) |
|
|
| export_file = gr.File(label="Exported session", visible=False, elem_classes=["hidden-control"]) |
|
|
| return { |
| "chat_transcript": chat_transcript, |
| "export_file": export_file, |
| "on_load_sessions": on_load_sessions, |
| "create_new_session": create_new_session, |
| "load_session": load_session, |
| "export_session_file": export_session_file, |
| "stream_chat": stream_chat, |
| } |
|
|