feat: close shared-ai-platform-java integration and decommission 8012 path
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
@@ -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,确认删除服务块后仍然稳定
|
||||
Reference in New Issue
Block a user