Files
huihuiSquare/digital-avatar-app/backend/routers/chat.py
2026-07-23 17:21:49 +08:00

206 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import difflib
import os
import re
import string
from typing import Any, Callable
import httpx
from fastapi import APIRouter, Body, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
import embeddings
from database import get_db
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
from responses import ok, fail
router = APIRouter(tags=["数字分身聊天"])
CHAT_API_URL = os.getenv("CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
CHAT_API_KEY = os.getenv("CHAT_API_KEY", "")
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen-plus")
MAX_MESSAGE_LENGTH = 4000
MAX_HISTORY_MESSAGES = 10
QA_SIMILARITY_THRESHOLD = 0.86
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
content: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
class ChatIn(BaseModel):
message: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
history: list[ChatMessage] = Field(default_factory=list, max_length=MAX_HISTORY_MESSAGES)
def _resolve_user(authorization: str | None, db: Session):
if not authorization:
return None
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
return db.query(User).filter(User.app_token == token).first()
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
if not avatar:
raise HTTPException(status_code=404, detail="分身不存在")
user = _resolve_user(authorization, db)
if not user:
raise HTTPException(status_code=401, detail="未登录")
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
raise HTTPException(status_code=403, detail="无权访问该分身")
return avatar
def _normalize_question(value: str) -> str:
value = (value or "").strip().lower()
value = re.sub(r"\s+", "", value)
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
def _match_standard_qa(question: str, qa_pairs: list[Any]):
normalized = _normalize_question(question)
if not normalized:
return None
enabled = [qa for qa in qa_pairs if getattr(qa, "enabled", True)]
for qa in enabled:
if _normalize_question(getattr(qa, "question", "")) == normalized:
return qa
best = None
best_score = 0.0
for qa in enabled:
candidate = _normalize_question(getattr(qa, "question", ""))
if not candidate:
continue
score = difflib.SequenceMatcher(None, normalized, candidate).ratio()
if score > best_score:
best, best_score = qa, score
return best if best_score >= QA_SIMILARITY_THRESHOLD else None
def _config(avatar: Avatar) -> dict:
config = getattr(avatar, "config", None) or {}
return {
"replyStyle": config.get("replyStyle", "professional"),
"creativity": max(0, min(100, int(config.get("creativity", 50)))),
"rigor": max(0, min(100, int(config.get("rigor", 50)))),
"humor": max(0, min(100, int(config.get("humor", 30)))),
"responseLength": config.get("responseLength", "medium"),
"systemPrompt": (config.get("systemPrompt", "") or "").strip(),
}
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]:
config = _config(avatar)
knowledge = "\n".join(
f"[{hit.get('filename', '知识库')}] {hit.get('snippet', '')}"
for hit in knowledge_hits
if hit.get("snippet")
)
system = (
"你是用户的专属数字分身。请基于已提供的知识库回答,不要编造事实;"
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100"
f"幽默感:{config['humor']}/100回复长度{config['responseLength']}"
)
if config["systemPrompt"]:
system += f"\n额外系统提示词:{config['systemPrompt']}"
if knowledge:
system += f"\n以下是可参考的知识库内容:\n{knowledge}"
messages = [{"role": "system", "content": system}]
for item in history[-MAX_HISTORY_MESSAGES:]:
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
messages.append({"role": "user", "content": question.strip()})
return messages
def _search_knowledge(db: Session, avatar_id: str, question: str, top_k: int = 5) -> list[dict]:
chunks = db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).all()
if not chunks:
return []
qvec = embeddings.embed([question])[0]
scored = []
for chunk in chunks:
try:
vector = __import__("json").loads(chunk.vector)
except Exception:
continue
scored.append((embeddings.cosine(qvec, vector), chunk))
scored.sort(key=lambda item: item[0], reverse=True)
results = []
for score, chunk in scored[: max(1, top_k)]:
doc = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == chunk.doc_id).first()
results.append({
"docId": chunk.doc_id,
"filename": doc.filename if doc else "",
"fileType": doc.file_type if doc else "",
"snippet": chunk.content[:120] + ("" if len(chunk.content) > 120 else ""),
"score": round(score, 4),
})
return results
def _call_qwen(messages: list[dict], temperature: float) -> str:
if not CHAT_API_KEY:
raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
payload = {
"model": CHAT_MODEL,
"messages": messages,
"temperature": temperature,
}
try:
response = httpx.post(
url,
headers={"Authorization": f"Bearer {CHAT_API_KEY}"},
json=payload,
timeout=30,
)
response.raise_for_status()
data = response.json()
answer = data.get("choices", [{}])[0].get("message", {}).get("content", "")
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
raise RuntimeError("Qwen 模型服务暂时不可用") from exc
if not isinstance(answer, str) or not answer.strip():
raise RuntimeError("Qwen 模型没有返回有效回答")
return answer.strip()
def _resolve_reply(
db: Session,
avatar: Avatar,
question: str,
history: list[Any],
*,
qa_pairs: list[Any] | None = None,
search_fn: Callable[..., list[dict]] | None = None,
model_client: Callable[..., str] | None = None,
) -> dict:
if qa_pairs is None:
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
if matched:
return {"answer": matched.answer, "source": "qa", "references": []}
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
hits = search_fn(question, avatar.id)
messages = _build_prompt(avatar, history, question, hits)
config = _config(avatar)
temperature = 0.2 + config["creativity"] / 100 * 0.6
model_client = model_client or _call_qwen
answer = model_client(messages=messages, temperature=temperature)
return {
"answer": answer,
"source": "knowledge" if hits else "qwen",
"references": hits,
}
@router.post("/avatar/{avatar_id}/chat")
def chat(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
avatar = _require_owned_avatar(db, avatar_id, authorization)
try:
return ok(_resolve_reply(db, avatar, body.message, body.history))
except RuntimeError as exc:
return fail(str(exc), code=502)