Files
ai-contract-platform/backend/app/clients/shared_ai_platform.py
T

448 lines
18 KiB
Python

import json
from typing import Any, Dict, Iterable, Mapping, Optional, Protocol
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db import get_db_session
from shared_ai_platform_sdk import RequestContext as SharedAIRequestContext
from shared_ai_platform_sdk import SharedAIPlatformClient as SharedAIPlatformSdkClient
from shared_ai_platform_sdk import SharedAIPlatformError
REMOTE_CHAT_TEMPLATE_KEYS = {
"general-assistant.chat",
"internal-knowledge.chat",
"site-chat.customer-service",
}
class SharedAIPlatformClient(Protocol):
def stream_answer(
self,
current_user: Dict[str, Any],
conversation_id: Optional[str],
message: str,
review_context: Optional[Any] = None,
template_key: Optional[str] = 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_knowledge_base_versions(self, current_user: Dict[str, Any], knowledge_base_id: str) -> list[dict]: ...
def publish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict: ...
def unpublish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict: ...
def rollback_knowledge_base(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
target_version_label: str,
change_summary: Optional[str] = None,
) -> 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: ...
def enhance_contract_generation(
self,
current_user: Dict[str, Any],
*,
prompt_runtime: Optional[Dict[str, Any]],
prompt: str,
draft_content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> dict: ...
class RemoteSharedAIPlatformClient:
def __init__(self, base_url: str, app_id: str, api_key: str, timeout_seconds: float):
self.base_url = base_url.rstrip("/")
self.app_id = app_id
self.api_key = api_key
self.timeout_seconds = timeout_seconds
self.sdk = SharedAIPlatformSdkClient(
base_url=self.base_url,
api_key=self.api_key,
app_id=self.app_id,
timeout_seconds=self.timeout_seconds,
api_prefix=settings.shared_ai_platform_api_prefix,
)
def _context(self, current_user: Dict[str, Any]) -> SharedAIRequestContext:
return SharedAIRequestContext(
tenant_id=current_user.get("tenant_id", ""),
user_id=current_user.get("id", ""),
user_role=current_user.get("role", "admin"),
)
def stream_answer(
self,
current_user: Dict[str, Any],
conversation_id: Optional[str],
message: str,
review_context: Optional[Any] = None,
template_key: Optional[str] = None,
) -> Iterable[str]:
review_context_payload = self._normalize_review_context(review_context)
remote_template_key = self._resolve_remote_chat_template_key(template_key)
def _event_stream():
attempt_template_keys = [remote_template_key]
if remote_template_key:
# Some shared-ai deployments only have generic chat templates.
# If the template-key-specific stream dies before sending any
# payload, retry once without the explicit template key.
attempt_template_keys.append(None)
for attempt_index, active_template_key in enumerate(attempt_template_keys):
emitted_payload = False
try:
for event in self.sdk.stream_conversation(
self._context(current_user),
message=message,
chat_context=review_context_payload or {},
conversation_id=conversation_id,
template_key=active_template_key,
):
if event.event_type in {"delta", "text"}:
emitted_payload = True
yield self._format_event({"type": "text", "content": event.payload.get("content", "")})
elif event.event_type == "sources":
emitted_payload = True
yield self._format_event({"type": "sources", "sources": event.payload.get("sources", [])})
elif event.event_type == "end":
emitted_payload = True
yield self._format_event(
{
"type": "end",
"conversation_id": event.payload.get("conversation_id"),
}
)
elif event.event_type == "error":
if attempt_index == 0 and template_key and not emitted_payload:
break
yield self._format_event(
{
"type": "error",
"message": event.payload.get("message", "shared ai platform request failed"),
}
)
return
elif event.event_type in {"start", "usage"}:
continue
else:
return
except SharedAIPlatformError as exc:
if attempt_index == 0 and remote_template_key and not emitted_payload:
continue
yield self._format_event({"type": "error", "message": self._build_stream_error_message(exc)})
return
except Exception as exc: # pragma: no cover - protects remote stream regressions
if attempt_index == 0 and remote_template_key and not emitted_payload:
continue
yield self._format_event({"type": "error", "message": self._build_stream_error_message(exc)})
return
yield self._format_event(
{
"type": "error",
"message": "共享 AI 问答暂时不可用,请稍后重试。",
}
)
return _event_stream()
@staticmethod
def _build_stream_error_message(exc: Exception) -> str:
detail = str(exc).strip()
if detail:
return f"共享 AI 问答暂时不可用,请稍后重试。{detail}"
return "共享 AI 问答暂时不可用,请稍后重试。"
@staticmethod
def _resolve_remote_chat_template_key(template_key: Optional[str]) -> Optional[str]:
normalized = str(template_key or "").strip()
if normalized in REMOTE_CHAT_TEMPLATE_KEYS:
return normalized
return None
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
payload = self.sdk.list_knowledge_bases(self._context(current_user), page=page, page_size=page_size)
return self._apply_remote_knowledge_defaults(payload)
def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
return self.sdk.list_conversations(self._context(current_user), page=page, page_size=page_size)
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
return self.sdk.get_conversation_detail(self._context(current_user), conversation_id)
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
return self.sdk.run_legal_review(
self._context(current_user),
title=payload.get("title") or "文本输入合同",
text=payload.get("text") or "",
contract_type=payload.get("contract_type"),
review_as=payload.get("review_as") or "party_a",
provider=payload.get("provider"),
)
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict:
payload = self.sdk.create_knowledge_base(
self._context(current_user),
name=name,
description=description,
retrieval_mode="hybrid",
)
if "retrieval_mode" not in payload:
payload["retrieval_mode"] = "hybrid"
return payload
def list_knowledge_base_versions(self, current_user: Dict[str, Any], knowledge_base_id: str) -> list[dict]:
return self.sdk.list_knowledge_base_versions(self._context(current_user), knowledge_base_id)
def publish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
payload = self.sdk.publish_knowledge_base(self._context(current_user), knowledge_base_id)
if "retrieval_mode" not in payload:
payload["retrieval_mode"] = "hybrid"
return payload
def unpublish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
payload = self.sdk.unpublish_knowledge_base(self._context(current_user), knowledge_base_id)
if "retrieval_mode" not in payload:
payload["retrieval_mode"] = "hybrid"
return payload
def rollback_knowledge_base(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
target_version_label: str,
change_summary: Optional[str] = None,
) -> dict:
payload = self.sdk.rollback_knowledge_base(
self._context(current_user),
knowledge_base_id,
target_version_label=target_version_label,
change_summary=change_summary,
)
if "retrieval_mode" not in payload:
payload["retrieval_mode"] = "hybrid"
return payload
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict:
payload = self.sdk.list_documents(self._context(current_user), knowledge_base_id, page=page, page_size=page_size)
return self._normalize_remote_knowledge_documents_payload(payload)
def upload_document(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
file_name: str,
mime_type: Optional[str],
content: bytes,
) -> dict:
payload = self.sdk.upload_document(
self._context(current_user),
knowledge_base_id,
file_name=file_name,
mime_type=mime_type,
content=content,
)
return self._normalize_remote_knowledge_document_item(payload)
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:
payload = self.sdk.retry_document(
self._context(current_user),
knowledge_base_id,
document_id,
file_name=file_name,
mime_type=mime_type,
content=content,
)
return self._normalize_remote_knowledge_document_item(payload)
def enhance_contract_generation(
self,
current_user: Dict[str, Any],
*,
prompt_runtime: Optional[Dict[str, Any]],
prompt: str,
draft_content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> dict:
template_key = str((prompt_runtime or {}).get("template_key") or "").strip() or None
payload = dict(metadata or {})
payload["draft_content"] = draft_content
payload["prompt_version"] = (prompt_runtime or {}).get("prompt_version")
payload["prompt_key"] = (prompt_runtime or {}).get("prompt_key")
payload["prompt_type"] = (prompt_runtime or {}).get("prompt_type")
stream = self.stream_answer(
current_user,
None,
prompt,
review_context=payload,
template_key=template_key,
)
text_parts: list[str] = []
for chunk in stream:
if not isinstance(chunk, str):
continue
for line in chunk.splitlines():
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if not raw:
continue
try:
event = json.loads(raw)
except json.JSONDecodeError:
continue
if not isinstance(event, Mapping):
continue
if event.get("type") == "text":
text_parts.append(str(event.get("content") or ""))
elif event.get("type") == "error":
raise RuntimeError(str(event.get("message") or "shared ai enhancement failed"))
content = "".join(text_parts).strip()
if not content:
raise RuntimeError("shared ai enhancement returned empty content")
return {
"content": content,
"provider": "shared-ai-platform",
"model": (prompt_runtime or {}).get("model"),
"prompt_key": (prompt_runtime or {}).get("prompt_key"),
"prompt_version": (prompt_runtime or {}).get("prompt_version"),
}
def _format_event(self, payload: Dict[str, object]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _apply_remote_knowledge_defaults(self, payload: dict) -> dict:
items = payload.get("items")
if not isinstance(items, list):
return payload
for item in items:
if isinstance(item, dict) and not item.get("retrieval_mode"):
item["retrieval_mode"] = "hybrid"
items.sort(key=self._knowledge_base_sort_key, reverse=True)
return payload
@staticmethod
def _knowledge_base_sort_key(item: Any) -> str:
if not isinstance(item, Mapping):
return ""
return str(item.get("updated_at") or item.get("created_at") or "")
def _normalize_remote_knowledge_documents_payload(self, payload: dict) -> dict:
items = payload.get("items")
if not isinstance(items, list):
return payload
payload["items"] = [self._normalize_remote_knowledge_document_item(item) for item in items]
return payload
def _normalize_remote_knowledge_document_item(self, payload: dict) -> dict:
metadata = payload.get("metadata_json")
if not isinstance(metadata, Mapping):
metadata = payload.get("metadata")
metadata = metadata if isinstance(metadata, Mapping) else {}
normalized = dict(payload)
source_type = normalized.get("source_type") or metadata.get("source_type")
authority = normalized.get("authority") or metadata.get("authority") or metadata.get("issuer")
effective_date = normalized.get("effective_date") or metadata.get("effective_date")
region = normalized.get("region") or metadata.get("region") or metadata.get("jurisdiction")
tags = normalized.get("tags")
if tags is None:
tags = normalized.get("tags_json")
if tags is None:
tags = metadata.get("tags")
normalized["source_type"] = source_type
normalized["authority"] = authority
normalized["effective_date"] = effective_date
normalized["region"] = region
normalized["tags"] = list(tags) if isinstance(tags, list) else None
if normalized["tags"] is not None and normalized.get("tags_json") is None:
normalized["tags_json"] = normalized["tags"]
if metadata and normalized.get("metadata_json") is None:
normalized["metadata_json"] = dict(metadata)
return normalized
def _normalize_review_context(self, review_context: Any) -> Optional[Dict[str, Any]]:
if review_context is None:
return None
if isinstance(review_context, Mapping):
return dict(review_context)
if hasattr(review_context, "model_dump"):
return review_context.model_dump(mode="json", by_alias=True, exclude_none=True)
return review_context
def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> SharedAIPlatformClient:
return build_shared_ai_platform_client(db)
def build_shared_ai_platform_client(db: Session) -> SharedAIPlatformClient:
base_url = settings.shared_ai_platform_base_url.strip()
if not base_url:
raise HTTPException(
status_code=503,
detail="shared-ai-platform base URL is required for AI features but is not configured",
)
api_key = settings.shared_ai_platform_api_key.strip()
if not api_key:
raise HTTPException(
status_code=503,
detail="shared-ai-platform API key is required for AI features but is not configured",
)
return RemoteSharedAIPlatformClient(
base_url=base_url,
app_id=settings.shared_ai_platform_app_id,
api_key=api_key,
timeout_seconds=settings.shared_ai_platform_timeout_seconds,
)