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

1106 lines
46 KiB
Python

import inspect
import json
from dataclasses import dataclass
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 app.repositories.conversation_repository import ConversationRepository
from app.repositories.message_repository import MessageRepository
from app.services.contract_content_normalizer import normalize_contract_content
from app.services.model_setting_service import ModelSettingService
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",
}
PLACEHOLDER_SHARED_AI_CONFIG_VALUES = {
"change-me",
"change_me",
"changeme",
"replace-me",
"replace_me",
"todo",
"tbd",
"change_me_shared_ai_api_key",
}
@dataclass(frozen=True)
class SharedAIPlatformRuntimeConfig:
base_url: str
api_key: str
app_id: str
api_prefix: str
timeout_seconds: float
source: str
provider: Optional[str] = None
model: Optional[str] = None
review_model: Optional[str] = None
legal_review_enabled: bool = True
legal_review_fallback_to_local: bool = False
document_extraction_enabled: bool = True
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 extract_document_text(self, current_user: Dict[str, Any], *, file_name: str, mime_type: Optional[str], content: bytes) -> dict: ...
def classify_pdf_source(self, current_user: Dict[str, Any], *, content: bytes) -> 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,
db: Optional[Session] = None,
api_prefix: Optional[str] = None,
runtime_config: Optional[SharedAIPlatformRuntimeConfig] = None,
):
self.base_url = base_url.rstrip("/")
self.app_id = app_id
self.api_key = api_key
self.timeout_seconds = timeout_seconds
self.db = db
self.runtime_config = runtime_config
self.api_prefix = (api_prefix or settings.shared_ai_platform_api_prefix).strip() or "/api/v0.2"
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=self.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)
review_context_payload = self._merge_runtime_model_context(review_context_payload)
remote_template_key = self._resolve_remote_chat_template_key(template_key)
provider = self._resolve_provider(review_context_payload)
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 {},
provider=provider,
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:
context = self._context(current_user)
method = self.sdk.list_knowledge_bases
normalized_page = max(int(page or 1), 1)
normalized_page_size = max(min(int(page_size or 20), 100), 1)
try:
signature = inspect.signature(method)
accepts_pagination = (
"page" in signature.parameters
or "page_size" in signature.parameters
or any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
)
except (TypeError, ValueError):
accepts_pagination = True
if accepts_pagination:
try:
payload = method(context, page=normalized_page, page_size=normalized_page_size)
except TypeError as exc:
if "unexpected keyword argument" not in str(exc):
raise
payload = method(context)
else:
payload = method(context)
return self._paginate_remote_knowledge_bases_payload(
self._apply_remote_knowledge_defaults(payload),
normalized_page,
normalized_page_size,
)
def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
try:
return self.sdk.list_conversations(self._context(current_user), page=page, page_size=page_size)
except Exception as exc:
return self._fallback_list_conversations(current_user, page, page_size, exc)
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
try:
return self.sdk.get_conversation_detail(self._context(current_user), conversation_id)
except Exception as exc:
return self._fallback_get_conversation_detail(current_user, conversation_id, exc)
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
provider = payload.get("provider") or (self.runtime_config.provider if self.runtime_config else None)
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=provider,
)
def extract_document_text(self, current_user: Dict[str, Any], *, file_name: str, mime_type: Optional[str], content: bytes) -> dict:
return self.sdk.extract_document_text(
self._context(current_user),
file_name=file_name,
mime_type=mime_type,
content=content,
)
def classify_pdf_source(self, current_user: Dict[str, Any], *, content: bytes) -> dict:
return self.sdk.classify_pdf_source(
self._context(current_user),
content=content,
)
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]:
try:
return self.sdk.list_knowledge_base_versions(self._context(current_user), knowledge_base_id)
except SharedAIPlatformError as exc:
if exc.status_code in (401, 403):
raise
return self._fallback_knowledge_base_versions(current_user, knowledge_base_id, exc)
except Exception as exc:
return self._fallback_knowledge_base_versions(current_user, knowledge_base_id, exc)
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"))
raw_content = "".join(text_parts).strip()
if not raw_content:
raise RuntimeError("shared ai enhancement returned empty content")
structured_output = self._extract_structured_output(raw_content)
content = self._extract_enhanced_contract_content(structured_output, raw_content)
return {
"content": content,
"raw_content": raw_content,
"structured_output": structured_output,
"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"),
}
@staticmethod
def _extract_enhanced_contract_content(structured_output: Optional[Dict[str, Any]], raw_content: str) -> str:
if not isinstance(structured_output, Mapping):
return normalize_contract_content(raw_content)
for key in ("html", "content", "text", "body", "markdown", "document"):
value = structured_output.get(key)
if isinstance(value, str) and value.strip():
return normalize_contract_content(value)
return normalize_contract_content(raw_content)
@staticmethod
def _extract_structured_output(raw_content: str) -> Optional[Dict[str, Any]]:
cleaned = str(raw_content or "").strip()
if not cleaned:
return None
if cleaned.startswith("```"):
cleaned = cleaned.removeprefix("```json").removeprefix("```").strip()
if cleaned.endswith("```"):
cleaned = cleaned[:-3].strip()
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
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
def _paginate_remote_knowledge_bases_payload(self, payload: dict, page: int, page_size: int) -> dict:
items = payload.get("items") if isinstance(payload, Mapping) else None
if not isinstance(items, list):
return payload
normalized = dict(payload)
total = int(normalized.get("total") or len(items))
source_page_size = int(normalized.get("page_size") or len(items) or page_size)
source_page = int(normalized.get("page") or 1)
if source_page == page and source_page_size == page_size:
normalized["total"] = total
normalized["page"] = page
normalized["page_size"] = page_size
return normalized
if total == len(items):
start = (page - 1) * page_size
normalized["items"] = items[start : start + page_size]
normalized["total"] = total
normalized["page"] = page
normalized["page_size"] = page_size
return normalized
@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 _fallback_knowledge_base_versions(self, current_user: Dict[str, Any], knowledge_base_id: str, exc: Exception) -> list[dict]:
try:
payload = self.list_knowledge_bases(current_user, 1, 100)
except Exception as list_exc:
raise HTTPException(status_code=503, detail=self._build_knowledge_base_versions_error_message(exc)) from list_exc
items = payload.get("items") if isinstance(payload, Mapping) else []
knowledge_base = next((item for item in items if isinstance(item, Mapping) and item.get("id") == knowledge_base_id), None)
if not knowledge_base:
raise HTTPException(status_code=404, detail="知识库不存在或无权访问")
version_label = str(knowledge_base.get("current_version_label") or "current")
timestamp = knowledge_base.get("published_at") or knowledge_base.get("updated_at") or knowledge_base.get("created_at")
return [
{
"id": f"{knowledge_base_id}-{version_label}",
"knowledge_base_id": knowledge_base_id,
"version_label": version_label,
"name": knowledge_base.get("name") or "",
"description": knowledge_base.get("description"),
"retrieval_mode": knowledge_base.get("retrieval_mode") or "hybrid",
"status": knowledge_base.get("status") or "active",
"published_at": knowledge_base.get("published_at"),
"published_by": knowledge_base.get("published_by"),
"change_summary": self._build_knowledge_base_versions_error_message(exc),
"created_at": timestamp,
}
]
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 _merge_runtime_model_context(self, review_context: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if self.runtime_config is None:
return review_context
enriched = dict(review_context or {})
runtime_payload = dict(enriched.get("modelRuntime") or enriched.get("model_runtime") or {})
runtime_payload.setdefault("source", self.runtime_config.source)
if self.runtime_config.provider:
runtime_payload.setdefault("provider", self.runtime_config.provider)
if self.runtime_config.model:
runtime_payload.setdefault("model", self.runtime_config.model)
if self.runtime_config.review_model:
runtime_payload.setdefault("review_model", self.runtime_config.review_model)
enriched["modelRuntime"] = runtime_payload
enriched["model_runtime"] = runtime_payload
metadata = dict(enriched.get("metadata") or {})
metadata.setdefault("modelRuntime", runtime_payload)
enriched["metadata"] = metadata
return enriched
def _resolve_provider(self, review_context: Optional[Dict[str, Any]]) -> Optional[str]:
if isinstance(review_context, Mapping):
runtime_payload = review_context.get("modelRuntime") or review_context.get("model_runtime")
if isinstance(runtime_payload, Mapping) and runtime_payload.get("provider"):
return str(runtime_payload.get("provider"))
metadata = review_context.get("metadata")
if isinstance(metadata, Mapping):
metadata_runtime = metadata.get("modelRuntime") or metadata.get("model_runtime")
if isinstance(metadata_runtime, Mapping) and metadata_runtime.get("provider"):
return str(metadata_runtime.get("provider"))
return self.runtime_config.provider if self.runtime_config else None
def _fallback_list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int, exc: Exception) -> dict:
if self.db is None:
raise HTTPException(status_code=503, detail=self._build_history_error_message(exc)) from exc
normalized_page = max(int(page or 1), 1)
normalized_page_size = max(min(int(page_size or 20), 100), 1)
repository = ConversationRepository(self.db)
tenant_id = str(current_user.get("tenant_id") or "")
user_id = str(current_user.get("id") or "")
items = repository.list_paginated(tenant_id, user_id, normalized_page, normalized_page_size)
return {
"items": [
{
"id": item.id,
"title": item.title,
"message_count": item.message_count,
"last_message_at": item.last_message_at.isoformat() if item.last_message_at else None,
}
for item in items
],
"page": normalized_page,
"page_size": normalized_page_size,
"total": repository.count(tenant_id, user_id),
"source": "local_fallback",
"warning": self._build_history_error_message(exc),
}
def _fallback_get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str, exc: Exception) -> dict:
if self.db is None:
raise HTTPException(status_code=503, detail=self._build_history_error_message(exc)) from exc
tenant_id = str(current_user.get("tenant_id") or "")
user_id = str(current_user.get("id") or "")
conversation = ConversationRepository(self.db).get_by_id_for_user(conversation_id, tenant_id, user_id)
if conversation is None:
raise HTTPException(status_code=503, detail=self._build_history_error_message(exc)) from exc
messages = MessageRepository(self.db).list_by_conversation(conversation.id)
return {
"id": conversation.id,
"title": conversation.title,
"messages": [
{
"id": message.id,
"role": message.role,
"content": message.content,
"sources": message.sources_json or [],
"created_at": message.created_at.isoformat(),
}
for message in messages
if message.tenant_id == tenant_id
],
"source": "local_fallback",
"warning": self._build_history_error_message(exc),
}
@staticmethod
def _build_history_error_message(exc: Exception) -> str:
detail = str(exc).strip()
if detail:
return f"共享 AI 会话历史暂时不可用,已切换为本地历史记录。原因:{detail[:200]}"
return "共享 AI 会话历史暂时不可用,已切换为本地历史记录。"
@staticmethod
def _build_knowledge_base_versions_error_message(exc: Exception) -> str:
detail = str(exc).strip()
if detail:
return f"共享 AI 版本历史暂时不可用,已展示当前知识库快照。原因:{detail[:200]}"
return "共享 AI 版本历史暂时不可用,已展示当前知识库快照。"
class UnavailableSharedAIPlatformClient:
def __init__(self, reason: str, db: Optional[Session] = None):
self.reason = reason
self.db = db
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]:
yield self._format_event(
{
"type": "error",
"message": f"模型服务未配置或暂不可用:{self.reason}。请管理员到“大模型设置”核对参数,并完成后端/共享 AI 服务配置后重试。",
}
)
def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
if self.db is None:
return self._empty_conversations(page, page_size)
normalized_page = max(int(page or 1), 1)
normalized_page_size = max(min(int(page_size or 20), 100), 1)
repository = ConversationRepository(self.db)
tenant_id = str(current_user.get("tenant_id") or "")
user_id = str(current_user.get("id") or "")
items = repository.list_paginated(tenant_id, user_id, normalized_page, normalized_page_size)
return {
"items": [
{
"id": item.id,
"title": item.title,
"message_count": item.message_count,
"last_message_at": item.last_message_at.isoformat() if item.last_message_at else None,
}
for item in items
],
"page": normalized_page,
"page_size": normalized_page_size,
"total": repository.count(tenant_id, user_id),
"source": "local_fallback",
"warning": self._history_warning(),
}
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
if self.db is None:
raise HTTPException(status_code=503, detail=self._history_warning())
tenant_id = str(current_user.get("tenant_id") or "")
user_id = str(current_user.get("id") or "")
conversation = ConversationRepository(self.db).get_by_id_for_user(conversation_id, tenant_id, user_id)
if conversation is None:
raise HTTPException(status_code=404, detail="conversation not found")
messages = MessageRepository(self.db).list_by_conversation(conversation.id)
return {
"id": conversation.id,
"title": conversation.title,
"messages": [
{
"id": message.id,
"role": message.role,
"content": message.content,
"sources": message.sources_json or [],
"created_at": message.created_at.isoformat(),
}
for message in messages
if message.tenant_id == tenant_id
],
"source": "local_fallback",
"warning": self._history_warning(),
}
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
return {
"items": [],
"page": max(int(page or 1), 1),
"page_size": max(min(int(page_size or 20), 100), 1),
"total": 0,
"warning": self._unavailable_message(),
}
def list_knowledge_base_versions(self, current_user: Dict[str, Any], knowledge_base_id: str) -> list[dict]:
return []
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict:
return {
"items": [],
"page": max(int(page or 1), 1),
"page_size": max(min(int(page_size or 20), 100), 1),
"total": 0,
"warning": self._unavailable_message(),
}
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict:
self._raise_unavailable()
def publish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
self._raise_unavailable()
def unpublish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
self._raise_unavailable()
def rollback_knowledge_base(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
target_version_label: str,
change_summary: Optional[str] = None,
) -> dict:
self._raise_unavailable()
def upload_document(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
file_name: str,
mime_type: Optional[str],
content: bytes,
) -> dict:
self._raise_unavailable()
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:
self._raise_unavailable()
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
self._raise_unavailable()
def extract_document_text(self, current_user: Dict[str, Any], *, file_name: str, mime_type: Optional[str], content: bytes) -> dict:
self._raise_unavailable()
def classify_pdf_source(self, current_user: Dict[str, Any], *, content: bytes) -> dict:
self._raise_unavailable()
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:
self._raise_unavailable()
def _empty_conversations(self, page: int, page_size: int) -> dict:
return {
"items": [],
"page": max(int(page or 1), 1),
"page_size": max(min(int(page_size or 20), 100), 1),
"total": 0,
"source": "local_fallback",
"warning": self._history_warning(),
}
def _history_warning(self) -> str:
return f"共享 AI 会话历史暂不可用,已切换为本地历史记录。原因:{self.reason}"
def _unavailable_message(self) -> str:
return f"共享 AI 服务未配置或暂不可用:{self.reason}"
def _raise_unavailable(self):
raise HTTPException(status_code=503, detail=self._unavailable_message())
@staticmethod
def _format_event(payload: Dict[str, object]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> SharedAIPlatformClient:
return build_shared_ai_platform_client(db)
def resolve_shared_ai_platform_runtime_config(
db: Session,
tenant_id: Optional[str] = None,
) -> Optional[SharedAIPlatformRuntimeConfig]:
if tenant_id:
try:
db_config = ModelSettingService(db).get_shared_ai_platform_runtime_config(str(tenant_id))
except Exception:
db_config = {}
if db_config.get("configured"):
return SharedAIPlatformRuntimeConfig(
base_url=str(db_config.get("base_url") or "").strip(),
api_key=str(db_config.get("api_key") or "").strip(),
app_id=str(db_config.get("app_id") or settings.shared_ai_platform_app_id),
api_prefix=str(db_config.get("api_prefix") or settings.shared_ai_platform_api_prefix),
timeout_seconds=float(db_config.get("timeout_seconds") or settings.shared_ai_platform_timeout_seconds),
source=str(db_config.get("source") or "model_settings"),
provider=str(db_config.get("provider") or "") or None,
model=str(db_config.get("model") or "") or None,
review_model=str(db_config.get("review_model") or "") or None,
legal_review_enabled=bool(db_config.get("legal_review_enabled")),
legal_review_fallback_to_local=bool(db_config.get("legal_review_fallback_to_local")),
document_extraction_enabled=bool(db_config.get("document_extraction_enabled")),
)
base_url = settings.shared_ai_platform_base_url.strip()
api_key = settings.shared_ai_platform_api_key.strip()
if not _is_runtime_config_value_ready(base_url) or not _is_runtime_config_value_ready(api_key):
return None
return SharedAIPlatformRuntimeConfig(
base_url=base_url,
api_key=api_key,
app_id=settings.shared_ai_platform_app_id,
api_prefix=settings.shared_ai_platform_api_prefix,
timeout_seconds=settings.shared_ai_platform_timeout_seconds,
source="env",
legal_review_enabled=settings.shared_ai_platform_legal_review_enabled,
legal_review_fallback_to_local=settings.shared_ai_platform_legal_review_fallback_to_local,
document_extraction_enabled=settings.shared_ai_platform_document_extraction_enabled,
)
def _is_runtime_config_value_ready(value: str) -> bool:
normalized = str(value or "").strip()
return bool(normalized) and normalized.lower() not in PLACEHOLDER_SHARED_AI_CONFIG_VALUES
class TenantAwareSharedAIPlatformClient:
def __init__(self, db: Session):
self.db = db
def _client_for_user(self, current_user: Dict[str, Any]) -> SharedAIPlatformClient:
tenant_id = str(current_user.get("tenant_id") or "").strip()
runtime_config = resolve_shared_ai_platform_runtime_config(self.db, tenant_id)
if runtime_config is None:
return _build_unavailable_shared_ai_platform_client(self.db)
return _build_remote_shared_ai_platform_client(self.db, runtime_config)
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]:
return self._client_for_user(current_user).stream_answer(
current_user,
conversation_id,
message,
review_context,
template_key=template_key,
)
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
return self._client_for_user(current_user).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._client_for_user(current_user).list_conversations(current_user, page, page_size)
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
return self._client_for_user(current_user).get_conversation_detail(current_user, conversation_id)
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
return self._client_for_user(current_user).run_legal_review(current_user, payload)
def extract_document_text(
self,
current_user: Dict[str, Any],
*,
file_name: str,
mime_type: Optional[str],
content: bytes,
) -> dict:
return self._client_for_user(current_user).extract_document_text(
current_user,
file_name=file_name,
mime_type=mime_type,
content=content,
)
def classify_pdf_source(self, current_user: Dict[str, Any], *, content: bytes) -> dict:
return self._client_for_user(current_user).classify_pdf_source(current_user, content=content)
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict:
return self._client_for_user(current_user).create_knowledge_base(current_user, name, description)
def list_knowledge_base_versions(self, current_user: Dict[str, Any], knowledge_base_id: str) -> list[dict]:
return self._client_for_user(current_user).list_knowledge_base_versions(current_user, knowledge_base_id)
def publish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
return self._client_for_user(current_user).publish_knowledge_base(current_user, knowledge_base_id)
def unpublish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
return self._client_for_user(current_user).unpublish_knowledge_base(current_user, knowledge_base_id)
def rollback_knowledge_base(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
target_version_label: str,
change_summary: Optional[str] = None,
) -> dict:
return self._client_for_user(current_user).rollback_knowledge_base(
current_user,
knowledge_base_id,
target_version_label,
change_summary,
)
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict:
return self._client_for_user(current_user).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._client_for_user(current_user).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._client_for_user(current_user).retry_document(
current_user,
knowledge_base_id,
document_id,
file_name,
mime_type,
content,
)
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:
return self._client_for_user(current_user).enhance_contract_generation(
current_user,
prompt_runtime=prompt_runtime,
prompt=prompt,
draft_content=draft_content,
metadata=metadata,
)
def _build_remote_shared_ai_platform_client(
db: Session,
runtime_config: SharedAIPlatformRuntimeConfig,
) -> RemoteSharedAIPlatformClient:
return RemoteSharedAIPlatformClient(
base_url=runtime_config.base_url,
app_id=runtime_config.app_id,
api_key=runtime_config.api_key,
timeout_seconds=runtime_config.timeout_seconds,
db=db,
api_prefix=runtime_config.api_prefix,
runtime_config=runtime_config,
)
def _build_unavailable_shared_ai_platform_client(db: Session) -> UnavailableSharedAIPlatformClient:
missing_parts = []
if not settings.shared_ai_platform_base_url.strip():
missing_parts.append("SHARED_AI_PLATFORM_BASE_URL")
if not settings.shared_ai_platform_api_key.strip():
missing_parts.append("SHARED_AI_PLATFORM_API_KEY")
if not missing_parts:
missing_parts.append("model_settings.shared_ai_platform")
return UnavailableSharedAIPlatformClient(f"缺少 {', '.join(missing_parts)}", db=db)
def build_shared_ai_platform_client(db: Session) -> SharedAIPlatformClient:
runtime_config = resolve_shared_ai_platform_runtime_config(db)
if runtime_config is not None and runtime_config.source == "env":
return _build_remote_shared_ai_platform_client(db, runtime_config)
return TenantAwareSharedAIPlatformClient(db)