4a93bd1c44
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
10 KiB
Python
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"
|