feat: support runtime shared AI settings
This commit is contained in:
+56
-22
@@ -9,12 +9,17 @@ from app.core.config import settings
|
||||
from app.core.response import success_response
|
||||
from app.db import get_db_session
|
||||
from app.core.security import verify_password
|
||||
from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai_platform_client
|
||||
from app.clients.shared_ai_platform import (
|
||||
SharedAIPlatformClient,
|
||||
get_shared_ai_platform_client,
|
||||
resolve_shared_ai_platform_runtime_config,
|
||||
)
|
||||
from app.core.roles import ROLE_ADMIN
|
||||
from app.models.user import User
|
||||
from app.services.audit_log_service import AuditLogService
|
||||
from app.services.auth_service import AuthService, DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME
|
||||
from app.services.governance_service import GovernanceService
|
||||
from app.services.model_setting_service import ModelSettingService
|
||||
from app.services.template_service import TemplateService
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
@@ -638,7 +643,28 @@ def _check_object_storage_config() -> Dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def _check_shared_ai_config() -> Dict[str, Any]:
|
||||
def _has_model_settings_shared_ai_config(db: Optional[Session]) -> bool:
|
||||
if db is None:
|
||||
return False
|
||||
try:
|
||||
return ModelSettingService(db).has_any_shared_ai_platform_runtime_config()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _shared_ai_runtime_configured(db: Optional[Session]) -> bool:
|
||||
base_url = _clean_config_value(settings.shared_ai_platform_base_url)
|
||||
api_key = _clean_config_value(settings.shared_ai_platform_api_key)
|
||||
env_configured = (
|
||||
bool(base_url)
|
||||
and bool(api_key)
|
||||
and not _is_placeholder_config_value(base_url)
|
||||
and not _is_placeholder_config_value(api_key)
|
||||
)
|
||||
return env_configured or _has_model_settings_shared_ai_config(db)
|
||||
|
||||
|
||||
def _check_shared_ai_config(db: Optional[Session] = None) -> Dict[str, Any]:
|
||||
base_url = _clean_config_value(settings.shared_ai_platform_base_url)
|
||||
api_key = _clean_config_value(settings.shared_ai_platform_api_key)
|
||||
legal_review_enabled = settings.shared_ai_platform_legal_review_enabled
|
||||
@@ -646,7 +672,7 @@ def _check_shared_ai_config() -> Dict[str, Any]:
|
||||
base_url_configured = bool(base_url) and not _is_placeholder_config_value(base_url)
|
||||
api_key_configured = bool(api_key) and not _is_placeholder_config_value(api_key)
|
||||
|
||||
if base_url_configured and api_key_configured:
|
||||
if (base_url_configured and api_key_configured) or _has_model_settings_shared_ai_config(db):
|
||||
return _build_check(
|
||||
"shared_ai_config",
|
||||
"共享 AI 配置",
|
||||
@@ -898,19 +924,23 @@ def get_governance_service(db: Session = Depends(get_db_session)) -> GovernanceS
|
||||
return GovernanceService(db)
|
||||
|
||||
|
||||
def _build_shared_ai_status() -> Dict[str, Any]:
|
||||
remote_configured = bool(
|
||||
settings.shared_ai_platform_base_url.strip() and settings.shared_ai_platform_api_key.strip()
|
||||
)
|
||||
def _build_shared_ai_status(db: Optional[Session] = None, current_user: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
tenant_id = str((current_user or {}).get("tenant_id") or "").strip() or None
|
||||
runtime_config = resolve_shared_ai_platform_runtime_config(db, tenant_id) if db is not None else None
|
||||
remote_configured = runtime_config is not None
|
||||
mode = "remote-preferred" if remote_configured else "configuration-required"
|
||||
|
||||
return {
|
||||
"mode": mode,
|
||||
"remote_configured": remote_configured,
|
||||
"base_url": settings.shared_ai_platform_base_url.strip() or None,
|
||||
"api_prefix": settings.shared_ai_platform_api_prefix,
|
||||
"app_id": settings.shared_ai_platform_app_id,
|
||||
"timeout_seconds": settings.shared_ai_platform_timeout_seconds,
|
||||
"base_url": runtime_config.base_url if runtime_config else (settings.shared_ai_platform_base_url.strip() or None),
|
||||
"api_prefix": runtime_config.api_prefix if runtime_config else settings.shared_ai_platform_api_prefix,
|
||||
"app_id": runtime_config.app_id if runtime_config else settings.shared_ai_platform_app_id,
|
||||
"timeout_seconds": runtime_config.timeout_seconds if runtime_config else settings.shared_ai_platform_timeout_seconds,
|
||||
"config_source": runtime_config.source if runtime_config else None,
|
||||
"provider": runtime_config.provider if runtime_config else None,
|
||||
"model": runtime_config.model if runtime_config else None,
|
||||
"review_model": runtime_config.review_model if runtime_config else None,
|
||||
"transport": {
|
||||
"sdk": "local-shared-ai-sdk" if remote_configured else "disabled",
|
||||
"fallback": "disabled",
|
||||
@@ -924,8 +954,14 @@ def _build_shared_ai_status() -> Dict[str, Any]:
|
||||
"knowledge_bases": remote_configured,
|
||||
"knowledge_documents": remote_configured,
|
||||
"knowledge_retry": remote_configured,
|
||||
"legal_review_domain_pack": remote_configured and settings.shared_ai_platform_legal_review_enabled,
|
||||
"local_legal_review_fallback": remote_configured and settings.shared_ai_platform_legal_review_fallback_to_local,
|
||||
"legal_review_domain_pack": remote_configured
|
||||
and (runtime_config.legal_review_enabled if runtime_config else settings.shared_ai_platform_legal_review_enabled),
|
||||
"local_legal_review_fallback": remote_configured
|
||||
and (
|
||||
runtime_config.legal_review_fallback_to_local
|
||||
if runtime_config
|
||||
else settings.shared_ai_platform_legal_review_fallback_to_local
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1047,7 +1083,7 @@ def get_system_readiness(db: Session = Depends(get_db_session)):
|
||||
_check_database(db),
|
||||
_check_runtime_config(),
|
||||
_check_object_storage_config(),
|
||||
_check_shared_ai_config(),
|
||||
_check_shared_ai_config(db),
|
||||
_build_check("auth_seed", "默认管理员初始化", "ok", "默认管理员初始化入口可用", required=False),
|
||||
default_admin_password_check,
|
||||
]
|
||||
@@ -1068,12 +1104,7 @@ def get_system_readiness(db: Session = Depends(get_db_session)):
|
||||
"app_env": settings.app_env,
|
||||
"api_prefix": settings.api_v1_prefix,
|
||||
"file_storage_backend": (settings.file_storage_backend or "minio").strip().lower(),
|
||||
"shared_ai_remote_configured": bool(
|
||||
_clean_config_value(settings.shared_ai_platform_base_url)
|
||||
and _clean_config_value(settings.shared_ai_platform_api_key)
|
||||
and not _is_placeholder_config_value(settings.shared_ai_platform_base_url)
|
||||
and not _is_placeholder_config_value(settings.shared_ai_platform_api_key)
|
||||
),
|
||||
"shared_ai_remote_configured": _shared_ai_runtime_configured(db),
|
||||
"shared_ai_legal_review_enabled": settings.shared_ai_platform_legal_review_enabled,
|
||||
},
|
||||
"checks": checks,
|
||||
@@ -1093,8 +1124,11 @@ def get_system_readiness(db: Session = Depends(get_db_session)):
|
||||
|
||||
|
||||
@router.get("/shared-ai")
|
||||
def get_shared_ai_status(_current_user: dict = Depends(require_current_user)):
|
||||
return success_response(_build_shared_ai_status())
|
||||
def get_shared_ai_status(
|
||||
current_user: dict = Depends(require_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
return success_response(_build_shared_ai_status(db, current_user))
|
||||
|
||||
|
||||
@router.get("/p0-mainline")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import inspect
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional, Protocol
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
@@ -10,6 +11,7 @@ from app.db import get_db_session
|
||||
from app.repositories.conversation_repository import ConversationRepository
|
||||
from app.repositories.message_repository import MessageRepository
|
||||
from app.services.contract_content_normalizer import normalize_contract_content
|
||||
from app.services.model_setting_service import ModelSettingService
|
||||
from shared_ai_platform_sdk import RequestContext as SharedAIRequestContext
|
||||
from shared_ai_platform_sdk import SharedAIPlatformClient as SharedAIPlatformSdkClient
|
||||
from shared_ai_platform_sdk import SharedAIPlatformError
|
||||
@@ -20,6 +22,33 @@ REMOTE_CHAT_TEMPLATE_KEYS = {
|
||||
"site-chat.customer-service",
|
||||
}
|
||||
|
||||
PLACEHOLDER_SHARED_AI_CONFIG_VALUES = {
|
||||
"change-me",
|
||||
"change_me",
|
||||
"changeme",
|
||||
"replace-me",
|
||||
"replace_me",
|
||||
"todo",
|
||||
"tbd",
|
||||
"change_me_shared_ai_api_key",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SharedAIPlatformRuntimeConfig:
|
||||
base_url: str
|
||||
api_key: str
|
||||
app_id: str
|
||||
api_prefix: str
|
||||
timeout_seconds: float
|
||||
source: str
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
review_model: Optional[str] = None
|
||||
legal_review_enabled: bool = True
|
||||
legal_review_fallback_to_local: bool = False
|
||||
document_extraction_enabled: bool = True
|
||||
|
||||
|
||||
class SharedAIPlatformClient(Protocol):
|
||||
def stream_answer(
|
||||
@@ -92,18 +121,29 @@ class SharedAIPlatformClient(Protocol):
|
||||
|
||||
|
||||
class RemoteSharedAIPlatformClient:
|
||||
def __init__(self, base_url: str, app_id: str, api_key: str, timeout_seconds: float, db: Optional[Session] = None):
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
app_id: str,
|
||||
api_key: str,
|
||||
timeout_seconds: float,
|
||||
db: Optional[Session] = None,
|
||||
api_prefix: Optional[str] = None,
|
||||
runtime_config: Optional[SharedAIPlatformRuntimeConfig] = None,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.app_id = app_id
|
||||
self.api_key = api_key
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.db = db
|
||||
self.runtime_config = runtime_config
|
||||
self.api_prefix = (api_prefix or settings.shared_ai_platform_api_prefix).strip() or "/api/v0.2"
|
||||
self.sdk = SharedAIPlatformSdkClient(
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
app_id=self.app_id,
|
||||
timeout_seconds=self.timeout_seconds,
|
||||
api_prefix=settings.shared_ai_platform_api_prefix,
|
||||
api_prefix=self.api_prefix,
|
||||
)
|
||||
|
||||
def _context(self, current_user: Dict[str, Any]) -> SharedAIRequestContext:
|
||||
@@ -122,7 +162,9 @@ class RemoteSharedAIPlatformClient:
|
||||
template_key: Optional[str] = None,
|
||||
) -> Iterable[str]:
|
||||
review_context_payload = self._normalize_review_context(review_context)
|
||||
review_context_payload = self._merge_runtime_model_context(review_context_payload)
|
||||
remote_template_key = self._resolve_remote_chat_template_key(template_key)
|
||||
provider = self._resolve_provider(review_context_payload)
|
||||
|
||||
def _event_stream():
|
||||
attempt_template_keys = [remote_template_key]
|
||||
@@ -139,6 +181,7 @@ class RemoteSharedAIPlatformClient:
|
||||
self._context(current_user),
|
||||
message=message,
|
||||
chat_context=review_context_payload or {},
|
||||
provider=provider,
|
||||
conversation_id=conversation_id,
|
||||
template_key=active_template_key,
|
||||
):
|
||||
@@ -247,13 +290,14 @@ class RemoteSharedAIPlatformClient:
|
||||
return self._fallback_get_conversation_detail(current_user, conversation_id, exc)
|
||||
|
||||
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
|
||||
provider = payload.get("provider") or (self.runtime_config.provider if self.runtime_config else None)
|
||||
return self.sdk.run_legal_review(
|
||||
self._context(current_user),
|
||||
title=payload.get("title") or "文本输入合同",
|
||||
text=payload.get("text") or "",
|
||||
contract_type=payload.get("contract_type"),
|
||||
review_as=payload.get("review_as") or "party_a",
|
||||
provider=payload.get("provider"),
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
def extract_document_text(self, current_user: Dict[str, Any], *, file_name: str, mime_type: Optional[str], content: bytes) -> dict:
|
||||
@@ -561,6 +605,37 @@ class RemoteSharedAIPlatformClient:
|
||||
return review_context.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
return review_context
|
||||
|
||||
def _merge_runtime_model_context(self, review_context: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if self.runtime_config is None:
|
||||
return review_context
|
||||
enriched = dict(review_context or {})
|
||||
runtime_payload = dict(enriched.get("modelRuntime") or enriched.get("model_runtime") or {})
|
||||
runtime_payload.setdefault("source", self.runtime_config.source)
|
||||
if self.runtime_config.provider:
|
||||
runtime_payload.setdefault("provider", self.runtime_config.provider)
|
||||
if self.runtime_config.model:
|
||||
runtime_payload.setdefault("model", self.runtime_config.model)
|
||||
if self.runtime_config.review_model:
|
||||
runtime_payload.setdefault("review_model", self.runtime_config.review_model)
|
||||
enriched["modelRuntime"] = runtime_payload
|
||||
enriched["model_runtime"] = runtime_payload
|
||||
metadata = dict(enriched.get("metadata") or {})
|
||||
metadata.setdefault("modelRuntime", runtime_payload)
|
||||
enriched["metadata"] = metadata
|
||||
return enriched
|
||||
|
||||
def _resolve_provider(self, review_context: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if isinstance(review_context, Mapping):
|
||||
runtime_payload = review_context.get("modelRuntime") or review_context.get("model_runtime")
|
||||
if isinstance(runtime_payload, Mapping) and runtime_payload.get("provider"):
|
||||
return str(runtime_payload.get("provider"))
|
||||
metadata = review_context.get("metadata")
|
||||
if isinstance(metadata, Mapping):
|
||||
metadata_runtime = metadata.get("modelRuntime") or metadata.get("model_runtime")
|
||||
if isinstance(metadata_runtime, Mapping) and metadata_runtime.get("provider"):
|
||||
return str(metadata_runtime.get("provider"))
|
||||
return self.runtime_config.provider if self.runtime_config else None
|
||||
|
||||
def _fallback_list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int, exc: Exception) -> dict:
|
||||
if self.db is None:
|
||||
raise HTTPException(status_code=503, detail=self._build_history_error_message(exc)) from exc
|
||||
@@ -811,22 +886,220 @@ def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> Shar
|
||||
return build_shared_ai_platform_client(db)
|
||||
|
||||
|
||||
def resolve_shared_ai_platform_runtime_config(
|
||||
db: Session,
|
||||
tenant_id: Optional[str] = None,
|
||||
) -> Optional[SharedAIPlatformRuntimeConfig]:
|
||||
if tenant_id:
|
||||
try:
|
||||
db_config = ModelSettingService(db).get_shared_ai_platform_runtime_config(str(tenant_id))
|
||||
except Exception:
|
||||
db_config = {}
|
||||
if db_config.get("configured"):
|
||||
return SharedAIPlatformRuntimeConfig(
|
||||
base_url=str(db_config.get("base_url") or "").strip(),
|
||||
api_key=str(db_config.get("api_key") or "").strip(),
|
||||
app_id=str(db_config.get("app_id") or settings.shared_ai_platform_app_id),
|
||||
api_prefix=str(db_config.get("api_prefix") or settings.shared_ai_platform_api_prefix),
|
||||
timeout_seconds=float(db_config.get("timeout_seconds") or settings.shared_ai_platform_timeout_seconds),
|
||||
source=str(db_config.get("source") or "model_settings"),
|
||||
provider=str(db_config.get("provider") or "") or None,
|
||||
model=str(db_config.get("model") or "") or None,
|
||||
review_model=str(db_config.get("review_model") or "") or None,
|
||||
legal_review_enabled=bool(db_config.get("legal_review_enabled")),
|
||||
legal_review_fallback_to_local=bool(db_config.get("legal_review_fallback_to_local")),
|
||||
document_extraction_enabled=bool(db_config.get("document_extraction_enabled")),
|
||||
)
|
||||
|
||||
def build_shared_ai_platform_client(db: Session) -> SharedAIPlatformClient:
|
||||
base_url = settings.shared_ai_platform_base_url.strip()
|
||||
api_key = settings.shared_ai_platform_api_key.strip()
|
||||
missing_parts = []
|
||||
if not base_url:
|
||||
missing_parts.append("SHARED_AI_PLATFORM_BASE_URL")
|
||||
if not api_key:
|
||||
missing_parts.append("SHARED_AI_PLATFORM_API_KEY")
|
||||
if missing_parts:
|
||||
return UnavailableSharedAIPlatformClient(f"缺少 {', '.join(missing_parts)}", db=db)
|
||||
|
||||
return RemoteSharedAIPlatformClient(
|
||||
if not _is_runtime_config_value_ready(base_url) or not _is_runtime_config_value_ready(api_key):
|
||||
return None
|
||||
return SharedAIPlatformRuntimeConfig(
|
||||
base_url=base_url,
|
||||
app_id=settings.shared_ai_platform_app_id,
|
||||
api_key=api_key,
|
||||
app_id=settings.shared_ai_platform_app_id,
|
||||
api_prefix=settings.shared_ai_platform_api_prefix,
|
||||
timeout_seconds=settings.shared_ai_platform_timeout_seconds,
|
||||
db=db,
|
||||
source="env",
|
||||
legal_review_enabled=settings.shared_ai_platform_legal_review_enabled,
|
||||
legal_review_fallback_to_local=settings.shared_ai_platform_legal_review_fallback_to_local,
|
||||
document_extraction_enabled=settings.shared_ai_platform_document_extraction_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _is_runtime_config_value_ready(value: str) -> bool:
|
||||
normalized = str(value or "").strip()
|
||||
return bool(normalized) and normalized.lower() not in PLACEHOLDER_SHARED_AI_CONFIG_VALUES
|
||||
|
||||
|
||||
class TenantAwareSharedAIPlatformClient:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def _client_for_user(self, current_user: Dict[str, Any]) -> SharedAIPlatformClient:
|
||||
tenant_id = str(current_user.get("tenant_id") or "").strip()
|
||||
runtime_config = resolve_shared_ai_platform_runtime_config(self.db, tenant_id)
|
||||
if runtime_config is None:
|
||||
return _build_unavailable_shared_ai_platform_client(self.db)
|
||||
return _build_remote_shared_ai_platform_client(self.db, runtime_config)
|
||||
|
||||
def stream_answer(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
conversation_id: Optional[str],
|
||||
message: str,
|
||||
review_context: Optional[Any] = None,
|
||||
template_key: Optional[str] = None,
|
||||
) -> Iterable[str]:
|
||||
return self._client_for_user(current_user).stream_answer(
|
||||
current_user,
|
||||
conversation_id,
|
||||
message,
|
||||
review_context,
|
||||
template_key=template_key,
|
||||
)
|
||||
|
||||
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
|
||||
return self._client_for_user(current_user).list_knowledge_bases(current_user, page, page_size)
|
||||
|
||||
def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
|
||||
return self._client_for_user(current_user).list_conversations(current_user, page, page_size)
|
||||
|
||||
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
|
||||
return self._client_for_user(current_user).get_conversation_detail(current_user, conversation_id)
|
||||
|
||||
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
|
||||
return self._client_for_user(current_user).run_legal_review(current_user, payload)
|
||||
|
||||
def extract_document_text(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
*,
|
||||
file_name: str,
|
||||
mime_type: Optional[str],
|
||||
content: bytes,
|
||||
) -> dict:
|
||||
return self._client_for_user(current_user).extract_document_text(
|
||||
current_user,
|
||||
file_name=file_name,
|
||||
mime_type=mime_type,
|
||||
content=content,
|
||||
)
|
||||
|
||||
def classify_pdf_source(self, current_user: Dict[str, Any], *, content: bytes) -> dict:
|
||||
return self._client_for_user(current_user).classify_pdf_source(current_user, content=content)
|
||||
|
||||
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict:
|
||||
return self._client_for_user(current_user).create_knowledge_base(current_user, name, description)
|
||||
|
||||
def list_knowledge_base_versions(self, current_user: Dict[str, Any], knowledge_base_id: str) -> list[dict]:
|
||||
return self._client_for_user(current_user).list_knowledge_base_versions(current_user, knowledge_base_id)
|
||||
|
||||
def publish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
|
||||
return self._client_for_user(current_user).publish_knowledge_base(current_user, knowledge_base_id)
|
||||
|
||||
def unpublish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
|
||||
return self._client_for_user(current_user).unpublish_knowledge_base(current_user, knowledge_base_id)
|
||||
|
||||
def rollback_knowledge_base(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
knowledge_base_id: str,
|
||||
target_version_label: str,
|
||||
change_summary: Optional[str] = None,
|
||||
) -> dict:
|
||||
return self._client_for_user(current_user).rollback_knowledge_base(
|
||||
current_user,
|
||||
knowledge_base_id,
|
||||
target_version_label,
|
||||
change_summary,
|
||||
)
|
||||
|
||||
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict:
|
||||
return self._client_for_user(current_user).list_documents(current_user, knowledge_base_id, page, page_size)
|
||||
|
||||
def upload_document(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
knowledge_base_id: str,
|
||||
file_name: str,
|
||||
mime_type: Optional[str],
|
||||
content: bytes,
|
||||
) -> dict:
|
||||
return self._client_for_user(current_user).upload_document(
|
||||
current_user,
|
||||
knowledge_base_id,
|
||||
file_name,
|
||||
mime_type,
|
||||
content,
|
||||
)
|
||||
|
||||
def retry_document(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
knowledge_base_id: str,
|
||||
document_id: str,
|
||||
file_name: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
content: Optional[bytes] = None,
|
||||
) -> dict:
|
||||
return self._client_for_user(current_user).retry_document(
|
||||
current_user,
|
||||
knowledge_base_id,
|
||||
document_id,
|
||||
file_name,
|
||||
mime_type,
|
||||
content,
|
||||
)
|
||||
|
||||
def enhance_contract_generation(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
*,
|
||||
prompt_runtime: Optional[Dict[str, Any]],
|
||||
prompt: str,
|
||||
draft_content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> dict:
|
||||
return self._client_for_user(current_user).enhance_contract_generation(
|
||||
current_user,
|
||||
prompt_runtime=prompt_runtime,
|
||||
prompt=prompt,
|
||||
draft_content=draft_content,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _build_remote_shared_ai_platform_client(
|
||||
db: Session,
|
||||
runtime_config: SharedAIPlatformRuntimeConfig,
|
||||
) -> RemoteSharedAIPlatformClient:
|
||||
return RemoteSharedAIPlatformClient(
|
||||
base_url=runtime_config.base_url,
|
||||
app_id=runtime_config.app_id,
|
||||
api_key=runtime_config.api_key,
|
||||
timeout_seconds=runtime_config.timeout_seconds,
|
||||
db=db,
|
||||
api_prefix=runtime_config.api_prefix,
|
||||
runtime_config=runtime_config,
|
||||
)
|
||||
|
||||
|
||||
def _build_unavailable_shared_ai_platform_client(db: Session) -> UnavailableSharedAIPlatformClient:
|
||||
missing_parts = []
|
||||
if not settings.shared_ai_platform_base_url.strip():
|
||||
missing_parts.append("SHARED_AI_PLATFORM_BASE_URL")
|
||||
if not settings.shared_ai_platform_api_key.strip():
|
||||
missing_parts.append("SHARED_AI_PLATFORM_API_KEY")
|
||||
if not missing_parts:
|
||||
missing_parts.append("model_settings.shared_ai_platform")
|
||||
return UnavailableSharedAIPlatformClient(f"缺少 {', '.join(missing_parts)}", db=db)
|
||||
|
||||
|
||||
|
||||
def build_shared_ai_platform_client(db: Session) -> SharedAIPlatformClient:
|
||||
runtime_config = resolve_shared_ai_platform_runtime_config(db)
|
||||
if runtime_config is not None and runtime_config.source == "env":
|
||||
return _build_remote_shared_ai_platform_client(db, runtime_config)
|
||||
return TenantAwareSharedAIPlatformClient(db)
|
||||
|
||||
@@ -20,6 +20,23 @@ SUPPORTED_PROVIDER_TYPES = {"openai", "anthropic"}
|
||||
SUPPORTED_CONFIG_FORMATS = {"openai_toml", "anthropic_env_json"}
|
||||
OPENAI_SECRET_FIELDS = {"api_key", "auth_token", "OPENAI_API_KEY", "SHARED_AI_OPENAI_API_KEY"}
|
||||
ANTHROPIC_SECRET_FIELDS = {"ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"}
|
||||
SHARED_AI_PLATFORM_SECRET_FIELDS = {
|
||||
"SHARED_AI_PLATFORM_API_KEY",
|
||||
"shared_ai_platform_api_key",
|
||||
"api_key_encrypted",
|
||||
}
|
||||
SHARED_AI_PLATFORM_CONFIG_KEYS = {
|
||||
"base_url",
|
||||
"api_key",
|
||||
"api_key_encrypted",
|
||||
"api_prefix",
|
||||
"app_id",
|
||||
"timeout_seconds",
|
||||
"provider",
|
||||
"legal_review_enabled",
|
||||
"legal_review_fallback_to_local",
|
||||
"document_extraction_enabled",
|
||||
}
|
||||
|
||||
|
||||
class ModelSettingService:
|
||||
@@ -59,6 +76,7 @@ class ModelSettingService:
|
||||
elif not token and existing and existing.auth_token_encrypted:
|
||||
token = self._decrypt_secret(existing.auth_token_encrypted)
|
||||
encrypted_token = self._encrypt_secret(token) if token else None
|
||||
config_json = self._prepare_config_json_for_storage(parsed["config_json"], existing)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if existing is None:
|
||||
@@ -87,7 +105,7 @@ class ModelSettingService:
|
||||
setting.reasoning_effort = parsed.get("reasoning_effort")
|
||||
setting.network_access = parsed.get("network_access")
|
||||
setting.auth_token_encrypted = encrypted_token
|
||||
setting.config_json = self._without_secrets(parsed["config_json"])
|
||||
setting.config_json = config_json
|
||||
setting.raw_config = self._mask_raw_config(normalized_format, normalized_raw)
|
||||
setting.updated_by = current_user.get("id")
|
||||
setting.updated_at = now
|
||||
@@ -106,15 +124,18 @@ class ModelSettingService:
|
||||
"provider_type": None,
|
||||
"env": {},
|
||||
"config": None,
|
||||
"shared_ai_platform": self._empty_shared_ai_platform_summary(),
|
||||
}
|
||||
token = self._decrypt_secret(setting.auth_token_encrypted)
|
||||
if setting.provider_type == "anthropic":
|
||||
env = self._build_anthropic_env(setting, token)
|
||||
env.update(self._build_shared_ai_platform_env(setting))
|
||||
return {
|
||||
"configured": bool(setting.enabled and env.get("ANTHROPIC_BASE_URL") and env.get("ANTHROPIC_AUTH_TOKEN")),
|
||||
"provider_type": "anthropic",
|
||||
"env": env,
|
||||
"config": None,
|
||||
"shared_ai_platform": self.get_shared_ai_platform_runtime_config(setting.tenant_id),
|
||||
}
|
||||
env = self._build_openai_shared_ai_env(setting, token)
|
||||
return {
|
||||
@@ -122,8 +143,53 @@ class ModelSettingService:
|
||||
"provider_type": "openai",
|
||||
"env": env,
|
||||
"config": self._build_openai_codex_config(setting, token),
|
||||
"shared_ai_platform": self.get_shared_ai_platform_runtime_config(setting.tenant_id),
|
||||
}
|
||||
|
||||
def get_shared_ai_platform_runtime_config(self, tenant_id: str) -> dict:
|
||||
setting = self._get_active_setting(tenant_id)
|
||||
if setting is None or not setting.enabled:
|
||||
return self._empty_shared_ai_platform_summary()
|
||||
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
|
||||
configured = bool(config.get("base_url") and config.get("api_key"))
|
||||
return {
|
||||
"configured": configured,
|
||||
"source": "model_settings" if configured else None,
|
||||
"base_url": config.get("base_url"),
|
||||
"api_prefix": config.get("api_prefix") or settings.shared_ai_platform_api_prefix,
|
||||
"app_id": config.get("app_id") or settings.shared_ai_platform_app_id,
|
||||
"api_key": config.get("api_key") or "",
|
||||
"has_api_key": bool(config.get("api_key")),
|
||||
"api_key_preview": self._preview_secret(str(config.get("api_key") or "")),
|
||||
"timeout_seconds": self._coerce_float(
|
||||
config.get("timeout_seconds"),
|
||||
settings.shared_ai_platform_timeout_seconds,
|
||||
),
|
||||
"provider": config.get("provider") or setting.provider_type,
|
||||
"model_provider": setting.model_provider,
|
||||
"model": setting.model,
|
||||
"review_model": setting.review_model or setting.model,
|
||||
"legal_review_enabled": self._coerce_bool(
|
||||
config.get("legal_review_enabled"),
|
||||
settings.shared_ai_platform_legal_review_enabled,
|
||||
),
|
||||
"legal_review_fallback_to_local": self._coerce_bool(
|
||||
config.get("legal_review_fallback_to_local"),
|
||||
settings.shared_ai_platform_legal_review_fallback_to_local,
|
||||
),
|
||||
"document_extraction_enabled": self._coerce_bool(
|
||||
config.get("document_extraction_enabled"),
|
||||
settings.shared_ai_platform_document_extraction_enabled,
|
||||
),
|
||||
}
|
||||
|
||||
def has_any_shared_ai_platform_runtime_config(self) -> bool:
|
||||
for setting in self.db.query(ModelSetting).filter(ModelSetting.enabled.is_(True)).all():
|
||||
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
|
||||
if config.get("base_url") and config.get("api_key"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_active_setting(self, tenant_id: str) -> Optional[ModelSetting]:
|
||||
return (
|
||||
self.db.query(ModelSetting)
|
||||
@@ -179,6 +245,10 @@ class ModelSettingService:
|
||||
provider_name: dict(provider_section),
|
||||
},
|
||||
"features": data.get("features") if isinstance(data.get("features"), Mapping) else {},
|
||||
"shared_ai_platform": self._parse_shared_ai_platform_config(
|
||||
data.get("shared_ai_platform") if isinstance(data.get("shared_ai_platform"), Mapping) else {},
|
||||
fallback=data,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"provider_type": "openai",
|
||||
@@ -205,7 +275,13 @@ class ModelSettingService:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="env must be an object")
|
||||
base_url = self._required_str(env.get("ANTHROPIC_BASE_URL"), "ANTHROPIC_BASE_URL")
|
||||
token = self._optional_str(env.get("ANTHROPIC_AUTH_TOKEN") or env.get("ANTHROPIC_API_KEY"))
|
||||
config_json = {"env": {key: str(value) for key, value in env.items() if value is not None}}
|
||||
config_json = {
|
||||
"env": {key: str(value) for key, value in env.items() if value is not None},
|
||||
"shared_ai_platform": self._parse_shared_ai_platform_config(
|
||||
payload.get("shared_ai_platform") if isinstance(payload.get("shared_ai_platform"), Mapping) else {},
|
||||
fallback=env,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"provider_type": "anthropic",
|
||||
"model_provider": "Anthropic",
|
||||
@@ -296,6 +372,7 @@ class ModelSettingService:
|
||||
"has_auth_token": bool(token),
|
||||
"auth_token_preview": self._preview_secret(token),
|
||||
"config_json": self._without_secrets(setting.config_json or {}),
|
||||
"shared_ai_platform": self._shared_ai_platform_public_summary(setting),
|
||||
"created_at": setting.created_at.isoformat() if setting.created_at else None,
|
||||
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
|
||||
}
|
||||
@@ -322,6 +399,7 @@ class ModelSettingService:
|
||||
"has_auth_token": False,
|
||||
"auth_token_preview": None,
|
||||
"config_json": {},
|
||||
"shared_ai_platform": ModelSettingService._empty_shared_ai_platform_summary(),
|
||||
"raw_config": "",
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
@@ -329,7 +407,7 @@ class ModelSettingService:
|
||||
|
||||
def _build_openai_shared_ai_env(self, setting: ModelSetting, token: str) -> dict[str, str]:
|
||||
config_json = setting.config_json or {}
|
||||
return {
|
||||
env = {
|
||||
"SHARED_AI_DEFAULT_PROVIDER": "openai_compatible",
|
||||
"SHARED_AI_OPENAI_BASE_URL": setting.base_url or "",
|
||||
"SHARED_AI_OPENAI_API_KEY": token,
|
||||
@@ -338,6 +416,8 @@ class ModelSettingService:
|
||||
"SHARED_AI_OPENAI_MAX_TOKENS": str(config_json.get("max_tokens") or "1200"),
|
||||
"SHARED_AI_OPENAI_TIMEOUT_SECONDS": str(config_json.get("timeout_seconds") or settings.shared_ai_platform_timeout_seconds),
|
||||
}
|
||||
env.update(self._build_shared_ai_platform_env(setting))
|
||||
return env
|
||||
|
||||
@staticmethod
|
||||
def _build_anthropic_env(setting: ModelSetting, token: str) -> dict[str, str]:
|
||||
@@ -346,6 +426,21 @@ class ModelSettingService:
|
||||
env["ANTHROPIC_AUTH_TOKEN"] = token or env.get("ANTHROPIC_AUTH_TOKEN") or ""
|
||||
return {str(key): str(value) for key, value in env.items() if value is not None}
|
||||
|
||||
def _build_shared_ai_platform_env(self, setting: ModelSetting) -> dict[str, str]:
|
||||
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
|
||||
if not config:
|
||||
return {}
|
||||
env = {
|
||||
"SHARED_AI_PLATFORM_BASE_URL": str(config.get("base_url") or ""),
|
||||
"SHARED_AI_PLATFORM_API_KEY": str(config.get("api_key") or ""),
|
||||
"SHARED_AI_PLATFORM_APP_ID": str(config.get("app_id") or settings.shared_ai_platform_app_id),
|
||||
"SHARED_AI_PLATFORM_API_PREFIX": str(config.get("api_prefix") or settings.shared_ai_platform_api_prefix),
|
||||
"SHARED_AI_PLATFORM_TIMEOUT_SECONDS": str(
|
||||
self._coerce_float(config.get("timeout_seconds"), settings.shared_ai_platform_timeout_seconds)
|
||||
),
|
||||
}
|
||||
return {key: value for key, value in env.items() if value}
|
||||
|
||||
@staticmethod
|
||||
def _build_openai_codex_config(setting: ModelSetting, token: str) -> dict[str, Any]:
|
||||
provider_name = setting.model_provider or "OpenAI"
|
||||
@@ -389,7 +484,11 @@ class ModelSettingService:
|
||||
if isinstance(value, Mapping):
|
||||
masked: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
if str(key) in OPENAI_SECRET_FIELDS or str(key) in ANTHROPIC_SECRET_FIELDS:
|
||||
if (
|
||||
str(key) in OPENAI_SECRET_FIELDS
|
||||
or str(key) in ANTHROPIC_SECRET_FIELDS
|
||||
or str(key) in SHARED_AI_PLATFORM_SECRET_FIELDS
|
||||
):
|
||||
masked[str(key)] = "***" if item else ""
|
||||
else:
|
||||
masked[str(key)] = self._without_secrets(item)
|
||||
@@ -436,3 +535,147 @@ class ModelSettingService:
|
||||
if len(value) <= 8:
|
||||
return "***"
|
||||
return f"{value[:4]}...{value[-4:]}"
|
||||
|
||||
def _prepare_config_json_for_storage(self, parsed_config_json: Mapping[str, Any], existing: Optional[ModelSetting]) -> dict:
|
||||
config_json = dict(parsed_config_json)
|
||||
shared_config = dict(config_json.get("shared_ai_platform") or {})
|
||||
shared_api_key = self._resolve_shared_ai_platform_api_key(shared_config, existing)
|
||||
shared_config.pop("api_key", None)
|
||||
if shared_api_key:
|
||||
shared_config["api_key_encrypted"] = self._encrypt_secret(shared_api_key)
|
||||
elif "api_key_encrypted" in shared_config:
|
||||
shared_config.pop("api_key_encrypted", None)
|
||||
if self._has_shared_ai_platform_config_values(shared_config):
|
||||
config_json["shared_ai_platform"] = shared_config
|
||||
else:
|
||||
config_json.pop("shared_ai_platform", None)
|
||||
return config_json
|
||||
|
||||
def _resolve_shared_ai_platform_api_key(self, shared_config: Mapping[str, Any], existing: Optional[ModelSetting]) -> str:
|
||||
raw_key = str(shared_config.get("api_key") or "").strip()
|
||||
if raw_key and raw_key not in {"***", MASKED_SECRET}:
|
||||
return raw_key
|
||||
if existing is None:
|
||||
return ""
|
||||
return self._shared_ai_platform_config_from_setting(existing, include_secret=True).get("api_key") or ""
|
||||
|
||||
def _parse_shared_ai_platform_config(
|
||||
self,
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
fallback: Optional[Mapping[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
fallback = fallback or {}
|
||||
config = {
|
||||
"base_url": self._optional_str(
|
||||
value.get("base_url")
|
||||
or value.get("SHARED_AI_PLATFORM_BASE_URL")
|
||||
or fallback.get("SHARED_AI_PLATFORM_BASE_URL")
|
||||
or fallback.get("shared_ai_platform_base_url")
|
||||
),
|
||||
"api_key": self._optional_str(
|
||||
value.get("api_key")
|
||||
or value.get("auth_token")
|
||||
or value.get("SHARED_AI_PLATFORM_API_KEY")
|
||||
or fallback.get("SHARED_AI_PLATFORM_API_KEY")
|
||||
or fallback.get("shared_ai_platform_api_key")
|
||||
),
|
||||
"api_prefix": self._optional_str(
|
||||
value.get("api_prefix")
|
||||
or value.get("SHARED_AI_PLATFORM_API_PREFIX")
|
||||
or fallback.get("SHARED_AI_PLATFORM_API_PREFIX")
|
||||
or fallback.get("shared_ai_platform_api_prefix")
|
||||
),
|
||||
"app_id": self._optional_str(
|
||||
value.get("app_id")
|
||||
or value.get("SHARED_AI_PLATFORM_APP_ID")
|
||||
or fallback.get("SHARED_AI_PLATFORM_APP_ID")
|
||||
or fallback.get("shared_ai_platform_app_id")
|
||||
),
|
||||
"timeout_seconds": value.get("timeout_seconds")
|
||||
or value.get("SHARED_AI_PLATFORM_TIMEOUT_SECONDS")
|
||||
or fallback.get("SHARED_AI_PLATFORM_TIMEOUT_SECONDS")
|
||||
or fallback.get("shared_ai_platform_timeout_seconds"),
|
||||
"provider": self._optional_str(value.get("provider") or fallback.get("SHARED_AI_DEFAULT_PROVIDER")),
|
||||
"legal_review_enabled": value.get("legal_review_enabled"),
|
||||
"legal_review_fallback_to_local": value.get("legal_review_fallback_to_local"),
|
||||
"document_extraction_enabled": value.get("document_extraction_enabled"),
|
||||
}
|
||||
return {key: item for key, item in config.items() if item not in (None, "")}
|
||||
|
||||
def _shared_ai_platform_config_from_setting(self, setting: ModelSetting, *, include_secret: bool = False) -> dict[str, Any]:
|
||||
raw_config = (setting.config_json or {}).get("shared_ai_platform")
|
||||
if not isinstance(raw_config, Mapping):
|
||||
return {}
|
||||
config = {key: raw_config.get(key) for key in SHARED_AI_PLATFORM_CONFIG_KEYS if raw_config.get(key) not in (None, "")}
|
||||
if include_secret:
|
||||
encrypted = str(raw_config.get("api_key_encrypted") or "")
|
||||
if encrypted:
|
||||
config["api_key"] = self._decrypt_secret(encrypted)
|
||||
return config
|
||||
|
||||
def _shared_ai_platform_public_summary(self, setting: ModelSetting) -> dict[str, Any]:
|
||||
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
|
||||
if not config:
|
||||
return self._empty_shared_ai_platform_summary()
|
||||
return {
|
||||
"configured": bool(setting.enabled and config.get("base_url") and config.get("api_key")),
|
||||
"source": "model_settings",
|
||||
"base_url": config.get("base_url"),
|
||||
"api_prefix": config.get("api_prefix") or settings.shared_ai_platform_api_prefix,
|
||||
"app_id": config.get("app_id") or settings.shared_ai_platform_app_id,
|
||||
"has_api_key": bool(config.get("api_key")),
|
||||
"api_key_preview": self._preview_secret(str(config.get("api_key") or "")),
|
||||
"timeout_seconds": self._coerce_float(config.get("timeout_seconds"), settings.shared_ai_platform_timeout_seconds),
|
||||
"provider": config.get("provider") or setting.provider_type,
|
||||
"legal_review_enabled": self._coerce_bool(config.get("legal_review_enabled"), settings.shared_ai_platform_legal_review_enabled),
|
||||
"legal_review_fallback_to_local": self._coerce_bool(
|
||||
config.get("legal_review_fallback_to_local"),
|
||||
settings.shared_ai_platform_legal_review_fallback_to_local,
|
||||
),
|
||||
"document_extraction_enabled": self._coerce_bool(
|
||||
config.get("document_extraction_enabled"),
|
||||
settings.shared_ai_platform_document_extraction_enabled,
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _empty_shared_ai_platform_summary() -> dict[str, Any]:
|
||||
return {
|
||||
"configured": False,
|
||||
"source": None,
|
||||
"base_url": None,
|
||||
"api_prefix": None,
|
||||
"app_id": None,
|
||||
"has_api_key": False,
|
||||
"api_key_preview": None,
|
||||
"timeout_seconds": None,
|
||||
"provider": None,
|
||||
"legal_review_enabled": None,
|
||||
"legal_review_fallback_to_local": None,
|
||||
"document_extraction_enabled": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _has_shared_ai_platform_config_values(value: Mapping[str, Any]) -> bool:
|
||||
return any(value.get(key) not in (None, "") for key in SHARED_AI_PLATFORM_CONFIG_KEYS)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_bool(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return bool(default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(default)
|
||||
|
||||
@@ -187,7 +187,7 @@ class PromptAssetService:
|
||||
self.run_link_repository = PromptAssetRunLinkRepository(db)
|
||||
self.evaluation_run_repository = PromptAssetEvaluationRunRepository(db)
|
||||
self.quality_eval_service = QualityEvalService(db) if db is not None else None
|
||||
self.golden_sample_evaluator = PromptGoldenSampleEvaluator()
|
||||
self.golden_sample_evaluator = PromptGoldenSampleEvaluator(db=db)
|
||||
|
||||
def get_first_priority_blueprint(self, current_user: Dict[str, Any]) -> dict[str, Any]:
|
||||
self._require_admin(current_user)
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, Optional
|
||||
|
||||
from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient
|
||||
from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient, build_shared_ai_platform_client
|
||||
from app.core.config import settings
|
||||
from app.models.prompt_asset import PromptAsset
|
||||
from app.models.prompt_asset_version import PromptAssetVersion
|
||||
@@ -59,6 +59,7 @@ class PromptGoldenSampleEvaluator:
|
||||
*,
|
||||
repo_root: Optional[Path | str] = None,
|
||||
llm_client: Optional[Any] = None,
|
||||
db: Optional[Any] = None,
|
||||
evaluation_mode: Optional[str] = None,
|
||||
include_raw_model_response: Optional[bool] = None,
|
||||
):
|
||||
@@ -66,6 +67,7 @@ class PromptGoldenSampleEvaluator:
|
||||
manifest_path = resolved_root / "project-docs" / "phase15-prompt-golden-samples" / "prompt-golden-suite-manifest.json"
|
||||
self.repo_root = resolved_root if manifest_path.exists() else Path(__file__).resolve().parents[3]
|
||||
self.llm_client = llm_client
|
||||
self.db = db
|
||||
self.evaluation_mode = self._normalize_mode(evaluation_mode or settings.prompt_golden_eval_mode)
|
||||
self.include_raw_model_response = (
|
||||
settings.prompt_golden_eval_include_raw_model_response
|
||||
@@ -416,6 +418,8 @@ class PromptGoldenSampleEvaluator:
|
||||
return None
|
||||
if self.llm_client is not None:
|
||||
return self.llm_client
|
||||
if self.db is not None:
|
||||
return build_shared_ai_platform_client(self.db)
|
||||
if not settings.shared_ai_platform_base_url.strip() or not settings.shared_ai_platform_api_key.strip():
|
||||
return None
|
||||
return RemoteSharedAIPlatformClient(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -25,6 +26,17 @@ base_url = "https://sub2api.example.test"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
|
||||
[shared_ai_platform]
|
||||
base_url = "http://shared-ai.example.test"
|
||||
api_key = "shared-platform-key"
|
||||
api_prefix = "/api/v0.2"
|
||||
app_id = "ai-contract-platform-test"
|
||||
timeout_seconds = 45
|
||||
provider = "openai"
|
||||
legal_review_enabled = true
|
||||
legal_review_fallback_to_local = true
|
||||
document_extraction_enabled = true
|
||||
|
||||
[features]
|
||||
goals = true
|
||||
'''
|
||||
@@ -36,7 +48,18 @@ ANTHROPIC_JSON = {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-test-anthropic",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0",
|
||||
}
|
||||
},
|
||||
"shared_ai_platform": {
|
||||
"base_url": "http://shared-ai-anthropic.example.test",
|
||||
"api_key": "shared-platform-anthropic-key",
|
||||
"api_prefix": "/api/v0.2",
|
||||
"app_id": "ai-contract-platform-test",
|
||||
"timeout_seconds": 50,
|
||||
"provider": "anthropic",
|
||||
"legal_review_enabled": True,
|
||||
"legal_review_fallback_to_local": False,
|
||||
"document_extraction_enabled": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +125,15 @@ class ModelSettingsApiTestCase(unittest.TestCase):
|
||||
self.assertEqual(body["wire_api"], "responses")
|
||||
self.assertEqual(body["reasoning_effort"], "xhigh")
|
||||
self.assertTrue(body["has_auth_token"])
|
||||
self.assertTrue(body["shared_ai_platform"]["configured"])
|
||||
self.assertEqual(body["shared_ai_platform"]["base_url"], "http://shared-ai.example.test")
|
||||
self.assertEqual(body["shared_ai_platform"]["api_prefix"], "/api/v0.2")
|
||||
self.assertEqual(body["shared_ai_platform"]["app_id"], "ai-contract-platform-test")
|
||||
self.assertEqual(body["shared_ai_platform"]["provider"], "openai")
|
||||
self.assertTrue(body["shared_ai_platform"]["legal_review_fallback_to_local"])
|
||||
self.assertNotIn("shared-platform-key", json.dumps(body["config_json"], ensure_ascii=False))
|
||||
self.assertNotIn("sk-test-openai", body["raw_config"])
|
||||
self.assertNotIn("shared-platform-key", body["raw_config"])
|
||||
|
||||
export_response = self.client.get("/api/v1/model-settings/runtime-export")
|
||||
|
||||
@@ -112,13 +143,22 @@ class ModelSettingsApiTestCase(unittest.TestCase):
|
||||
self.assertEqual(exported["env"]["SHARED_AI_OPENAI_BASE_URL"], "https://sub2api.example.test")
|
||||
self.assertEqual(exported["env"]["SHARED_AI_OPENAI_API_KEY"], "sk-test-openai")
|
||||
self.assertEqual(exported["env"]["SHARED_AI_OPENAI_MODEL"], "gpt-5.5")
|
||||
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_BASE_URL"], "http://shared-ai.example.test")
|
||||
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_API_KEY"], "shared-platform-key")
|
||||
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_APP_ID"], "ai-contract-platform-test")
|
||||
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_API_PREFIX"], "/api/v0.2")
|
||||
self.assertEqual(exported["shared_ai_platform"]["api_key"], "shared-platform-key")
|
||||
self.assertEqual(exported["config"]["model_providers"]["OpenAI"]["wire_api"], "responses")
|
||||
|
||||
updated_toml = OPENAI_TOML.replace("gpt-5.5", "gpt-5.6").replace(
|
||||
'api_key = "shared-platform-key"',
|
||||
'api_key = ""',
|
||||
)
|
||||
update_response = self.client.put(
|
||||
"/api/v1/model-settings",
|
||||
json={
|
||||
"config_format": "openai_toml",
|
||||
"raw_config": OPENAI_TOML.replace("gpt-5.5", "gpt-5.6"),
|
||||
"raw_config": updated_toml,
|
||||
"display_name": "Primary OpenAI compatible",
|
||||
"auth_token": "",
|
||||
},
|
||||
@@ -129,6 +169,7 @@ class ModelSettingsApiTestCase(unittest.TestCase):
|
||||
preserved_exported = preserved_export_response.json()["data"]
|
||||
self.assertEqual(preserved_exported["env"]["SHARED_AI_OPENAI_API_KEY"], "sk-test-openai")
|
||||
self.assertEqual(preserved_exported["env"]["SHARED_AI_OPENAI_MODEL"], "gpt-5.6")
|
||||
self.assertEqual(preserved_exported["env"]["SHARED_AI_PLATFORM_API_KEY"], "shared-platform-key")
|
||||
|
||||
def test_admin_can_save_anthropic_env_json(self):
|
||||
self._as_admin()
|
||||
@@ -137,7 +178,7 @@ class ModelSettingsApiTestCase(unittest.TestCase):
|
||||
"/api/v1/model-settings",
|
||||
json={
|
||||
"config_format": "anthropic_env_json",
|
||||
"raw_config": __import__("json").dumps(ANTHROPIC_JSON),
|
||||
"raw_config": json.dumps(ANTHROPIC_JSON),
|
||||
"display_name": "Claude compatible",
|
||||
},
|
||||
)
|
||||
@@ -147,8 +188,13 @@ class ModelSettingsApiTestCase(unittest.TestCase):
|
||||
self.assertEqual(body["provider_type"], "anthropic")
|
||||
self.assertEqual(body["base_url"], "https://sub2api.example.test")
|
||||
self.assertTrue(body["has_auth_token"])
|
||||
self.assertTrue(body["shared_ai_platform"]["configured"])
|
||||
self.assertEqual(body["shared_ai_platform"]["base_url"], "http://shared-ai-anthropic.example.test")
|
||||
self.assertEqual(body["shared_ai_platform"]["provider"], "anthropic")
|
||||
self.assertEqual(body["config_json"]["env"]["ANTHROPIC_AUTH_TOKEN"], "***")
|
||||
self.assertEqual(body["config_json"]["shared_ai_platform"]["api_key_encrypted"], "***")
|
||||
self.assertNotIn("sk-test-anthropic", body["raw_config"])
|
||||
self.assertNotIn("shared-platform-anthropic-key", body["raw_config"])
|
||||
|
||||
export_response = self.client.get("/api/v1/model-settings/runtime-export")
|
||||
|
||||
@@ -157,6 +203,7 @@ class ModelSettingsApiTestCase(unittest.TestCase):
|
||||
self.assertEqual(exported["provider_type"], "anthropic")
|
||||
self.assertEqual(exported["env"]["ANTHROPIC_BASE_URL"], "https://sub2api.example.test")
|
||||
self.assertEqual(exported["env"]["ANTHROPIC_AUTH_TOKEN"], "sk-test-anthropic")
|
||||
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_API_KEY"], "shared-platform-anthropic-key")
|
||||
self.assertEqual(exported["env"]["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], "1")
|
||||
|
||||
def test_member_cannot_access_model_settings(self):
|
||||
|
||||
@@ -6,11 +6,17 @@ import httpx
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient, build_shared_ai_platform_client
|
||||
from app.clients.shared_ai_platform import (
|
||||
RemoteSharedAIPlatformClient,
|
||||
SharedAIPlatformRuntimeConfig,
|
||||
build_shared_ai_platform_client,
|
||||
resolve_shared_ai_platform_runtime_config,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.db import Base
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.services.model_setting_service import ModelSettingService
|
||||
from shared_ai_platform_sdk import SharedAIPlatformError
|
||||
from shared_ai_platform_sdk.client import SharedAIPlatformClient as SharedAiPlatformSdkClient
|
||||
from shared_ai_platform_sdk.types import RequestContext, SSEEvent
|
||||
@@ -163,6 +169,107 @@ class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase):
|
||||
settings.shared_ai_platform_base_url = original_base_url
|
||||
settings.shared_ai_platform_api_key = original_api_key
|
||||
|
||||
def test_runtime_config_can_be_resolved_from_model_settings(self):
|
||||
original_base_url = settings.shared_ai_platform_base_url
|
||||
original_api_key = settings.shared_ai_platform_api_key
|
||||
settings.shared_ai_platform_base_url = ""
|
||||
settings.shared_ai_platform_api_key = ""
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine, future=True)()
|
||||
try:
|
||||
ModelSettingService(session).upsert_current_setting(
|
||||
{"id": "admin-1", "tenant_id": "tenant-1", "role": "admin"},
|
||||
config_format="openai_toml",
|
||||
raw_config='''
|
||||
model_provider = "OpenAI"
|
||||
model = "gpt-5.5"
|
||||
review_model = "gpt-5.5-review"
|
||||
|
||||
[model_providers.OpenAI]
|
||||
name = "OpenAI"
|
||||
base_url = "https://model.example.test"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
|
||||
[shared_ai_platform]
|
||||
base_url = "http://shared-ai.example.test"
|
||||
api_key = "tenant-shared-ai-key"
|
||||
api_prefix = "/api/v0.2"
|
||||
app_id = "ai-contract-platform-test"
|
||||
timeout_seconds = 42
|
||||
provider = "openai"
|
||||
legal_review_enabled = true
|
||||
legal_review_fallback_to_local = true
|
||||
document_extraction_enabled = false
|
||||
''',
|
||||
display_name="Tenant OpenAI",
|
||||
enabled=True,
|
||||
auth_token="model-key",
|
||||
)
|
||||
|
||||
runtime_config = resolve_shared_ai_platform_runtime_config(session, "tenant-1")
|
||||
|
||||
self.assertIsNotNone(runtime_config)
|
||||
assert runtime_config is not None
|
||||
self.assertEqual(runtime_config.source, "model_settings")
|
||||
self.assertEqual(runtime_config.base_url, "http://shared-ai.example.test")
|
||||
self.assertEqual(runtime_config.api_key, "tenant-shared-ai-key")
|
||||
self.assertEqual(runtime_config.app_id, "ai-contract-platform-test")
|
||||
self.assertEqual(runtime_config.timeout_seconds, 42.0)
|
||||
self.assertEqual(runtime_config.provider, "openai")
|
||||
self.assertEqual(runtime_config.model, "gpt-5.5")
|
||||
self.assertEqual(runtime_config.review_model, "gpt-5.5-review")
|
||||
self.assertTrue(runtime_config.legal_review_fallback_to_local)
|
||||
self.assertFalse(runtime_config.document_extraction_enabled)
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
settings.shared_ai_platform_base_url = original_base_url
|
||||
settings.shared_ai_platform_api_key = original_api_key
|
||||
|
||||
def test_remote_stream_includes_runtime_provider_and_model_context(self):
|
||||
calls = []
|
||||
|
||||
class StubSdk:
|
||||
def stream_conversation(self, context, *, message, chat_context=None, provider=None, template_key=None, conversation_id=None):
|
||||
calls.append(
|
||||
{
|
||||
"message": message,
|
||||
"chat_context": chat_context,
|
||||
"provider": provider,
|
||||
}
|
||||
)
|
||||
yield SSEEvent(event_type="delta", payload={"type": "delta", "content": "ok"})
|
||||
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,
|
||||
runtime_config=SharedAIPlatformRuntimeConfig(
|
||||
base_url="http://testserver",
|
||||
api_key="test-key",
|
||||
app_id="test-app",
|
||||
api_prefix="/api/v0.2",
|
||||
timeout_seconds=30.0,
|
||||
source="model_settings",
|
||||
provider="openai",
|
||||
model="gpt-5.5",
|
||||
review_model="gpt-5.5-review",
|
||||
),
|
||||
)
|
||||
client.sdk = StubSdk()
|
||||
|
||||
chunks = list(client.stream_answer({"id": "user-1", "tenant_id": "tenant-1", "role": "admin"}, None, "hello"))
|
||||
|
||||
self.assertEqual(chunks[0], 'data: {"type": "text", "content": "ok"}\n\n')
|
||||
self.assertEqual(calls[0]["provider"], "openai")
|
||||
self.assertEqual(calls[0]["chat_context"]["modelRuntime"]["model"], "gpt-5.5")
|
||||
self.assertEqual(calls[0]["chat_context"]["modelRuntime"]["review_model"], "gpt-5.5-review")
|
||||
self.assertEqual(calls[0]["chat_context"]["metadata"]["modelRuntime"]["source"], "model_settings")
|
||||
|
||||
def test_remote_stream_ignores_start_and_usage_and_maps_delta(self):
|
||||
calls = []
|
||||
|
||||
|
||||
@@ -507,6 +507,7 @@ class SystemApiTestCase(unittest.TestCase):
|
||||
"tenant_id": "tenant-demo-001",
|
||||
"role": "admin",
|
||||
}
|
||||
app.dependency_overrides[get_db_session] = lambda: StubReadyDb()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/system/shared-ai")
|
||||
@@ -534,6 +535,7 @@ class SystemApiTestCase(unittest.TestCase):
|
||||
"tenant_id": "tenant-demo-001",
|
||||
"role": "member",
|
||||
}
|
||||
app.dependency_overrides[get_db_session] = lambda: StubReadyDb()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/system/shared-ai")
|
||||
@@ -558,6 +560,7 @@ class SystemApiTestCase(unittest.TestCase):
|
||||
"tenant_id": "tenant-demo-001",
|
||||
"role": "member",
|
||||
}
|
||||
app.dependency_overrides[get_db_session] = lambda: StubReadyDb()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/system/shared-ai")
|
||||
|
||||
@@ -56,6 +56,17 @@ base_url = "https://sub2api.eqing.tech"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
|
||||
[shared_ai_platform]
|
||||
base_url = "https://shared-ai.example.com"
|
||||
api_key = ""
|
||||
app_id = "ai-contract-platform"
|
||||
api_prefix = "/api/v0.2"
|
||||
timeout_seconds = 30
|
||||
provider = "openai"
|
||||
legal_review_enabled = true
|
||||
legal_review_fallback_to_local = false
|
||||
document_extraction_enabled = true
|
||||
|
||||
[features]
|
||||
goals = true`;
|
||||
|
||||
@@ -65,6 +76,17 @@ const ANTHROPIC_TEMPLATE = `{
|
||||
"ANTHROPIC_AUTH_TOKEN": "",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
|
||||
},
|
||||
"shared_ai_platform": {
|
||||
"base_url": "https://shared-ai.example.com",
|
||||
"api_key": "",
|
||||
"app_id": "ai-contract-platform",
|
||||
"api_prefix": "/api/v0.2",
|
||||
"timeout_seconds": 30,
|
||||
"provider": "anthropic",
|
||||
"legal_review_enabled": true,
|
||||
"legal_review_fallback_to_local": false,
|
||||
"document_extraction_enabled": true
|
||||
}
|
||||
}`;
|
||||
|
||||
@@ -95,6 +117,9 @@ function formatRuntimeExport(exportData: ModelSettingRuntimeExportResponse | nul
|
||||
return "";
|
||||
}
|
||||
const parts = ["# Environment variables", formatEnv(exportData.env)];
|
||||
if (exportData.shared_ai_platform) {
|
||||
parts.push("", "# Contract platform shared AI runtime", JSON.stringify(exportData.shared_ai_platform, null, 2));
|
||||
}
|
||||
if (exportData.config) {
|
||||
parts.push("", "# Codex/OpenAI-compatible config JSON", JSON.stringify(exportData.config, null, 2));
|
||||
}
|
||||
@@ -257,6 +282,11 @@ export default function ModelSettingsPage() {
|
||||
<Space wrap>
|
||||
{setting?.enabled ? <Tag color="green">已启用</Tag> : <Tag color="default">未启用</Tag>}
|
||||
{setting?.has_auth_token ? <Tag color="blue">已保存密钥</Tag> : <Tag color="gold">未保存密钥</Tag>}
|
||||
{setting?.shared_ai_platform?.configured ? (
|
||||
<Tag color="cyan">合同 AI 已接通</Tag>
|
||||
) : (
|
||||
<Tag color="orange">合同 AI 待配置</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -268,10 +298,27 @@ export default function ModelSettingsPage() {
|
||||
<Descriptions.Item label="配置格式">{setting?.config_format || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Provider">{setting?.model_provider || getProviderLabel(setting?.provider_type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Base URL">{setting?.base_url || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="合同系统 AI 平台">
|
||||
{setting?.shared_ai_platform?.configured ? <Tag color="green">已接通</Tag> : <Tag color="gold">待配置</Tag>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="AI 平台地址">{setting?.shared_ai_platform?.base_url || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="AI API Prefix">{setting?.shared_ai_platform?.api_prefix || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="AI App ID">{setting?.shared_ai_platform?.app_id || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="AI Provider">{setting?.shared_ai_platform?.provider || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="AI API Key">
|
||||
{setting?.shared_ai_platform?.api_key_preview ||
|
||||
(setting?.shared_ai_platform?.has_api_key ? "已保存" : "未保存")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{setting?.model || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="审查模型">{setting?.review_model || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Wire API">{setting?.wire_api || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Reasoning">{setting?.reasoning_effort || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="法律审查远程能力">
|
||||
{setting?.shared_ai_platform?.legal_review_enabled ? "启用" : "停用"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="文档解析远程能力">
|
||||
{setting?.shared_ai_platform?.document_extraction_enabled ? "启用" : "停用"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="密钥">{setting?.auth_token_preview || "未保存"}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{formatDateTime(setting?.updated_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
@@ -1633,6 +1633,25 @@ export type FileListResponse = PageResponse<FileItem>;
|
||||
|
||||
export type ModelSettingConfigFormat = "openai_toml" | "anthropic_env_json";
|
||||
|
||||
export type SharedAiPlatformRuntimeConfigSummary = {
|
||||
configured: boolean;
|
||||
source: string | null;
|
||||
base_url: string | null;
|
||||
api_prefix: string | null;
|
||||
app_id: string | null;
|
||||
api_key?: string;
|
||||
has_api_key: boolean;
|
||||
api_key_preview: string | null;
|
||||
timeout_seconds: number | null;
|
||||
provider: string | null;
|
||||
model_provider?: string | null;
|
||||
model?: string | null;
|
||||
review_model?: string | null;
|
||||
legal_review_enabled: boolean | null;
|
||||
legal_review_fallback_to_local: boolean | null;
|
||||
document_extraction_enabled: boolean | null;
|
||||
};
|
||||
|
||||
export type ModelSettingResponse = {
|
||||
id: string | null;
|
||||
tenant_id: string;
|
||||
@@ -1650,6 +1669,7 @@ export type ModelSettingResponse = {
|
||||
has_auth_token: boolean;
|
||||
auth_token_preview: string | null;
|
||||
config_json: Record<string, unknown>;
|
||||
shared_ai_platform: SharedAiPlatformRuntimeConfigSummary;
|
||||
raw_config: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
@@ -1668,6 +1688,7 @@ export type ModelSettingRuntimeExportResponse = {
|
||||
provider_type: "openai" | "anthropic" | string | null;
|
||||
env: Record<string, string>;
|
||||
config: Record<string, unknown> | null;
|
||||
shared_ai_platform: SharedAiPlatformRuntimeConfigSummary;
|
||||
};
|
||||
|
||||
export type SharedAiMode = "configuration-required" | "remote-preferred";
|
||||
@@ -1679,6 +1700,10 @@ export type SharedAiStatusResponse = {
|
||||
api_prefix: string;
|
||||
app_id: string;
|
||||
timeout_seconds: number;
|
||||
config_source?: string | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
review_model?: string | null;
|
||||
transport: {
|
||||
sdk: string;
|
||||
fallback: string;
|
||||
|
||||
Reference in New Issue
Block a user