"""聊天会话、消息、反馈与后台日志接口。""" from __future__ import annotations import csv import json import logging import uuid from datetime import datetime from io import StringIO from urllib import error as urlerror from urllib import request as urlrequest from flask import Blueprint, Response, request, stream_with_context from sqlalchemy import or_, text from extensions.ext_database import db from insurance.config import DIFY_BASE_URL, DIFY_CHAT_APP_API_KEY from insurance.db.models import ChatMessage, ChatSession, WecomUserMapping from insurance.utils.auth import get_current_user, login_required from insurance.utils.response import error, success logger = logging.getLogger(__name__) chat_bp = Blueprint("insurance_chat", __name__) def _now_iso(value) -> str: if not value: return "" if isinstance(value, datetime): return value.strftime("%Y-%m-%d %H:%M:%S") return str(value) def _current_user_id() -> str: user = get_current_user() or {} return str(user.get("user_id") or "guest") def _current_username(user_id: str) -> str: user = get_current_user() or {} username = user.get("username") if username: return str(username) mapping = db.session.query(WecomUserMapping).filter_by(id=user_id).first() if mapping: return mapping.username or mapping.wecom_userid or user_id return user_id def _ensure_tables() -> None: db.session.execute(text(""" CREATE TABLE IF NOT EXISTS insurance_chat_sessions ( id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, name VARCHAR(255) DEFAULT '新会话', app_id VARCHAR(64), api_token VARCHAR(255), dify_conversation_id VARCHAR(64), status VARCHAR(16) DEFAULT 'active', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """)) db.session.execute(text(""" CREATE INDEX IF NOT EXISTS insurance_chat_sessions_user_idx ON insurance_chat_sessions (user_id, updated_at DESC) """)) db.session.execute(text(""" CREATE TABLE IF NOT EXISTS insurance_chat_messages ( id VARCHAR(64) PRIMARY KEY, session_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, query TEXT NOT NULL, answer TEXT DEFAULT '', sources JSONB DEFAULT '[]'::jsonb, filters JSONB, dify_message_id VARCHAR(64), feedback VARCHAR(32), correction TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """)) db.session.execute(text(""" CREATE INDEX IF NOT EXISTS insurance_chat_messages_session_idx ON insurance_chat_messages (session_id, created_at ASC) """)) db.session.execute(text(""" CREATE INDEX IF NOT EXISTS insurance_chat_messages_user_idx ON insurance_chat_messages (user_id, created_at DESC) """)) db.session.commit() def _session_to_dict(session: ChatSession) -> dict: return { "id": session.id, "name": session.name or "新会话", "user_id": session.user_id, "app_id": session.app_id, "conversation_id": session.dify_conversation_id or session.id, "created_at": _now_iso(session.created_at), "updated_at": _now_iso(session.updated_at), } def _message_to_dict(message: ChatMessage) -> dict: return { "id": message.id, "session_id": message.session_id, "user_id": message.user_id, "query": message.query, "answer": message.answer or "", "sources": message.sources or [], "feedback": message.feedback, "correction": message.correction, "created_at": _now_iso(message.created_at), } def _get_session_for_user(session_id: str, user_id: str) -> ChatSession | None: return db.session.query(ChatSession).filter_by(id=session_id, user_id=user_id, status="active").first() def _create_session(user_id: str, name: str = "新会话", api_token: str = "", app_id: str = "") -> ChatSession: session = ChatSession( id=str(uuid.uuid4()), user_id=user_id, name=name or "新会话", app_id=app_id or None, api_token=api_token or None, ) db.session.add(session) db.session.commit() return session def _call_dify_chat(api_token: str, message: str, dify_conversation_id: str | None, user_id: str, filters: dict | None): token = api_token or DIFY_CHAT_APP_API_KEY if not token: raise RuntimeError("Dify API Token 未配置") base_url = DIFY_BASE_URL.rstrip("/") endpoint = f"{base_url}/v1/chat-messages" payload = { "inputs": filters or {}, "query": message, "response_mode": "streaming", "user": user_id, } if dify_conversation_id: payload["conversation_id"] = dify_conversation_id req = urlrequest.Request( endpoint, data=json.dumps(payload).encode("utf-8"), headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "text/event-stream", }, method="POST", ) return urlrequest.urlopen(req, timeout=120) def _normalize_feedback(rating: str | None) -> str | None: if rating == "helpful": return "helpful" if rating == "not_helpful": return "not_helpful" if rating == "like": return "helpful" if rating == "dislike": return "not_helpful" return None @chat_bp.route("/sessions", methods=["GET"]) @login_required def list_sessions(): _ensure_tables() user_id = _current_user_id() page = max(int(request.args.get("page", 1)), 1) page_size = min(max(int(request.args.get("page_size", 50)), 1), 100) query = db.session.query(ChatSession).filter_by(user_id=user_id, status="active") total = query.count() items = ( query.order_by(ChatSession.updated_at.desc(), ChatSession.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) return success({"items": [_session_to_dict(item) for item in items], "total": total}) @chat_bp.route("/sessions", methods=["POST"]) @login_required def create_session(): _ensure_tables() data = request.get_json(force=True, silent=True) or {} session = _create_session( user_id=_current_user_id(), name=(data.get("name") or "新会话").strip() or "新会话", api_token=data.get("api_token") or "", app_id=data.get("app_id") or "", ) return success({"session_id": session.id, "id": session.id}) @chat_bp.route("/sessions/", methods=["GET"]) @login_required def get_session_messages(session_id: str): _ensure_tables() user_id = _current_user_id() session = _get_session_for_user(session_id, user_id) if not session: return error(404, "会话不存在"), 404 messages = ( db.session.query(ChatMessage) .filter_by(session_id=session.id, user_id=user_id) .order_by(ChatMessage.created_at.asc()) .all() ) return success({"session": _session_to_dict(session), "messages": [_message_to_dict(item) for item in messages]}) @chat_bp.route("/sessions//rename", methods=["PUT"]) @login_required def rename_session(session_id: str): _ensure_tables() data = request.get_json(force=True, silent=True) or {} name = (data.get("name") or "").strip() if not name: return error(1001, "会话名称不能为空"), 400 session = _get_session_for_user(session_id, _current_user_id()) if not session: return error(404, "会话不存在"), 404 session.name = name[:255] db.session.commit() return success(_session_to_dict(session)) @chat_bp.route("/sessions//auto-name", methods=["POST"]) @login_required def auto_name_session(session_id: str): _ensure_tables() data = request.get_json(force=True, silent=True) or {} first_message = (data.get("message") or "").strip() session = _get_session_for_user(session_id, _current_user_id()) if not session: return error(404, "会话不存在"), 404 if first_message and (not session.name or session.name == "新会话"): session.name = first_message[:30] db.session.commit() return success(_session_to_dict(session)) @chat_bp.route("/sessions/", methods=["DELETE"]) @login_required def delete_session(session_id: str): _ensure_tables() session = _get_session_for_user(session_id, _current_user_id()) if not session: return error(404, "会话不存在"), 404 session.status = "deleted" db.session.commit() return success() @chat_bp.route("/message", methods=["POST"]) @login_required def send_message(): _ensure_tables() data = request.get_json(force=True, silent=True) or {} message_text = (data.get("message") or "").strip() if not message_text: return error(1001, "消息不能为空"), 400 user_id = _current_user_id() session_id = (data.get("session_id") or "").strip() api_token = data.get("api_token") or "" app_id = data.get("app_id") or "" filters = data.get("filters") or None session = _get_session_for_user(session_id, user_id) if session_id else None if not session: session = _create_session(user_id=user_id, api_token=api_token, app_id=app_id) elif api_token and not session.api_token: session.api_token = api_token if app_id and not session.app_id: session.app_id = app_id db.session.commit() chat_message = ChatMessage( id=str(uuid.uuid4()), session_id=session.id, user_id=user_id, query=message_text, answer="", sources=[], filters=filters, ) db.session.add(chat_message) db.session.commit() def generate(): answer_parts: list[str] = [] sources: list[dict] = [] dify_message_id = None dify_conversation_id = session.dify_conversation_id try: with _call_dify_chat(session.api_token or api_token, message_text, dify_conversation_id, user_id, filters) as upstream: for raw_line in upstream: line = raw_line.decode("utf-8", errors="ignore").strip() if not line.startswith("data:"): continue raw_data = line[5:].strip() if not raw_data or raw_data == "[DONE]": continue try: event = json.loads(raw_data) except json.JSONDecodeError: continue event_type = event.get("event") if event_type in {"message", "agent_message"}: text_delta = event.get("answer") or "" if text_delta: answer_parts.append(text_delta) yield f"data: {json.dumps({'type': 'delta', 'data': text_delta}, ensure_ascii=False)}\n\n" elif event_type == "message_file": continue elif event_type == "message_end": dify_message_id = event.get("message_id") or dify_message_id dify_conversation_id = event.get("conversation_id") or dify_conversation_id metadata = event.get("metadata") or {} for item in metadata.get("retriever_resources") or []: source = { "doc_name": item.get("document_name") or item.get("dataset_name") or "知识库文档", "chunk": item.get("content") or "", "score": item.get("score"), } sources.append(source) yield f"data: {json.dumps({'type': 'source', 'data': source}, ensure_ascii=False)}\n\n" elif event_type == "error": message = event.get("message") or "Dify 调用失败" yield f"data: {json.dumps({'type': 'error', 'data': {'message': message}}, ensure_ascii=False)}\n\n" final_answer = "".join(answer_parts) chat_message.answer = final_answer chat_message.sources = sources chat_message.dify_message_id = dify_message_id session.dify_conversation_id = dify_conversation_id or session.dify_conversation_id session.updated_at = datetime.utcnow() db.session.commit() done_payload = { "conversation_id": session.id, "dify_conversation_id": session.dify_conversation_id, "message_id": chat_message.id, } yield f"data: {json.dumps({'type': 'done', 'data': done_payload}, ensure_ascii=False)}\n\n" except (urlerror.URLError, TimeoutError, RuntimeError) as exc: logger.exception("Dify chat request failed") chat_message.answer = "" db.session.commit() yield f"data: {json.dumps({'type': 'error', 'data': {'message': str(exc)}}, ensure_ascii=False)}\n\n" return Response(stream_with_context(generate()), mimetype="text/event-stream") @chat_bp.route("/messages//feedback", methods=["POST"]) @login_required def submit_feedback(message_id: str): _ensure_tables() data = request.get_json(force=True, silent=True) or {} rating = _normalize_feedback(data.get("rating")) if not rating: return error(1001, "评分参数错误"), 400 message = db.session.query(ChatMessage).filter_by(id=message_id, user_id=_current_user_id()).first() if not message: return error(404, "消息不存在"), 404 message.feedback = rating if data.get("correction"): message.correction = str(data.get("correction")) db.session.commit() return success(_message_to_dict(message)) @chat_bp.route("/admin/logs/chat", methods=["GET"]) @login_required def admin_chat_logs(): _ensure_tables() page = max(int(request.args.get("page", 1)), 1) page_size = min(max(int(request.args.get("page_size", 20)), 1), 100) query = db.session.query(ChatMessage, ChatSession, WecomUserMapping).join( ChatSession, ChatSession.id == ChatMessage.session_id ).outerjoin(WecomUserMapping, WecomUserMapping.id == ChatMessage.user_id) keyword = (request.args.get("keyword") or "").strip() user_id = (request.args.get("user_id") or "").strip() start_date = (request.args.get("start_date") or "").strip() end_date = (request.args.get("end_date") or "").strip() if keyword: like = f"%{keyword}%" query = query.filter(or_(ChatMessage.query.ilike(like), ChatMessage.answer.ilike(like))) if user_id: query = query.filter(ChatMessage.user_id == user_id) if start_date: query = query.filter(ChatMessage.created_at >= f"{start_date} 00:00:00") if end_date: query = query.filter(ChatMessage.created_at <= f"{end_date} 23:59:59") total = query.count() rows = query.order_by(ChatMessage.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() items = [] for message, session, user in rows: items.append({ "id": message.id, "session_id": session.id, "user_id": message.user_id, "user": (user.username if user else message.user_id), "username": (user.username if user else message.user_id), "user_name": (user.username if user else message.user_id), "query": message.query, "answer": message.answer or "", "feedback": message.feedback, "correction": message.correction, "created_at": _now_iso(message.created_at), }) return success({"items": items, "total": total}) @chat_bp.route("/admin/logs/chat/export", methods=["GET"]) @login_required def export_chat_logs(): response = admin_chat_logs() payload = response[0] if isinstance(response, tuple) else response data = payload.get("data", {}) if isinstance(payload, dict) else {} output = StringIO() writer = csv.writer(output) writer.writerow(["ID", "用户ID", "用户", "问题", "回答", "评分", "纠错", "时间"]) for item in data.get("items", []): writer.writerow([ item.get("id", ""), item.get("user_id", ""), item.get("user_name", ""), item.get("query", ""), item.get("answer", ""), item.get("feedback", ""), item.get("correction", ""), item.get("created_at", ""), ]) csv_data = "\ufeff" + output.getvalue() return Response( csv_data, mimetype="text/csv; charset=utf-8", headers={"Content-Disposition": "attachment; filename=chat-logs.csv"}, )