baodan/dify-main/api/insurance/chat/routes.py

464 lines
17 KiB
Python
Raw Normal View History

"""聊天会话、消息、反馈与后台日志接口。"""
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/<session_id>", 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/<session_id>/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/<session_id>/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/<session_id>", 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/<message_id>/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"},
)