Files
ai-contract-platform/backend/app/services/chat_service.py
T
2026-04-24 13:44:17 +08:00

259 lines
10 KiB
Python

import json
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.conversation import Conversation
from app.models.message import Message
from app.repositories.conversation_repository import ConversationRepository
from app.repositories.message_repository import MessageRepository
from app.services.rag_service import RAGService
DEFAULT_CHUNKS = [
"已收到你的问题,我会按合同审查视角给出辅助判断。",
"当前版本先返回结构化回答和引用来源,建议你继续结合具体条款文本进行人工复核。",
]
DEFAULT_SOURCES = [
{"title": "平台提示", "content": "AI 审查结果仅供参考,请结合正式法律意见审慎决策。"}
]
class ChatService:
def __init__(self, db: Session):
self.db = db
self.conversation_repository = ConversationRepository(db)
self.message_repository = MessageRepository(db)
self.rag_service = RAGService(db)
def stream_answer(
self,
current_user: Dict[str, Any],
conversation_id: Optional[str],
message: str,
review_context: Optional[Dict[str, Any]] = None,
) -> Iterable[str]:
normalized_message = message.strip()
if not normalized_message:
yield self._format_event({"type": "error", "message": "message 不能为空"})
return
active_conversation = self._ensure_conversation(current_user, conversation_id, normalized_message, review_context)
payload = self._build_payload(current_user, normalized_message, review_context)
assistant_content = "\n\n".join(payload["chunks"])
self._create_message(
tenant_id=current_user["tenant_id"],
conversation_id=active_conversation.id,
user_id=current_user["id"],
role="user",
content=normalized_message,
sources_json=None,
)
for chunk in payload["chunks"]:
yield self._format_event({"type": "text", "content": chunk})
self._create_message(
tenant_id=current_user["tenant_id"],
conversation_id=active_conversation.id,
user_id=None,
role="assistant",
content=assistant_content,
sources_json=payload["sources"],
)
self._refresh_conversation(active_conversation, normalized_message, review_context)
yield self._format_event({"type": "sources", "sources": payload["sources"]})
yield self._format_event({"type": "end", "conversation_id": active_conversation.id})
def list_conversations(self, current_user: Dict[str, Any], page: int = 1, page_size: int = 20) -> dict:
normalized_page = max(page, 1)
normalized_page_size = max(min(page_size, 100), 1)
conversations = self.conversation_repository.list_paginated(
current_user["tenant_id"],
current_user["id"],
normalized_page,
normalized_page_size,
)
total = self.conversation_repository.count(current_user["tenant_id"], current_user["id"])
return {
"items": [self._to_conversation_list_item(item) for item in conversations],
"page": normalized_page,
"page_size": normalized_page_size,
"total": total,
}
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
conversation = self.conversation_repository.get_by_id_for_user(
conversation_id,
current_user["tenant_id"],
current_user["id"],
)
if conversation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="conversation not found")
messages = self.message_repository.list_by_conversation(conversation.id)
return {
"id": conversation.id,
"title": conversation.title,
"messages": [self._to_conversation_message_item(item) for item in messages],
}
def _ensure_conversation(
self,
current_user: Dict[str, Any],
conversation_id: Optional[str],
message: str,
review_context: Optional[Dict[str, Any]] = None,
) -> Conversation:
if conversation_id:
conversation = self.conversation_repository.get_by_id_for_user(
conversation_id,
current_user["tenant_id"],
current_user["id"],
)
if conversation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="conversation not found")
return conversation
now = datetime.now(timezone.utc)
conversation = Conversation(
id=f"conv-{uuid4().hex[:12]}",
tenant_id=current_user["tenant_id"],
user_id=current_user["id"],
title=self._build_title(message, review_context),
message_count=0,
last_message_at=now,
created_at=now,
updated_at=now,
)
return self.conversation_repository.create(conversation)
def _refresh_conversation(self, conversation: Conversation, message: str, review_context: Optional[Dict[str, Any]] = None) -> Conversation:
now = datetime.now(timezone.utc)
conversation.message_count += 2
conversation.last_message_at = now
conversation.updated_at = now
if not conversation.title:
conversation.title = self._build_title(message, review_context)
return self.conversation_repository.update(conversation)
def _create_message(
self,
tenant_id: str,
conversation_id: str,
user_id: Optional[str],
role: str,
content: str,
sources_json: Optional[List[Dict[str, str]]],
) -> Message:
created_at = datetime.now(timezone.utc)
message = Message(
id=f"msg-{uuid4().hex[:12]}",
tenant_id=tenant_id,
conversation_id=conversation_id,
user_id=user_id,
role=role,
content=content,
sources_json=sources_json,
created_at=created_at,
)
return self.message_repository.create(message)
def _build_title(self, message: str, review_context: Optional[Dict[str, Any]] = None) -> str:
if review_context:
metadata = review_context.get("metadata") or {}
focus_label = metadata.get("focusLabel")
review_title = metadata.get("reviewTitle")
if focus_label and review_title:
return f"{review_title} · {focus_label}"[:30]
if review_title:
return review_title[:30]
normalized = " ".join(message.split())
return normalized[:30] or "法律问答"
def _build_payload(
self,
current_user: Dict[str, Any],
message: str,
review_context: Optional[Dict[str, Any]] = None,
) -> Dict[str, List[Dict[str, str]]]:
effective_query = self._build_effective_query(message, review_context)
search_results = self.rag_service.search_relevant_documents(
tenant_id=current_user["tenant_id"],
query=effective_query,
limit=5,
)
if search_results:
return self.rag_service.build_chat_payload(
effective_query,
search_results,
review_context=review_context,
)
return {
"chunks": self._build_default_chunks(message, review_context),
"sources": DEFAULT_SOURCES.copy(),
}
def _build_effective_query(self, message: str, review_context: Optional[Dict[str, Any]] = None) -> str:
if not review_context:
return message
metadata = review_context.get("metadata") or {}
context_parts = [
metadata.get("reviewTitle"),
metadata.get("contractType"),
metadata.get("overallRisk"),
metadata.get("reviewOpinion"),
metadata.get("focusLabel"),
metadata.get("actionLabel"),
message,
]
return "\n".join([str(item).strip() for item in context_parts if item and str(item).strip()])
def _build_default_chunks(self, message: str, review_context: Optional[Dict[str, Any]] = None) -> List[str]:
if not review_context:
return DEFAULT_CHUNKS.copy()
metadata = review_context.get("metadata") or {}
focus_label = metadata.get("focusLabel")
action_label = metadata.get("actionLabel")
review_title = metadata.get("reviewTitle")
chunks = [
f"已收到围绕{review_title or '当前合同'}的追问,我会继续按合同审查视角给出辅助判断。",
f"当前问题:{message}",
]
if focus_label or action_label:
chunks.append(
"当前上下文:" + " / ".join(
[item for item in [f"追问对象:{focus_label}" if focus_label else None, f"当前动作:{action_label}" if action_label else None] if item]
)
)
chunks.append("当前知识库未命中直接材料,以下建议将优先基于已注入的审查上下文生成,并建议继续结合具体条款人工复核。")
return chunks
def _to_conversation_list_item(self, conversation: Conversation) -> dict:
return {
"id": conversation.id,
"title": conversation.title,
"message_count": conversation.message_count,
"last_message_at": conversation.last_message_at.isoformat() if conversation.last_message_at else None,
}
def _to_conversation_message_item(self, message: Message) -> dict:
return {
"id": message.id,
"role": message.role,
"content": message.content,
"sources": message.sources_json or [],
"created_at": message.created_at.isoformat(),
}
def _format_event(self, payload: Dict[str, object]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"