From 278bc2841610a82850f656bfd788be136804103e Mon Sep 17 00:00:00 2001 From: yuqei Date: Sun, 21 Jun 2026 19:15:21 +0800 Subject: [PATCH] fix: fallback chat history when ai config missing --- backend/app/clients/shared_ai_platform.py | 193 +++++++++++++++++- .../tests/test_shared_ai_platform_client.py | 42 +++- 2 files changed, 224 insertions(+), 11 deletions(-) diff --git a/backend/app/clients/shared_ai_platform.py b/backend/app/clients/shared_ai_platform.py index 66a5541c..1c5aa8e5 100644 --- a/backend/app/clients/shared_ai_platform.py +++ b/backend/app/clients/shared_ai_platform.py @@ -630,6 +630,183 @@ class RemoteSharedAIPlatformClient: +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) @@ -637,18 +814,14 @@ def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> Shar 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() + missing_parts = [] + if not base_url: + missing_parts.append("SHARED_AI_PLATFORM_BASE_URL") if not api_key: - raise HTTPException( - status_code=503, - detail="shared-ai-platform API key is required for AI features but is not configured", - ) + 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, diff --git a/backend/tests/test_shared_ai_platform_client.py b/backend/tests/test_shared_ai_platform_client.py index 58e0d636..80084e8a 100644 --- a/backend/tests/test_shared_ai_platform_client.py +++ b/backend/tests/test_shared_ai_platform_client.py @@ -6,7 +6,8 @@ import httpx from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient +from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient, build_shared_ai_platform_client +from app.core.config import settings from app.db import Base from app.models.conversation import Conversation from app.models.message import Message @@ -123,6 +124,45 @@ class SharedAiPlatformSdkTestCase(unittest.TestCase): class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase): + def test_missing_shared_ai_config_uses_local_history_fallback(self): + original_base_url = settings.shared_ai_platform_base_url + original_api_key = settings.shared_ai_platform_api_key + settings.shared_ai_platform_base_url = "" + settings.shared_ai_platform_api_key = "" + engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine, future=True)() + try: + now = datetime.now(timezone.utc) + session.add( + Conversation( + id="conv-local-config-missing", + tenant_id="tenant-1", + user_id="user-1", + title="Local fallback", + message_count=1, + last_message_at=now, + created_at=now, + updated_at=now, + ) + ) + session.commit() + + client = build_shared_ai_platform_client(session) + payload = client.list_conversations({"id": "user-1", "tenant_id": "tenant-1", "role": "admin"}, 1, 20) + chunks = list(client.stream_answer({"id": "user-1", "tenant_id": "tenant-1", "role": "admin"}, None, "hello")) + + self.assertEqual(payload["source"], "local_fallback") + self.assertEqual(payload["items"][0]["id"], "conv-local-config-missing") + self.assertIn("SHARED_AI_PLATFORM_BASE_URL", payload["warning"]) + self.assertIn("SHARED_AI_PLATFORM_API_KEY", payload["warning"]) + self.assertIn("模型服务未配置", chunks[0]) + finally: + session.close() + engine.dispose() + settings.shared_ai_platform_base_url = original_base_url + settings.shared_ai_platform_api_key = original_api_key + def test_remote_stream_ignores_start_and_usage_and_maps_delta(self): calls = []