833 lines
34 KiB
Python
833 lines
34 KiB
Python
import inspect
|
|
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 app.repositories.conversation_repository import ConversationRepository
|
|
from app.repositories.message_repository import MessageRepository
|
|
from app.services.contract_content_normalizer import normalize_contract_content
|
|
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 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):
|
|
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.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:
|
|
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:
|
|
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 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 _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 build_shared_ai_platform_client(db: Session) -> SharedAIPlatformClient:
|
|
base_url = settings.shared_ai_platform_base_url.strip()
|
|
api_key = settings.shared_ai_platform_api_key.strip()
|
|
missing_parts = []
|
|
if not base_url:
|
|
missing_parts.append("SHARED_AI_PLATFORM_BASE_URL")
|
|
if not api_key:
|
|
missing_parts.append("SHARED_AI_PLATFORM_API_KEY")
|
|
if missing_parts:
|
|
return UnavailableSharedAIPlatformClient(f"缺少 {', '.join(missing_parts)}", db=db)
|
|
|
|
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,
|
|
db=db,
|
|
)
|