464 lines
17 KiB
Python
464 lines
17 KiB
Python
"""聊天会话、消息、反馈与后台日志接口。"""
|
|
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"},
|
|
)
|