430 lines
18 KiB
Python
430 lines
18 KiB
Python
import json
|
|
from typing import Any, Dict, Iterable, Optional, Protocol
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from app.repositories.review_rule_repository import ReviewRuleRepository
|
|
from app.services.chat_service import ChatService
|
|
from app.services.knowledge_base_service import KnowledgeBaseService
|
|
from app.services.legal_review_local_engine import LocalLegalReviewEngine
|
|
|
|
|
|
class SharedAIPlatformClient(Protocol):
|
|
def stream_answer(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
conversation_id: Optional[str],
|
|
message: str,
|
|
review_context: Optional[Dict[str, Any]] = None,
|
|
) -> Iterable[str]: ...
|
|
|
|
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: ...
|
|
|
|
def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: ...
|
|
|
|
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict: ...
|
|
|
|
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict: ...
|
|
|
|
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict: ...
|
|
|
|
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict: ...
|
|
|
|
def upload_document(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
knowledge_base_id: str,
|
|
file_name: str,
|
|
mime_type: Optional[str],
|
|
content: bytes,
|
|
) -> dict: ...
|
|
|
|
def retry_document(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
knowledge_base_id: str,
|
|
document_id: str,
|
|
file_name: Optional[str] = None,
|
|
mime_type: Optional[str] = None,
|
|
content: Optional[bytes] = None,
|
|
) -> dict: ...
|
|
|
|
|
|
class LocalSharedAIPlatformClient:
|
|
"""Compatibility transport used until the shared-ai-platform service is live."""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.chat_service = ChatService(db)
|
|
self.knowledge_base_service = KnowledgeBaseService(db)
|
|
self.legal_review_engine = LocalLegalReviewEngine(ReviewRuleRepository(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]:
|
|
return self.chat_service.stream_answer(current_user, conversation_id, message, review_context)
|
|
|
|
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
|
|
return self.knowledge_base_service.list_knowledge_bases(current_user, page, page_size)
|
|
|
|
def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
|
|
return self.chat_service.list_conversations(current_user, page, page_size)
|
|
|
|
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
|
|
return self.chat_service.get_conversation_detail(current_user, conversation_id)
|
|
|
|
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
|
|
return self.legal_review_engine.run_review(
|
|
tenant_id=current_user["tenant_id"],
|
|
title=payload.get("title") or "文本输入合同",
|
|
contract_type=payload.get("contract_type"),
|
|
review_as=payload.get("review_as") or "party_a",
|
|
text=payload.get("text") or "",
|
|
)
|
|
|
|
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict:
|
|
return self.knowledge_base_service.create_knowledge_base(current_user, name, description)
|
|
|
|
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict:
|
|
return self.knowledge_base_service.list_documents(current_user, knowledge_base_id, page, page_size)
|
|
|
|
def upload_document(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
knowledge_base_id: str,
|
|
file_name: str,
|
|
mime_type: Optional[str],
|
|
content: bytes,
|
|
) -> dict:
|
|
return self.knowledge_base_service.upload_document(current_user, knowledge_base_id, file_name, mime_type, content)
|
|
|
|
def retry_document(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
knowledge_base_id: str,
|
|
document_id: str,
|
|
file_name: Optional[str] = None,
|
|
mime_type: Optional[str] = None,
|
|
content: Optional[bytes] = None,
|
|
) -> dict:
|
|
return self.knowledge_base_service.retry_document(
|
|
current_user=current_user,
|
|
knowledge_base_id=knowledge_base_id,
|
|
document_id=document_id,
|
|
file_name=file_name,
|
|
mime_type=mime_type,
|
|
content=content,
|
|
)
|
|
|
|
|
|
class RemoteSharedAIPlatformClient:
|
|
def __init__(self, base_url: str, api_prefix: str, app_id: str, api_key: str, timeout_seconds: float):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.api_prefix = api_prefix if api_prefix.startswith("/") else f"/{api_prefix}"
|
|
self.app_id = app_id
|
|
self.api_key = api_key
|
|
self.timeout_seconds = timeout_seconds
|
|
|
|
def stream_answer(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
conversation_id: Optional[str],
|
|
message: str,
|
|
review_context: Optional[Dict[str, Any]] = None,
|
|
) -> Iterable[str]:
|
|
payload = {
|
|
"conversation_id": conversation_id,
|
|
"message": message,
|
|
"review_context": review_context,
|
|
}
|
|
|
|
def _event_stream():
|
|
with httpx.Client(base_url=self.base_url, timeout=None) as client:
|
|
with client.stream(
|
|
"POST",
|
|
f"{self.api_prefix}/conversations/stream",
|
|
headers=self._build_headers(current_user, accept="text/event-stream"),
|
|
json=payload,
|
|
) as response:
|
|
if response.status_code >= 400:
|
|
yield self._format_error_event(self._extract_error_message(response))
|
|
return
|
|
for chunk in response.iter_raw():
|
|
if chunk:
|
|
yield chunk
|
|
|
|
return _event_stream()
|
|
|
|
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
|
|
with self._client() as client:
|
|
response = client.get(
|
|
f"{self.api_prefix}/knowledge-bases",
|
|
headers=self._build_headers(current_user),
|
|
params={"page": page, "page_size": page_size},
|
|
)
|
|
return self._unwrap_response(response)
|
|
|
|
def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
|
|
with self._client() as client:
|
|
response = client.get(
|
|
f"{self.api_prefix}/conversations",
|
|
headers=self._build_headers(current_user),
|
|
params={"page": page, "page_size": page_size},
|
|
)
|
|
return self._unwrap_response(response)
|
|
|
|
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
|
|
with self._client() as client:
|
|
response = client.get(
|
|
f"{self.api_prefix}/conversations/{conversation_id}",
|
|
headers=self._build_headers(current_user),
|
|
)
|
|
return self._unwrap_response(response)
|
|
|
|
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
|
|
with self._client() as client:
|
|
response = client.post(
|
|
f"{self.api_prefix}/domain-packs/legal-review/reviews/run",
|
|
headers=self._build_headers(current_user),
|
|
json=payload,
|
|
)
|
|
return self._unwrap_response(response)
|
|
|
|
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict:
|
|
with self._client() as client:
|
|
response = client.post(
|
|
f"{self.api_prefix}/knowledge-bases",
|
|
headers=self._build_headers(current_user),
|
|
json={"name": name, "description": description},
|
|
)
|
|
return self._unwrap_response(response)
|
|
|
|
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict:
|
|
with self._client() as client:
|
|
response = client.get(
|
|
f"{self.api_prefix}/knowledge-bases/{knowledge_base_id}/documents",
|
|
headers=self._build_headers(current_user),
|
|
params={"page": page, "page_size": page_size},
|
|
)
|
|
return self._unwrap_response(response)
|
|
|
|
def upload_document(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
knowledge_base_id: str,
|
|
file_name: str,
|
|
mime_type: Optional[str],
|
|
content: bytes,
|
|
) -> dict:
|
|
files = {"file": (file_name, content, mime_type or "application/octet-stream")}
|
|
with self._client() as client:
|
|
response = client.post(
|
|
f"{self.api_prefix}/knowledge-bases/{knowledge_base_id}/documents",
|
|
headers=self._build_headers(current_user),
|
|
files=files,
|
|
)
|
|
return self._unwrap_response(response)
|
|
|
|
def retry_document(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
knowledge_base_id: str,
|
|
document_id: str,
|
|
file_name: Optional[str] = None,
|
|
mime_type: Optional[str] = None,
|
|
content: Optional[bytes] = None,
|
|
) -> dict:
|
|
request_kwargs: Dict[str, Any] = {
|
|
"headers": self._build_headers(current_user),
|
|
}
|
|
if content is not None:
|
|
request_kwargs["files"] = {
|
|
"file": (file_name or "document.txt", content, mime_type or "application/octet-stream")
|
|
}
|
|
with self._client() as client:
|
|
response = client.post(
|
|
f"{self.api_prefix}/knowledge-bases/{knowledge_base_id}/documents/{document_id}/retry",
|
|
**request_kwargs,
|
|
)
|
|
return self._unwrap_response(response)
|
|
|
|
def _client(self) -> httpx.Client:
|
|
return httpx.Client(base_url=self.base_url, timeout=self.timeout_seconds)
|
|
|
|
def _build_headers(self, current_user: Dict[str, Any], accept: str = "application/json") -> Dict[str, str]:
|
|
headers = {
|
|
"Accept": accept,
|
|
"X-Shared-AI-App-Id": self.app_id,
|
|
"X-Shared-AI-Tenant-Id": str(current_user.get("tenant_id") or ""),
|
|
"X-Shared-AI-User-Id": str(current_user.get("id") or ""),
|
|
}
|
|
role = current_user.get("role")
|
|
if role:
|
|
headers["X-Shared-AI-User-Role"] = str(role)
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
return headers
|
|
|
|
def _unwrap_response(self, response: httpx.Response) -> dict:
|
|
if response.status_code >= 400:
|
|
raise HTTPException(status_code=response.status_code, detail=self._extract_error_message(response))
|
|
|
|
try:
|
|
payload = response.json()
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=502, detail="shared ai platform returned non-json response") from exc
|
|
|
|
if isinstance(payload, dict) and "code" in payload:
|
|
if payload.get("code") != 0:
|
|
raise HTTPException(status_code=502, detail=payload.get("message") or "shared ai platform returned error")
|
|
return payload.get("data")
|
|
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=502, detail="shared ai platform returned unexpected response payload")
|
|
return payload
|
|
|
|
def _extract_error_message(self, response: httpx.Response) -> str:
|
|
try:
|
|
payload = response.json()
|
|
except ValueError:
|
|
return response.text or "shared ai platform request failed"
|
|
|
|
if isinstance(payload, dict):
|
|
return str(payload.get("message") or payload.get("detail") or "shared ai platform request failed")
|
|
return "shared ai platform request failed"
|
|
|
|
def _format_error_event(self, message: str) -> str:
|
|
return f"data: {json.dumps({'type': 'error', 'message': message}, ensure_ascii=False)}\n\n"
|
|
|
|
|
|
class HybridSharedAIPlatformClient:
|
|
"""Prefer remote shared-ai-platform, but keep core read flows available via local fallback.
|
|
|
|
The local platform remains the truth source for this repo's current closed-loop delivery.
|
|
If the remote shared-ai-platform bridge is unavailable or returns server-side errors, we
|
|
keep chat history and knowledge-base management readable instead of surfacing raw 500s.
|
|
"""
|
|
|
|
def __init__(self, remote: RemoteSharedAIPlatformClient, local: LocalSharedAIPlatformClient):
|
|
self.remote = remote
|
|
self.local = local
|
|
|
|
def stream_answer(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
conversation_id: Optional[str],
|
|
message: str,
|
|
review_context: Optional[Dict[str, Any]] = None,
|
|
) -> Iterable[str]:
|
|
def _event_stream():
|
|
try:
|
|
for chunk in self.remote.stream_answer(current_user, conversation_id, message, review_context):
|
|
yield chunk
|
|
except Exception as exc:
|
|
if not self._should_fallback(exc):
|
|
raise
|
|
for chunk in self.local.stream_answer(current_user, conversation_id, message, review_context):
|
|
yield chunk
|
|
|
|
return _event_stream()
|
|
|
|
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
|
|
return self._run_with_read_fallback(
|
|
lambda: self.remote.list_knowledge_bases(current_user, page, page_size),
|
|
lambda: self.local.list_knowledge_bases(current_user, page, page_size),
|
|
)
|
|
|
|
def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
|
|
return self._run_with_read_fallback(
|
|
lambda: self.remote.list_conversations(current_user, page, page_size),
|
|
lambda: self.local.list_conversations(current_user, page, page_size),
|
|
)
|
|
|
|
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
|
|
return self._run_with_read_fallback(
|
|
lambda: self.remote.get_conversation_detail(current_user, conversation_id),
|
|
lambda: self.local.get_conversation_detail(current_user, conversation_id),
|
|
)
|
|
|
|
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
|
|
return self.remote.run_legal_review(current_user, payload)
|
|
|
|
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict:
|
|
try:
|
|
return self.remote.create_knowledge_base(current_user, name, description)
|
|
except Exception as exc:
|
|
if not self._should_fallback(exc):
|
|
raise
|
|
return self.local.create_knowledge_base(current_user, name, description)
|
|
|
|
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict:
|
|
return self._run_with_read_fallback(
|
|
lambda: self.remote.list_documents(current_user, knowledge_base_id, page, page_size),
|
|
lambda: self.local.list_documents(current_user, knowledge_base_id, page, page_size),
|
|
)
|
|
|
|
def upload_document(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
knowledge_base_id: str,
|
|
file_name: str,
|
|
mime_type: Optional[str],
|
|
content: bytes,
|
|
) -> dict:
|
|
try:
|
|
return self.remote.upload_document(current_user, knowledge_base_id, file_name, mime_type, content)
|
|
except Exception as exc:
|
|
if not self._should_fallback(exc):
|
|
raise
|
|
return self.local.upload_document(current_user, knowledge_base_id, file_name, mime_type, content)
|
|
|
|
def retry_document(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
knowledge_base_id: str,
|
|
document_id: str,
|
|
file_name: Optional[str] = None,
|
|
mime_type: Optional[str] = None,
|
|
content: Optional[bytes] = None,
|
|
) -> dict:
|
|
try:
|
|
return self.remote.retry_document(current_user, knowledge_base_id, document_id, file_name, mime_type, content)
|
|
except Exception as exc:
|
|
if not self._should_fallback(exc):
|
|
raise
|
|
return self.local.retry_document(current_user, knowledge_base_id, document_id, file_name, mime_type, content)
|
|
|
|
def _run_with_read_fallback(self, remote_call, local_call):
|
|
try:
|
|
return remote_call()
|
|
except Exception as exc:
|
|
if not self._should_fallback(exc):
|
|
raise
|
|
return local_call()
|
|
|
|
def _should_fallback(self, error: Exception) -> bool:
|
|
if isinstance(error, HTTPException):
|
|
return error.status_code >= 500
|
|
return True
|
|
|
|
|
|
def build_shared_ai_platform_client(db: Session) -> SharedAIPlatformClient:
|
|
if settings.shared_ai_platform_base_url.strip():
|
|
remote = RemoteSharedAIPlatformClient(
|
|
base_url=settings.shared_ai_platform_base_url,
|
|
api_prefix=settings.shared_ai_platform_api_prefix,
|
|
app_id=settings.shared_ai_platform_app_id,
|
|
api_key=settings.shared_ai_platform_api_key,
|
|
timeout_seconds=settings.shared_ai_platform_timeout_seconds,
|
|
)
|
|
local = LocalSharedAIPlatformClient(db)
|
|
return HybridSharedAIPlatformClient(remote, local)
|
|
return LocalSharedAIPlatformClient(db)
|