Files
ai-contract-platform/backend/app/clients/shared_ai_platform.py
T
2026-05-03 19:09:26 +08:00

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)