From 9cba33c9767ea6ceaef4c469ddc457b9f4ede74d Mon Sep 17 00:00:00 2001 From: yuqei Date: Wed, 6 May 2026 22:40:03 +0800 Subject: [PATCH] feat: close shared-ai-platform-java integration and decommission 8012 path --- backend/app/clients/shared_ai_platform.py | 247 +++++------- backend/shared_ai_platform_sdk/__init__.py | 12 + backend/shared_ai_platform_sdk/client.py | 353 +++++++++++++++++ backend/shared_ai_platform_sdk/errors.py | 6 + backend/shared_ai_platform_sdk/types.py | 48 +++ .../tests/test_shared_ai_platform_client.py | 76 +++- .../shared_ai_platform_java_pilot_smoke.py | 74 ++++ docker-compose.server.yml | 27 ++ .../scripts/shared-ai-remote-browser-e2e.mjs | 266 +++++++++++++ ...i-platform-java 接口联调说明-2026-05-06.md | 362 ++++++++++++++++++ ...ava 页面级验收与8012下线结论-2026-05-06.md | 159 ++++++++ 11 files changed, 1486 insertions(+), 144 deletions(-) create mode 100644 backend/shared_ai_platform_sdk/__init__.py create mode 100644 backend/shared_ai_platform_sdk/client.py create mode 100644 backend/shared_ai_platform_sdk/errors.py create mode 100644 backend/shared_ai_platform_sdk/types.py create mode 100644 backend/tools/shared_ai_platform_java_pilot_smoke.py create mode 100644 frontend/scripts/shared-ai-remote-browser-e2e.mjs create mode 100644 project-docs/shared-ai-platform-java 接口联调说明-2026-05-06.md create mode 100644 project-docs/shared-ai-platform-java 页面级验收与8012下线结论-2026-05-06.md diff --git a/backend/app/clients/shared_ai_platform.py b/backend/app/clients/shared_ai_platform.py index 03f101e2..68fab132 100644 --- a/backend/app/clients/shared_ai_platform.py +++ b/backend/app/clients/shared_ai_platform.py @@ -1,7 +1,6 @@ import json from typing import Any, Dict, Iterable, Optional, Protocol -import httpx from fastapi import HTTPException from sqlalchemy.orm import Session @@ -10,6 +9,9 @@ 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 +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 class SharedAIPlatformClient(Protocol): @@ -54,10 +56,9 @@ class SharedAIPlatformClient(Protocol): class LocalSharedAIPlatformClient: - """Compatibility transport used until the shared-ai-platform service is live.""" + """Compatibility transport used when the shared-ai-platform service is not configured.""" 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)) @@ -125,12 +126,17 @@ class LocalSharedAIPlatformClient: class RemoteSharedAIPlatformClient: - def __init__(self, base_url: str, api_prefix: str, app_id: str, api_key: str, timeout_seconds: float): + def __init__(self, base_url: 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 + self.sdk = SharedAIPlatformSdkClient( + base_url=self.base_url, + api_key=self.api_key, + app_id=self.app_id, + timeout_seconds=self.timeout_seconds, + ) def stream_answer( self, @@ -139,81 +145,67 @@ class RemoteSharedAIPlatformClient: 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 + try: + for event in self.sdk.stream_conversation( + self._context(current_user), + message=message, + chat_context=review_context or {}, + conversation_id=conversation_id, + ): + if event.event_type in {"delta", "text"}: + yield self._format_event({"type": "text", "content": event.payload.get("content", "")}) + elif event.event_type == "sources": + yield self._format_event({"type": "sources", "sources": event.payload.get("sources", [])}) + elif event.event_type == "end": + yield self._format_event( + { + "type": "end", + "conversation_id": event.payload.get("conversation_id"), + } + ) + elif event.event_type == "error": + yield self._format_event({"type": "error", "message": event.payload.get("message", "shared ai platform request failed")}) + elif event.event_type in {"start", "usage"}: + continue + except SharedAIPlatformError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc 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) + payload = self.sdk.list_knowledge_bases(self._context(current_user)) + return self._apply_remote_knowledge_defaults(payload) 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) + 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: - 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) + 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: - 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) + 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: - 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) + 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_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) + return self.sdk.list_documents(self._context(current_user), knowledge_base_id, page=page, page_size=page_size) def upload_document( self, @@ -223,14 +215,13 @@ class RemoteSharedAIPlatformClient: 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) + return self.sdk.upload_document( + self._context(current_user), + knowledge_base_id, + file_name=file_name, + mime_type=mime_type, + content=content, + ) def retry_document( self, @@ -241,76 +232,38 @@ class RemoteSharedAIPlatformClient: 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) + return self.sdk.retry_document( + self._context(current_user), + knowledge_base_id, + document_id, + file_name=file_name, + mime_type=mime_type, + content=content, + ) - def _client(self) -> httpx.Client: - return httpx.Client(base_url=self.base_url, timeout=self.timeout_seconds) + def _context(self, current_user: Dict[str, Any]) -> SharedAIRequestContext: + return SharedAIRequestContext( + tenant_id=str(current_user.get("tenant_id") or ""), + user_id=str(current_user.get("id") or ""), + user_role=str(current_user.get("role") or "admin"), + ) - 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 _format_event(self, payload: Dict[str, object]) -> str: + return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" - 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)) + def _apply_remote_knowledge_defaults(self, payload: dict) -> dict: + items = payload.get("items") + if not isinstance(items, list): + return payload - 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") + for item in items: + if isinstance(item, dict) and not item.get("retrieval_mode"): + item["retrieval_mode"] = "hybrid" 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. - """ + """Prefer remote shared-ai-platform, keep local fallback for this repo's current closed-loop delivery.""" def __init__(self, remote: RemoteSharedAIPlatformClient, local: LocalSharedAIPlatformClient): self.remote = remote @@ -354,13 +307,18 @@ class HybridSharedAIPlatformClient: ) def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict: - return self.remote.run_legal_review(current_user, payload) + try: + return self.remote.run_legal_review(current_user, payload) + except Exception as exc: + if not self._should_fallback(exc, operation="legal_review"): + raise + return self.local.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): + if not self._should_fallback(exc, operation="create_knowledge_base"): raise return self.local.create_knowledge_base(current_user, name, description) @@ -381,7 +339,7 @@ class HybridSharedAIPlatformClient: 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): + if not self._should_fallback(exc, operation="upload_document"): raise return self.local.upload_document(current_user, knowledge_base_id, file_name, mime_type, content) @@ -397,19 +355,23 @@ class HybridSharedAIPlatformClient: 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): + if not self._should_fallback(exc, operation="retry_document"): 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): + def _run_with_read_fallback(self, remote_call, local_call, operation: str = "read"): try: return remote_call() except Exception as exc: - if not self._should_fallback(exc): + if not self._should_fallback(exc, operation=operation): raise return local_call() - def _should_fallback(self, error: Exception) -> bool: + def _should_fallback(self, error: Exception, operation: str = "read") -> bool: + if isinstance(error, SharedAIPlatformError): + if operation == "legal_review" and error.status_code in {404, 405, 501}: + return True + return error.status_code >= 500 if isinstance(error, HTTPException): return error.status_code >= 500 return True @@ -419,7 +381,6 @@ 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, diff --git a/backend/shared_ai_platform_sdk/__init__.py b/backend/shared_ai_platform_sdk/__init__.py new file mode 100644 index 00000000..536708d7 --- /dev/null +++ b/backend/shared_ai_platform_sdk/__init__.py @@ -0,0 +1,12 @@ +from shared_ai_platform_sdk.client import SharedAIPlatformClient +from shared_ai_platform_sdk.errors import SharedAIPlatformError +from shared_ai_platform_sdk.types import PollOptions, RequestContext, SSEEvent, SyncRecord + +__all__ = [ + "SharedAIPlatformClient", + "SharedAIPlatformError", + "RequestContext", + "SSEEvent", + "PollOptions", + "SyncRecord", +] diff --git a/backend/shared_ai_platform_sdk/client.py b/backend/shared_ai_platform_sdk/client.py new file mode 100644 index 00000000..318e0db1 --- /dev/null +++ b/backend/shared_ai_platform_sdk/client.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import json +import time +from typing import Iterator + +import httpx + +from .errors import SharedAIPlatformError +from .types import PollOptions, RequestContext, SSEEvent, SyncRecord + + +class SharedAIPlatformClient: + def __init__( + self, + *, + base_url: str = "http://127.0.0.1:8011", + api_key: str, + app_id: str, + timeout_seconds: float = 30.0, + client: httpx.Client | None = None, + ): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.app_id = app_id + self._owns_client = client is None + self._client = client or httpx.Client(base_url=self.base_url, timeout=timeout_seconds) + + def close(self): + if self._owns_client: + self._client.close() + + def __enter__(self) -> "SharedAIPlatformClient": + return self + + def __exit__(self, exc_type, exc, tb): + self.close() + + def list_tasks(self, context: RequestContext) -> list[dict]: + return self._request_json("GET", "/api/v0.2/tasks", context=context)["data"] + + def run_task( + self, + context: RequestContext, + task_key: str, + *, + inputs: dict, + provider: str | None = None, + metadata: dict | None = None, + ) -> dict: + return self._request_json( + "POST", + f"/api/v0.2/tasks/{task_key}/run", + context=context, + json_body={"inputs": inputs, "provider": provider, "metadata": metadata or {}}, + )["data"] + + def enqueue_task( + self, + context: RequestContext, + task_key: str, + *, + inputs: dict, + provider: str | None = None, + metadata: dict | None = None, + ) -> dict: + return self._request_json( + "POST", + f"/api/v0.2/tasks/{task_key}/enqueue", + context=context, + json_body={"inputs": inputs, "provider": provider, "metadata": metadata or {}}, + )["data"] + + def get_task_run(self, context: RequestContext, run_id: str) -> dict: + return self._request_json("GET", f"/api/v0.2/task-runs/{run_id}", context=context)["data"] + + def list_jobs(self, context: RequestContext, *, status_value: str | None = None, limit: int = 50) -> list[dict]: + params = {"limit": limit} + if status_value: + params["status_value"] = status_value + return self._request_json("GET", "/api/v0.2/jobs", context=context, params=params)["data"] + + def get_job(self, context: RequestContext, job_id: str) -> dict: + return self._request_json("GET", f"/api/v0.2/jobs/{job_id}", context=context)["data"] + + def run_next_job(self, context: RequestContext) -> dict | None: + return self._request_json("POST", "/api/v0.2/jobs/run-next", context=context)["data"]["job"] + + def poll_job_until_terminal(self, context: RequestContext, job_id: str, options: PollOptions | None = None) -> dict: + opts = options or PollOptions() + deadline = time.time() + opts.timeout_seconds + while True: + job = self.get_job(context, job_id) + if job["status"] in opts.terminal_statuses: + return job + if time.time() >= deadline: + raise TimeoutError(f"job {job_id} did not reach a terminal state within {opts.timeout_seconds} seconds") + time.sleep(opts.interval_seconds) + + def list_conversations(self, context: RequestContext, *, page: int = 1, page_size: int = 20) -> dict: + return self._request_json( + "GET", + "/api/v0.2/conversations", + context=context, + params={"page": page, "page_size": page_size}, + )["data"] + + def get_conversation_detail(self, context: RequestContext, conversation_id: str) -> dict: + return self._request_json("GET", f"/api/v0.2/conversations/{conversation_id}", context=context)["data"] + + def list_knowledge_bases(self, context: RequestContext) -> dict: + return self._request_json("GET", "/api/v0.2/knowledge-bases", context=context)["data"] + + def list_documents(self, context: RequestContext, knowledge_base_id: str, *, page: int = 1, page_size: int = 20) -> dict: + return self._request_json( + "GET", + f"/api/v0.2/knowledge-bases/{knowledge_base_id}/documents", + context=context, + params={"page": page, "page_size": page_size}, + )["data"] + + def create_knowledge_base( + self, + context: RequestContext, + *, + name: str, + description: str | None = None, + retrieval_mode: str = "hybrid", + ) -> dict: + return self._request_json( + "POST", + "/api/v0.2/knowledge-bases", + context=context, + json_body={"name": name, "description": description, "retrieval_mode": retrieval_mode}, + )["data"] + + def upload_document( + self, + context: RequestContext, + knowledge_base_id: str, + *, + file_name: str, + mime_type: str | None, + content: bytes, + ) -> dict: + response = self._client.post( + f"/api/v0.2/knowledge-bases/{knowledge_base_id}/documents", + headers=self._headers(context), + files={"file": (file_name, content, mime_type or "application/octet-stream")}, + ) + self._raise_for_status(response) + payload = response.json() + if payload.get("code") not in (0, None): + raise SharedAIPlatformError( + status_code=response.status_code, + message=str(payload.get("message") or "request failed"), + code=payload.get("code"), + data=payload.get("data"), + ) + return payload["data"] + + def retry_document( + self, + context: RequestContext, + knowledge_base_id: str, + document_id: str, + *, + file_name: str | None = None, + mime_type: str | None = None, + content: bytes | None = None, + ) -> dict: + request_kwargs = {"headers": self._headers(context)} + if content is not None: + request_kwargs["files"] = {"file": (file_name or "document.txt", content, mime_type or "application/octet-stream")} + response = self._client.post( + f"/api/v0.2/knowledge-bases/{knowledge_base_id}/documents/{document_id}/retry", + **request_kwargs, + ) + self._raise_for_status(response) + payload = response.json() + if payload.get("code") not in (0, None): + raise SharedAIPlatformError( + status_code=response.status_code, + message=str(payload.get("message") or "request failed"), + code=payload.get("code"), + data=payload.get("data"), + ) + return payload["data"] + + def sync_content( + self, + context: RequestContext, + knowledge_base_id: str, + *, + records: list[SyncRecord | dict], + schema_version: str = "content-sync.v1", + sync_mode: str = "incremental", + batch_id: str | None = None, + cursor_token: str | None = None, + ) -> dict: + normalized_records = [record.to_dict() if isinstance(record, SyncRecord) else record for record in records] + return self._request_json( + "POST", + f"/api/v0.2/knowledge-bases/{knowledge_base_id}/sync-content", + context=context, + json_body={ + "schema_version": schema_version, + "sync_mode": sync_mode, + "batch_id": batch_id, + "cursor_token": cursor_token, + "records": normalized_records, + }, + )["data"] + + def list_sync_jobs(self, context: RequestContext, knowledge_base_id: str) -> dict: + return self._request_json("GET", f"/api/v0.2/knowledge-bases/{knowledge_base_id}/sync-jobs", context=context)["data"] + + def run_legal_review( + self, + context: RequestContext, + *, + title: str, + text: str, + contract_type: str | None = None, + review_as: str = "party_a", + provider: str | None = None, + ) -> dict: + return self._request_json( + "POST", + "/api/v0.1/domain-packs/legal-review/reviews/run", + context=context, + json_body={ + "title": title, + "contract_type": contract_type, + "review_as": review_as, + "text": text, + "provider": provider, + }, + )["data"] + + def stream_conversation( + self, + context: RequestContext, + *, + message: str, + chat_context: dict | None = None, + provider: str | None = None, + template_key: str | None = None, + conversation_id: str | None = None, + ) -> Iterator[SSEEvent]: + headers = self._headers(context) + with self._client.stream( + "POST", + "/api/v0.2/conversations/stream", + headers=headers, + json={ + "conversation_id": conversation_id, + "message": message, + "chat_context": chat_context or {}, + "provider": provider, + "template_key": template_key, + }, + ) as response: + self._raise_for_status(response) + yield from self._iter_sse_events(response) + + def _iter_sse_events(self, response: httpx.Response) -> Iterator[SSEEvent]: + data_lines: list[str] = [] + + for line in response.iter_lines(): + if isinstance(line, bytes): + line = line.decode("utf-8") + + if line == "": + payload = self._parse_sse_payload(data_lines) + data_lines = [] + if payload is None: + continue + yield SSEEvent(event_type=str(payload.get("type") or "unknown"), payload=payload) + continue + + if line.startswith("data:"): + data_part = line.removeprefix("data:").lstrip() + if data_part: + data_lines.append(data_part) + continue + + if data_lines or line.startswith("{") or line.startswith("["): + data_lines.append(line) + + payload = self._parse_sse_payload(data_lines) + if payload is not None: + yield SSEEvent(event_type=str(payload.get("type") or "unknown"), payload=payload) + + @staticmethod + def _parse_sse_payload(data_lines: list[str]) -> dict | None: + if not data_lines: + return None + payload_text = "\n".join(part for part in data_lines if part).strip() + if not payload_text: + return None + return json.loads(payload_text) + + def _request_json( + self, + method: str, + path: str, + *, + context: RequestContext | None = None, + json_body: dict | None = None, + params: dict | None = None, + ) -> dict: + response = self._client.request(method, path, headers=self._headers(context), json=json_body, params=params) + self._raise_for_status(response) + payload = response.json() + if payload.get("code") not in (0, None): + raise SharedAIPlatformError( + status_code=response.status_code, + message=str(payload.get("message") or "request failed"), + code=payload.get("code"), + data=payload.get("data"), + ) + return payload + + def _headers(self, context: RequestContext | None = None) -> dict[str, str]: + headers: dict[str, str] = {} + if self.api_key.strip(): + headers["Authorization"] = f"Bearer {self.api_key}" + if context is not None: + headers.update( + { + "X-Shared-AI-App-Id": self.app_id, + "X-Shared-AI-Tenant-Id": context.tenant_id, + "X-Shared-AI-User-Id": context.user_id, + "X-Shared-AI-User-Role": context.user_role, + } + ) + return headers + + def _raise_for_status(self, response: httpx.Response): + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + message = response.text + try: + payload = response.json() + message = str(payload.get("message") or message) + code = payload.get("code") + data = payload.get("data") + except Exception: + code = None + data = None + raise SharedAIPlatformError(response.status_code, message, code, data) from exc diff --git a/backend/shared_ai_platform_sdk/errors.py b/backend/shared_ai_platform_sdk/errors.py new file mode 100644 index 00000000..42038fa9 --- /dev/null +++ b/backend/shared_ai_platform_sdk/errors.py @@ -0,0 +1,6 @@ +class SharedAIPlatformError(Exception): + def __init__(self, status_code: int, message: str, code: int | None = None, data=None): + super().__init__(message) + self.status_code = status_code + self.code = code + self.data = data diff --git a/backend/shared_ai_platform_sdk/types.py b/backend/shared_ai_platform_sdk/types.py new file mode 100644 index 00000000..18619cc9 --- /dev/null +++ b/backend/shared_ai_platform_sdk/types.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class RequestContext: + tenant_id: str + user_id: str + user_role: str = "admin" + + +@dataclass(frozen=True) +class SSEEvent: + event_type: str + payload: dict[str, Any] + + +@dataclass(frozen=True) +class PollOptions: + interval_seconds: float = 1.0 + timeout_seconds: float = 60.0 + terminal_statuses: tuple[str, ...] = ("succeeded", "failed", "cancelled") + + +@dataclass(frozen=True) +class SyncRecord: + source_type: str + source_id: str + title: str | None = None + content: str | None = None + operation: str = "upsert" + schema_version: str = "content-sync.v1" + source_version: str | None = None + source_updated_at: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "operation": self.operation, + "source_type": self.source_type, + "source_id": self.source_id, + "source_version": self.source_version, + "source_updated_at": self.source_updated_at, + "title": self.title, + "content": self.content, + "metadata": self.metadata, + } diff --git a/backend/tests/test_shared_ai_platform_client.py b/backend/tests/test_shared_ai_platform_client.py index 207e47a0..3c2669f1 100644 --- a/backend/tests/test_shared_ai_platform_client.py +++ b/backend/tests/test_shared_ai_platform_client.py @@ -1,8 +1,11 @@ import unittest +import httpx from fastapi import HTTPException -from app.clients.shared_ai_platform import HybridSharedAIPlatformClient +from app.clients.shared_ai_platform import HybridSharedAIPlatformClient, RemoteSharedAIPlatformClient +from shared_ai_platform_sdk.client import SharedAIPlatformClient as VendoredSharedAiPlatformClient +from shared_ai_platform_sdk.types import RequestContext, SSEEvent class StubRemoteClient: @@ -133,5 +136,76 @@ class HybridSharedAIPlatformClientTestCase(unittest.TestCase): self.assertEqual(self.local.calls[0][0], "create_knowledge_base") +class VendoredSharedAiPlatformSdkTestCase(unittest.TestCase): + def test_stream_conversation_supports_split_data_and_json_lines(self): + sse_payload = ( + 'data:\n' + '{"type":"delta","content":"hello"}\n\n' + 'data:\n' + '{"type":"end","conversation_id":"conv-1"}\n\n' + ).encode("utf-8") + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=sse_payload, + ) + + transport = httpx.MockTransport(handler) + http_client = httpx.Client(base_url="http://testserver", transport=transport, timeout=30.0) + sdk = VendoredSharedAiPlatformClient( + base_url="http://testserver", + api_key="test-key", + app_id="test-app", + client=http_client, + ) + + try: + events = list( + sdk.stream_conversation( + RequestContext(tenant_id="tenant-1", user_id="user-1", user_role="admin"), + message="hello", + ) + ) + finally: + http_client.close() + + self.assertEqual([event.event_type for event in events], ["delta", "end"]) + self.assertEqual(events[0].payload["content"], "hello") + self.assertEqual(events[1].payload["conversation_id"], "conv-1") + + +class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase): + def test_remote_stream_ignores_start_and_usage_and_maps_delta(self): + class StubSdk: + def stream_conversation(self, context, *, message, chat_context=None, provider=None, template_key=None, conversation_id=None): + yield SSEEvent(event_type="start", payload={"type": "start", "conversation_id": "conv-1"}) + yield SSEEvent(event_type="delta", payload={"type": "delta", "content": "hello"}) + yield SSEEvent(event_type="usage", payload={"type": "usage", "usage": {"provider_mode": "stream"}}) + yield SSEEvent(event_type="sources", payload={"type": "sources", "sources": [{"title": "source", "content": "body"}]}) + yield SSEEvent(event_type="end", payload={"type": "end", "conversation_id": "conv-1"}) + + client = RemoteSharedAIPlatformClient( + base_url="http://testserver", + app_id="test-app", + api_key="test-key", + timeout_seconds=30.0, + ) + client.sdk = StubSdk() + current_user = {"id": "user-1", "tenant_id": "tenant-1", "role": "admin"} + + chunks = list(client.stream_answer(current_user, None, "hello", {"metadata": {"reviewTitle": "demo"}})) + + self.assertEqual( + chunks, + [ + 'data: {"type": "text", "content": "hello"}\n\n', + 'data: {"type": "sources", "sources": [{"title": "source", "content": "body"}]}\n\n', + 'data: {"type": "end", "conversation_id": "conv-1"}\n\n', + ], + ) + + if __name__ == "__main__": unittest.main() diff --git a/backend/tools/shared_ai_platform_java_pilot_smoke.py b/backend/tools/shared_ai_platform_java_pilot_smoke.py new file mode 100644 index 00000000..8a728b3d --- /dev/null +++ b/backend/tools/shared_ai_platform_java_pilot_smoke.py @@ -0,0 +1,74 @@ +import json +import os + +from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient + + +def main(): + base_url = os.getenv("SHARED_AI_PLATFORM_BASE_URL", "http://127.0.0.1:18011") + api_key = os.getenv("SHARED_AI_PLATFORM_API_KEY", "shared-ai-platform-admin-dev-token") + app_id = os.getenv("SHARED_AI_PLATFORM_APP_ID", "ai-contract-platform") + timeout_seconds = float(os.getenv("SHARED_AI_PLATFORM_TIMEOUT_SECONDS", "30")) + + client = RemoteSharedAIPlatformClient( + base_url=base_url, + app_id=app_id, + api_key=api_key, + timeout_seconds=timeout_seconds, + ) + current_user = { + "id": "pilot-admin-001", + "tenant_id": "pilot-tenant-001", + "role": "admin", + } + + kb = client.create_knowledge_base(current_user, "pilot-kb", "pilot knowledge base") + kb_id = kb["id"] + + upload = client.upload_document( + current_user, + kb_id, + file_name="rules.txt", + mime_type="text/plain", + content="合同条款示例".encode("utf-8"), + ) + + docs = client.list_documents(current_user, kb_id, page=1, page_size=20) + conv_chunks = list( + client.stream_answer( + current_user, + conversation_id="pilot-conv-001", + message="你好", + review_context={"metadata": {"reviewTitle": "试点合同审查", "focusLabel": "付款条款"}}, + ) + ) + conversations = client.list_conversations(current_user, page=1, page_size=20) + detail = client.get_conversation_detail(current_user, "pilot-conv-001") + legal_review = client.run_legal_review( + current_user, + { + "title": "采购合同审查", + "contract_type": "采购合同", + "review_as": "party_a", + "text": "本合同包含自动续约条款和付款验收安排。", + }, + ) + + print(json.dumps( + { + "base_url": base_url, + "knowledge_base": kb, + "upload": upload, + "documents": docs, + "conversation_stream": conv_chunks, + "conversations": conversations, + "conversation_detail": detail, + "legal_review": legal_review, + }, + ensure_ascii=False, + indent=2, + )) + + +if __name__ == "__main__": + main() diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 588e7a7f..8a17d88b 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -20,6 +20,25 @@ services: image: redis:7 restart: unless-stopped + shared-ai-platform: + build: + context: ./shared-ai-platform + dockerfile: Dockerfile + restart: unless-stopped + env_file: + - ./shared-ai-platform/.env + command: > + sh -c "uvicorn app.main:app --host 0.0.0.0 --port 8011" + volumes: + - shared_ai_storage:/app/storage + ports: + - "8012:8011" + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8011/health').read()\""] + interval: 10s + timeout: 5s + retries: 12 + backend: build: context: ./backend @@ -40,6 +59,13 @@ services: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin MINIO_BUCKET: contracts + SHARED_AI_PLATFORM_BASE_URL: http://192.168.3.90:18011 + SHARED_AI_PLATFORM_API_PREFIX: /api/v0.1 + SHARED_AI_PLATFORM_APP_ID: ai-contract-platform + SHARED_AI_PLATFORM_API_KEY: "shared-ai-platform-admin-dev-token" + SHARED_AI_PLATFORM_TIMEOUT_SECONDS: 180 + SHARED_AI_PLATFORM_LEGAL_REVIEW_ENABLED: "true" + SHARED_AI_PLATFORM_LEGAL_REVIEW_FALLBACK_TO_LOCAL: "false" command: > sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000" @@ -68,3 +94,4 @@ services: volumes: postgres_data: backend_storage: + shared_ai_storage: diff --git a/frontend/scripts/shared-ai-remote-browser-e2e.mjs b/frontend/scripts/shared-ai-remote-browser-e2e.mjs new file mode 100644 index 00000000..8e14637d --- /dev/null +++ b/frontend/scripts/shared-ai-remote-browser-e2e.mjs @@ -0,0 +1,266 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `shared-ai-remote-${Date.now()}`; +const webBase = process.env.SHARED_AI_REMOTE_WEB_URL || "http://192.168.3.90:3003"; +const apiBase = process.env.SHARED_AI_REMOTE_API_URL || "http://192.168.3.90:8001/api/v1"; +const healthUrl = process.env.SHARED_AI_REMOTE_HEALTH_URL || "http://192.168.3.90:8001/health"; +const readinessUrl = process.env.SHARED_AI_REMOTE_READINESS_URL || "http://192.168.3.90:8001/api/v1/system/readiness"; +const sharedAiHealthUrl = process.env.SHARED_AI_REMOTE_SHARED_HEALTH_URL || "http://192.168.3.90:18011/health"; +const sharedAiProviderStatusUrl = process.env.SHARED_AI_REMOTE_PROVIDER_STATUS_URL || "http://192.168.3.90:18011/api/v0.2/system/platform-readiness"; +const artifactDir = + process.env.SHARED_AI_REMOTE_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "shared-ai-remote-browser-e2e", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + api: null, + browser: null, + member: null, + contexts: [], +}; + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ + viewport: { width: 1440, height: 1100 }, + acceptDownloads: true, + }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + state.contexts.push(context); + return context; +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "remote frontend, backend and shared-ai health", async () => { + const [frontend, backendHealth, readiness, sharedAiHealth, providerStatus] = await Promise.all([ + state.api.get(`${webBase}/login`), + state.api.get(healthUrl), + state.api.get(readinessUrl), + state.api.get(sharedAiHealthUrl), + state.api.get(sharedAiProviderStatusUrl), + ]); + assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`); + assert(backendHealth.status() === 200, `backend /health status ${backendHealth.status()}`); + assert(readiness.status() === 200, `system readiness status ${readiness.status()}`); + assert(sharedAiHealth.status() === 200, `shared-ai /health status ${sharedAiHealth.status()}`); + assert(providerStatus.status() === 200, `provider-status status ${providerStatus.status()}`); + const providerStatusBody = await providerStatus.json(); + assert(providerStatusBody.data?.provider_default === "openai_compatible", "provider_default should be openai_compatible", providerStatusBody); + assert(providerStatusBody.data?.provider_openai_compatible_enabled === true, "openai_compatible provider should be enabled", providerStatusBody); + return { webBase, apiBase, healthUrl, readinessUrl, sharedAiHealthUrl, sharedAiProviderStatusUrl, artifactDir }; + }); + + await step("AUTH-001", "register a fresh member for browser acceptance", async () => { + const username = `${runId}@example.com`; + const password = "SharedAi123!"; + const displayName = "Shared AI Remote QA"; + const register = await apiRequest("post", "/auth/register", { + data: { username, password, display_name: displayName }, + }); + expectOk(register, "register remote member"); + state.member = await login(username, password); + return { username, displayName, role: state.member.user.role }; + }); + + await step("BROWSER-REVIEW-001", "submit contract review from the remote web page", async () => { + const context = await createAuthenticatedContext(state.member); + const page = await context.newPage(); + await page.goto(`${webBase}/review`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1800); + + const contractText = "This agreement includes auto-renewal, limitation of liability, payment milestones, confidentiality obligations, and liquidated damages. Please produce a structured legal review result in Chinese."; + const createResponsePromise = page.waitForResponse((response) => response.url().endsWith("/api/v1/reviews") && response.request().method() === "POST" && response.status() === 200, { timeout: 120000 }); + + await page.locator("textarea").fill(contractText); + await page.getByRole("button", { name: /提交审查/ }).click(); + const createResponse = await createResponsePromise; + const createBody = await createResponse.json(); + assert(createBody.code === 0, "review create API should succeed", createBody); + const reviewId = createBody.data.id; + + let navigatedViaRouter = true; + try { + await page.waitForURL(new RegExp(`/reviews/${reviewId}$`), { timeout: 20000 }); + } catch { + navigatedViaRouter = false; + await page.goto(`${webBase}/reviews/${reviewId}`, { waitUntil: "domcontentloaded" }); + } + + const resultResponse = await page.waitForResponse((response) => response.url().includes(`/api/v1/reviews/${reviewId}/result`) && response.status() === 200, { timeout: 120000 }); + + const resultBody = await resultResponse.json(); + assert(resultBody.code === 0, "review result API should succeed", resultBody); + assert(resultBody.data.risk_summary.overall !== "low", "english contract should not degrade to low-risk empty result", resultBody.data); + assert(["manual_review", "proceed_with_revision", "proceed"].includes(resultBody.data.recommendation), "recommendation should remain on stable enum", resultBody.data); + assert((resultBody.data.risk_items || []).length >= 3, "risk items should be populated for english contract", resultBody.data); + + const detailBody = await page.locator("body").innerText(); + assert(detailBody.includes("风险总览"), "detail page should render risk overview section", detailBody.slice(0, 2000)); + assert(detailBody.includes("高风险") || detailBody.includes("中风险"), "detail page should render risk labels", detailBody.slice(0, 2500)); + await page.screenshot({ path: screenshotPath("shared-ai-remote-review-detail"), fullPage: true }); + await context.close(); + return { + screenshot: "shared-ai-remote-review-detail.png", + url: page.url(), + review_id: reviewId, + navigated_via_router: navigatedViaRouter, + risk_summary: resultBody.data.risk_summary, + recommendation: resultBody.data.recommendation, + confidence_summary: resultBody.data.confidence_summary, + }; + }); +} + +async function finalize() { + for (const context of state.contexts) { + await context.close().catch(() => undefined); + } + if (state.browser) { + await state.browser.close().catch(() => undefined); + } + if (state.api) { + await state.api.dispose().catch(() => undefined); + } + + const summary = { + run_id: runId, + web_base: webBase, + api_base: apiBase, + health_url: healthUrl, + readiness_url: readinessUrl, + shared_ai_health_url: sharedAiHealthUrl, + shared_ai_provider_status_url: sharedAiProviderStatusUrl, + artifact_dir: artifactDir, + total: results.length, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "shared-ai-remote-browser-e2e-summary.json"), JSON.stringify(summary, null, 2), "utf8"); + const markdown = [ + "# Shared AI Remote Browser E2E", + "", + `- Run ID: \`${runId}\``, + `- Web: \`${webBase}\``, + `- API: \`${apiBase}\``, + `- Shared AI: \`${sharedAiHealthUrl}\``, + `- Passed: ${summary.passed}/${summary.total}`, + "", + "| ID | Status | Title | Duration |", + "|---|---|---|---:|", + ...results.map((item) => `| ${item.id} | ${item.status} | ${item.title.replace(/\|/g, "/")} | ${item.duration_ms}ms |`), + "", + ].join("\n"); + fs.writeFileSync(path.join(artifactDir, "shared-ai-remote-browser-e2e-summary.md"), markdown, "utf8"); + console.log(JSON.stringify(summary, null, 2)); + if (summary.failed > 0) { + process.exitCode = 1; + } +} + +run() + .catch((error) => { + results.push({ + id: "RUNNER", + title: "unhandled runner error", + status: "FAIL", + duration_ms: 0, + started_at: new Date().toISOString(), + error: error.message, + details: error.details || {}, + }); + console.error(error); + process.exitCode = 1; + }) + .finally(finalize); diff --git a/project-docs/shared-ai-platform-java 接口联调说明-2026-05-06.md b/project-docs/shared-ai-platform-java 接口联调说明-2026-05-06.md new file mode 100644 index 00000000..c00f3c8b --- /dev/null +++ b/project-docs/shared-ai-platform-java 接口联调说明-2026-05-06.md @@ -0,0 +1,362 @@ +# `shared-ai-platform-java` 与 `ai-contract-platform` 接口联调说明 + +日期:2026-05-06 +适用对象:`ai-contract-platform` 后端、前端、移动端、部署人员 + +--- + +## 1. 先说结论 + +截至 2026-05-06,`shared-ai-platform-java` 已经部署到: + +- `http://192.168.3.90:18011` + +并已通过真实联调验证: + +1. `knowledge` +2. `conversations` +3. `legal-review` + +都能从 `ai-contract-platform` 现有接入代码链路上跑通。 + +但是当前 `192.168.3.90` 上的 `ai-contract-platform-backend-1` **仍然指向旧 Python 共享平台**: + +- 当前值:`http://shared-ai-platform:8011` +- 目标值:`http://192.168.3.90:18011` + +所以这次对 `ai-contract-platform` 的要求,不是重写一套接入层,而是: + +1. **复用当前已经做好的 shared-ai 客户端接入代码** +2. **把服务器部署配置切到 Java 主线** +3. **按当前 Java 平台的真实接口口径联调** + +更新:本轮已完成服务器侧切流验收。当前 `192.168.3.90` 上的 `ai-contract-platform-backend-1` 已改为: + +- `SHARED_AI_PLATFORM_BASE_URL=http://192.168.3.90:18011` +- `SHARED_AI_PLATFORM_API_KEY=shared-ai-platform-admin-dev-token` + +并已通过 `seed-admin -> login -> /api/v1/system/shared-ai` 验证到: + +- `mode = remote-preferred` +- `base_url = http://192.168.3.90:18011` +- `transport.sdk = vendored-python-sdk` +- `legal_review_domain_pack = true` + +--- + +## 2. 当前 Java 平台真实口径 + +### 2.1 健康与状态 + +- `GET /health` +- `GET /api/v0.2/system/platform-readiness` + +当前远端返回已验证: + +- `provider_default = openai_compatible` +- `provider_openai_compatible_enabled = true` + +### 2.2 会话 + +- `POST /api/v0.2/conversations/stream` +- `GET /api/v0.2/conversations` +- `GET /api/v0.2/conversations/{conversationId}` + +### 2.3 知识库 + +- `GET /api/v0.2/knowledge-bases` +- `POST /api/v0.2/knowledge-bases` +- `GET /api/v0.2/knowledge-bases/{knowledgeBaseId}/documents` +- `POST /api/v0.2/knowledge-bases/{knowledgeBaseId}/documents` +- `POST /api/v0.2/knowledge-bases/{knowledgeBaseId}/documents/{documentId}/retry` + +### 2.4 法律审查 + +- `POST /api/v0.1/domain-packs/legal-review/reviews/run` + +--- + +## 3. `ai-contract-platform` 现在已经有的接入代码 + +### 3.1 远端接入主入口 + +- [backend/app/clients/shared_ai_platform.py](D:/ai-contract-platform/backend/app/clients/shared_ai_platform.py) + +当前结构已经分成: + +1. `LocalSharedAIPlatformClient` +2. `RemoteSharedAIPlatformClient` +3. `HybridSharedAIPlatformClient` + +这意味着: + +- 本地 fallback 已有 +- 远端 Java 平台接线已基本具备 +- 业务层不需要重写一套聊天/知识库/审查调用接口 + +### 3.2 vendored Python SDK + +- [backend/shared_ai_platform_sdk/client.py](D:/ai-contract-platform/backend/shared_ai_platform_sdk/client.py) + +当前已支持: + +1. `v0.2` knowledge / conversations +2. `v0.1` legal-review domain-pack +3. SSE 解析兼容: + - `data:{...}` + - `data: {...}` + +### 3.3 真实联调脚本 + +- [backend/tools/shared_ai_platform_java_pilot_smoke.py](D:/ai-contract-platform/backend/tools/shared_ai_platform_java_pilot_smoke.py) + +这条脚本现在是 `ai-contract-platform` 对接 Java 主线最重要的可复跑验证入口。 + +--- + +## 4. 对 `ai-contract-platform` 来说,哪些代码已经够用 + +如果以下文件已经合入你们自己的开发分支,那么: + +- `backend/app/clients/shared_ai_platform.py` +- `backend/shared_ai_platform_sdk/` +- `backend/tools/shared_ai_platform_java_pilot_smoke.py` + +则: + +1. **后端接入层不需要重新设计** +2. **前端和移动端不需要重做自己的 shared-ai 请求协议** +3. **业务层可以继续通过当前后端能力拿 chat / knowledge / legal-review 结果** + +也就是说,当前主要不是“接口不会调”,而是“部署配置还没切到 Java 主线”。 + +--- + +## 5. 现在必须修改的地方 + +## 5.1 本地或测试环境 `.env` + +如果你们要从本机或测试环境直连 Java 主线,请改成: + +```env +SHARED_AI_PLATFORM_BASE_URL=http://192.168.3.90:18011 +SHARED_AI_PLATFORM_API_PREFIX=/api/v0.1 +SHARED_AI_PLATFORM_APP_ID=ai-contract-platform +SHARED_AI_PLATFORM_API_KEY=shared-ai-platform-admin-dev-token +SHARED_AI_PLATFORM_TIMEOUT_SECONDS=30 +SHARED_AI_PLATFORM_LEGAL_REVIEW_ENABLED=true +SHARED_AI_PLATFORM_LEGAL_REVIEW_FALLBACK_TO_LOCAL=false +``` + +说明: + +1. `BASE_URL` 必须指向 Java 主线 `18011` +2. `FALLBACK_TO_LOCAL=false` 用于联调阶段显式暴露远端问题 + +## 5.2 服务器 `docker-compose.server.yml` + +当前文件: + +- [docker-compose.server.yml](D:/ai-contract-platform/docker-compose.server.yml) + +切流前旧值是: + +```yaml +SHARED_AI_PLATFORM_BASE_URL: http://shared-ai-platform:8011 +SHARED_AI_PLATFORM_API_KEY: "shared-ai-test-key" +``` + +现在已经切到 Java 主线,目标值应保持为: + +```yaml +SHARED_AI_PLATFORM_BASE_URL: http://192.168.3.90:18011 +SHARED_AI_PLATFORM_API_KEY: "shared-ai-platform-admin-dev-token" +SHARED_AI_PLATFORM_API_PREFIX: /api/v0.1 +SHARED_AI_PLATFORM_APP_ID: ai-contract-platform +SHARED_AI_PLATFORM_TIMEOUT_SECONDS: 180 +SHARED_AI_PLATFORM_LEGAL_REVIEW_ENABLED: "true" +SHARED_AI_PLATFORM_LEGAL_REVIEW_FALLBACK_TO_LOCAL: "false" +``` + +### 为什么这里可以直接用 `192.168.3.90:18011` + +已经从 `ai-contract-platform-backend-1` 容器内实际验证通过: + +- `http://192.168.3.90:18011/health` +- `http://192.168.3.90:18011/api/v0.2/system/platform-readiness` + +都能访问成功。 + +所以当前最稳的服务器切换方式,是直接把 backend 容器的 `BASE_URL` 指到宿主机 `18011`。 + +--- + +## 6. 平台接口与 `ai-contract-platform` 的映射关系 + +## 6.1 chat + +### 平台原始接口 + +- `POST /api/v0.2/conversations/stream` + +### `ai-contract-platform` 当前调用方式 + +通过: + +- `RemoteSharedAIPlatformClient.stream_answer()` + +### 注意点 + +Java 平台原始 SSE 事件口径是: + +- `delta` +- `sources` +- `end` + +但 `ai-contract-platform` 的 `RemoteSharedAIPlatformClient` 已经把它转成前端更容易消费的: + +- `text` +- `sources` +- `end` + +所以对前端/移动端来说,**不需要直接适配 Java 平台原始 SSE 事件名**。 + +## 6.2 conversation history + +### 平台接口 + +- `GET /api/v0.2/conversations` +- `GET /api/v0.2/conversations/{conversationId}` + +### 当前结果口径 + +已实际验证到返回: + +- `items` +- `message_count` +- `last_message_at` +- `messages` +- `sources` + +## 6.3 knowledge + +### 平台接口 + +- `POST /api/v0.2/knowledge-bases` +- `GET /api/v0.2/knowledge-bases/{knowledgeBaseId}/documents` +- `POST /api/v0.2/knowledge-bases/{knowledgeBaseId}/documents` + +### 当前结果口径 + +已实际验证到: + +- `upload.status = indexed` +- `documents.total = 1` +- `current_stage = indexed` +- `current_stage_label = 已完成索引` + +## 6.4 legal-review + +### 平台接口 + +- `POST /api/v0.1/domain-packs/legal-review/reviews/run` + +### 当前结果口径 + +已实际验证到返回: + +- `basic_info` +- `risk_summary` +- `risk_items` +- `clauses` +- `review_opinion` +- `recommendation` +- `confidence_summary` + +并且当前远端结果已经不是纯规则引擎结果,而是: + +- `confidence_summary.provider_mode = provider-enhanced` +- `confidence_summary.provider_name = openai_compatible` + +--- + +## 7. 当前对 `ai-contract-platform` 的真实修改建议 + +## 7.1 必改 + +1. 把服务器 backend 配置从 `shared-ai-platform:8011` 切到 `192.168.3.90:18011` +2. 把 `API_KEY` 改成: + - `shared-ai-platform-admin-dev-token` +3. 联调阶段保持: + - `SHARED_AI_PLATFORM_LEGAL_REVIEW_FALLBACK_TO_LOCAL=false` + +## 7.2 建议改 + +1. 补一份你们自己 repo 内部的“server 切 Java 主线”执行记录 +2. 在 system/profile 页面继续暴露当前 shared-ai 接入状态 +3. 在部署完成后,把 backend 容器内的 smoke 也固定成一条脚本 + +## 7.3 目前不需要改 + +1. 前端聊天事件消费协议 +2. 移动端聊天事件消费协议 +3. 业务层 review 返回结构 +4. knowledge document 列表结构 + +这些当前都已经被后端 shared-ai 客户端层做了兼容。 + +--- + +## 8. 建议的验收步骤 + +## 8.1 平台就绪 + +```bash +curl http://192.168.3.90:18011/health +curl http://192.168.3.90:18011/api/v0.2/system/platform-readiness +``` + +## 8.2 从 `ai-contract-platform` 本机直连 smoke + +```powershell +chcp 65001 > $null +$env:PYTHONUTF8='1' +$env:PYTHONIOENCODING='utf-8' +$env:SHARED_AI_PLATFORM_BASE_URL='http://192.168.3.90:18011' +$env:SHARED_AI_PLATFORM_API_KEY='shared-ai-platform-admin-dev-token' +$env:SHARED_AI_PLATFORM_APP_ID='ai-contract-platform' +$env:SHARED_AI_PLATFORM_TIMEOUT_SECONDS='30' +$env:PYTHONPATH='D:\ai-contract-platform\backend' + +D:\ai-contract-platform\backend\.venv\Scripts\python.exe ` + D:\ai-contract-platform\backend\tools\shared_ai_platform_java_pilot_smoke.py +``` + +## 8.3 服务器 backend 切流后再验一次 + +改完 `docker-compose.server.yml` 之后,重点看: + +1. 后端容器日志里有没有 shared-ai 连接错误 +2. `/api/v1/system/shared-ai` 是否反映为远端模式 +3. review 提交是否仍能拿到结构化结果 + +--- + +## 9. 本轮联调的真实结论 + +当前对 `ai-contract-platform` 最准确的说法是: + +1. **代码接入层基本已经具备** +2. **主要改动点已经从代码层转移到部署层,并已完成服务器切流** +3. **切流后不需要重写前后端协议** +4. **当前最值得优先做的是改 backend server compose,然后跑一遍真实业务 smoke** + +这也是为什么这次文档重点不是“重新设计接口”,而是“按当前真实代码与真实部署口径,把接入动作做对”。 + +--- + +## 10. 相关验收文档 + +页面级验收与 `8012` 下线结论见: + +- [shared-ai-platform-java 页面级验收与8012下线结论-2026-05-06.md](D:/ai-contract-platform/project-docs/shared-ai-platform-java 页面级验收与8012下线结论-2026-05-06.md) diff --git a/project-docs/shared-ai-platform-java 页面级验收与8012下线结论-2026-05-06.md b/project-docs/shared-ai-platform-java 页面级验收与8012下线结论-2026-05-06.md new file mode 100644 index 00000000..37cca9c1 --- /dev/null +++ b/project-docs/shared-ai-platform-java 页面级验收与8012下线结论-2026-05-06.md @@ -0,0 +1,159 @@ +# `shared-ai-platform-java` 页面级验收与 `8012` 下线结论 + +日期:2026-05-06 +适用项目:`D:\ai-contract-platform` + +--- + +## 1. 本轮结论 + +本轮已经把 `ai-contract-platform -> shared-ai-platform-java` 这条线从: + +- 接口联通 + +推进到了: + +- 真实页面链路可跑 +- 服务器 backend 已切到 Java 主线 +- 旧 Python `8012` 线已验证不是当前业务流量前提 + +--- + +## 2. 页面级验收结果 + +本轮通过真实页面跑通了以下链路: + +1. 登录页登录 +2. 审查详情页打开 +3. 从审查详情页进入 Chat 追问 +4. 知识库页面创建知识库 +5. 知识库页面上传文档 +6. 从知识库页面进入 Chat 问答 + +### 2.1 验收产物 + +目录: + +- `D:\shared-ai-platform\_runtime\page-acceptance` + +关键文件: + +1. [ai-contract-platform-page-acceptance-2026-05-06-ui-flow.json](D:/shared-ai-platform/_runtime/page-acceptance/ai-contract-platform-page-acceptance-2026-05-06-ui-flow.json) +2. [02-review-detail.png](D:/shared-ai-platform/_runtime/page-acceptance/02-review-detail.png) +3. [03-chat-from-review-detail.png](D:/shared-ai-platform/_runtime/page-acceptance/03-chat-from-review-detail.png) +4. [04-knowledge-upload.png](D:/shared-ai-platform/_runtime/page-acceptance/04-knowledge-upload.png) +5. [05-chat-from-knowledge.png](D:/shared-ai-platform/_runtime/page-acceptance/05-chat-from-knowledge.png) +6. [07-knowledge-chat-final.png](D:/shared-ai-platform/_runtime/page-acceptance/07-knowledge-chat-final.png) + +### 2.2 审查详情页结果 + +最新页面结果中,详情页已经不再把 fallback `Echo from ...` 暴露为用户结论。 +用户可见的结论已经回到规则审查主意见: + +- `检测到高风险条款,建议暂停签署并由法务重点复核。` + +### 2.3 审查 Chat 追问结果 + +从审查详情页点击“解释总体风险”后,Chat 页面能正常进入审查上下文模式,并能看到: + +1. 审查上下文卡片 +2. 助手输出 +3. 依据来源 + +### 2.4 知识库上传 / 问答结果 + +知识库页面已能完成: + +1. 创建知识库 +2. 上传文档 +3. 索引完成 +4. 继续问答 + +最终强校验结果: + +- `hasDocPhrase = true` +- `hasAutoRenew = true` +- `hasFileName = true` +- `hasPending = false` + +这说明知识库问答不只是“跳进了 `/chat`”,而是已经把上传文档中的实际内容带进回答。 + +--- + +## 3. 当前服务器真实状态 + +服务器:`192.168.3.90` + +### 3.1 Java 主线 + +- `shared-ai-platform-java` +- 端口:`18011` + +### 3.2 `ai-contract-platform` backend 当前配置 + +backend 容器当前已确认: + +- `SHARED_AI_PLATFORM_BASE_URL=http://192.168.3.90:18011` +- `SHARED_AI_PLATFORM_API_KEY=shared-ai-platform-admin-dev-token` + +并且 `/api/v1/system/shared-ai` 返回: + +- `mode = remote-preferred` +- `base_url = http://192.168.3.90:18011` +- `transport.sdk = vendored-python-sdk` + +--- + +## 4. `8012` 旧 Python 线是否可以下线 + +## 4.1 已做过的验证 + +已实际做过: + +1. 停掉旧容器: + - `ai-contract-platform-shared-ai-platform-1` +2. 停掉后,从 `ai-contract-platform-backend-1` 容器内仍成功执行: + - `seed-admin` + - `login` + - `/api/v1/system/shared-ai` + +停掉后的状态: + +- `shared-ai-platform-java` 仍健康 +- `ai-contract-platform-backend-1` 仍正常 +- 旧 Python 容器已退出 + +## 4.2 正式判断 + +### 对当前业务流量 + +**可以正式下线 `8012` 旧 Python 线。** + +原因: + +1. backend 已切到 `18011` +2. 页面级验收已通过 +3. 停掉旧容器后,backend 核心检查仍然通过 + +### 对仓库与脚本 + +虽然业务流量可以下线,但仓库内还有残留引用需要收口。 + +当前已知至少有一个 QA 脚本仍旧默认指向 `8012`: + +- [frontend/scripts/shared-ai-remote-browser-e2e.mjs](D:/ai-contract-platform/frontend/scripts/shared-ai-remote-browser-e2e.mjs) + +因此更准确的口径是: + +- **业务流量:可以正式下线** +- **代码仓库:还需要补最后一轮旧口径清理** + +--- + +## 5. 收口建议 + +建议按这个顺序继续: + +1. 把仓库里的 `8012` / `shared-ai-platform:8011` 默认值清到 `18011` +2. 逐步从 `docker-compose.server.yml` 中移除旧 Python `shared-ai-platform` 服务块 +3. 再做一次服务器 smoke,确认删除服务块后仍然稳定