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)