feat: add ai platform migrations and provider flow
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# V2 AI Platform Migration and Provider Flow Closure
|
||||
|
||||
日期:2026-06-22
|
||||
|
||||
## 本轮目标
|
||||
|
||||
继续推进 `yuqei-ai-platform` 的真实运行能力,把上一轮“配置、Prompt、知识、审计可落库”推进到:
|
||||
|
||||
- 有 Alembic migration。
|
||||
- 有 OpenAI-compatible Provider Adapter。
|
||||
- 法律问答按“知识检索 -> Prompt 渲染 -> Provider 调用 -> 回退 -> 审计”运行。
|
||||
- 默认仍可在无 API Key 的情况下稳定工作。
|
||||
|
||||
## 已完成
|
||||
|
||||
1. Alembic 迁移底座
|
||||
- 新增 `alembic.ini`。
|
||||
- 新增 `migrations/env.py`。
|
||||
- 新增首个迁移 `20260622_0001_create_ai_platform_tables.py`。
|
||||
- 覆盖表:
|
||||
- `ai_provider_configs`
|
||||
- `prompt_templates`
|
||||
- `knowledge_documents`
|
||||
- `ai_run_audits`
|
||||
|
||||
2. Provider Adapter
|
||||
- 新增 `providers.py`。
|
||||
- 支持 `DisabledProviderAdapter`。
|
||||
- 支持 `OpenAICompatibleProviderAdapter`,接口按 `/chat/completions` 兼容格式调用。
|
||||
- 支持 `AI_PLATFORM_PROVIDER_CALL_MODE`。
|
||||
- 支持 `AI_PLATFORM_PROVIDER_API_KEY`、`AI_PLATFORM_PROVIDER_BASE_URL`、`AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS`。
|
||||
- Provider 调用失败时可按 `AI_PLATFORM_PROVIDER_FALLBACK_ENABLED` 回退。
|
||||
|
||||
3. Prompt 渲染
|
||||
- 新增 `prompts.py`。
|
||||
- 法律问答模板支持:
|
||||
- `{question}`
|
||||
- `{matter_context}`
|
||||
- `{citations}`
|
||||
- 后台模板未写占位符时,会自动附加标准问题/背景/依据块。
|
||||
- 模板占位符写错时,会回退到内置默认模板,避免问答接口 500。
|
||||
|
||||
4. 法律问答主链路
|
||||
- 先检索知识文档。
|
||||
- 将检索结果转换为引用依据。
|
||||
- 渲染 Prompt。
|
||||
- 如果 provider 成功,使用 provider 回答。
|
||||
- 如果 provider 被禁用或失败,默认回退到规则型可审计回答。
|
||||
- 审计状态支持:
|
||||
- `succeeded`
|
||||
- `fallback`
|
||||
- `failed`
|
||||
|
||||
5. 部署配置
|
||||
- AI Platform Docker 镜像增加 `alembic` 和 `httpx`。
|
||||
- compose 本地环境显式设置:
|
||||
- `AI_PLATFORM_REPOSITORY_BACKEND=sqlalchemy`
|
||||
- `AI_PLATFORM_DATABASE_AUTO_CREATE=true`
|
||||
- `AI_PLATFORM_PROVIDER_CALL_MODE=disabled`
|
||||
- `AI_PLATFORM_PROVIDER_FALLBACK_ENABLED=true`
|
||||
|
||||
## 设计边界
|
||||
|
||||
- 合同系统仍不直接调用模型 provider。
|
||||
- 合同系统仍只通过 SDK/OpenAPI 使用 AI 中台。
|
||||
- 真实 provider 默认关闭,避免无密钥环境下影响开发、测试和演示。
|
||||
- 当前 provider adapter 先支持 OpenAI-compatible 格式,DeepSeek、通义、私有模型可通过该格式优先接入。
|
||||
|
||||
## 下一轮建议
|
||||
|
||||
下一轮进入知识库检索增强:
|
||||
|
||||
1. 增加知识文档切分表。
|
||||
2. 增加导入批次和索引状态。
|
||||
3. 增加检索命中分数和来源定位。
|
||||
4. 法律问答返回更结构化的“主要依据”列表。
|
||||
5. 审计记录扩展 provider 错误、token、费用和命中文档 ID。
|
||||
@@ -663,7 +663,7 @@ components:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, running, succeeded, failed, cancelled]
|
||||
enum: [pending, running, succeeded, fallback, skipped, failed, cancelled]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -18,6 +18,22 @@ $env:AI_PLATFORM_DATABASE_URL="postgresql+psycopg://ai:ai@localhost:5432/yuqei_a
|
||||
python -m uvicorn yuqei_ai_platform_api.main:app --app-dir src --reload --port 8101
|
||||
```
|
||||
|
||||
Run database migrations from this service directory:
|
||||
|
||||
```powershell
|
||||
$env:AI_PLATFORM_DATABASE_URL="postgresql+psycopg://ai:ai@localhost:5432/yuqei_ai"
|
||||
python -m alembic upgrade head
|
||||
```
|
||||
|
||||
Provider calls are disabled by default. To enable an OpenAI-compatible provider:
|
||||
|
||||
```powershell
|
||||
$env:AI_PLATFORM_PROVIDER_CALL_MODE="enabled"
|
||||
$env:AI_PLATFORM_PROVIDER_API_KEY="your-api-key"
|
||||
$env:AI_PLATFORM_PROVIDER_BASE_URL="https://api.deepseek.com/v1"
|
||||
$env:AI_PLATFORM_PROVIDER_FALLBACK_ENABLED="true"
|
||||
```
|
||||
|
||||
Health endpoints:
|
||||
|
||||
- `GET /health`
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = src
|
||||
path_separator = os
|
||||
|
||||
sqlalchemy.url = postgresql+psycopg://ai:ai@localhost:5432/yuqei_ai
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from yuqei_ai_platform_api.config import get_settings
|
||||
from yuqei_ai_platform_api.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_database_url() -> str:
|
||||
return get_settings().database_url
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=get_database_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = get_database_url()
|
||||
connectable = engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
"""create ai platform tables
|
||||
|
||||
Revision ID: 20260622_0001
|
||||
Revises:
|
||||
Create Date: 2026-06-22
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260622_0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ai_provider_configs",
|
||||
sa.Column("id", sa.String(length=64), nullable=False),
|
||||
sa.Column("provider", sa.String(length=64), nullable=False),
|
||||
sa.Column("model", sa.String(length=128), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=128), nullable=False),
|
||||
sa.Column("base_url", sa.String(length=512), nullable=True),
|
||||
sa.Column("temperature", sa.Float(), nullable=False),
|
||||
sa.Column("max_tokens", sa.Integer(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_ai_provider_configs_provider", "ai_provider_configs", ["provider"])
|
||||
|
||||
op.create_table(
|
||||
"prompt_templates",
|
||||
sa.Column("id", sa.String(length=64), nullable=False),
|
||||
sa.Column("name", sa.String(length=128), nullable=False),
|
||||
sa.Column("version", sa.String(length=32), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_prompt_templates_name", "prompt_templates", ["name"])
|
||||
|
||||
op.create_table(
|
||||
"knowledge_documents",
|
||||
sa.Column("id", sa.String(length=64), nullable=False),
|
||||
sa.Column("knowledge_base_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("title", sa.String(length=256), nullable=False),
|
||||
sa.Column("source_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("reference", sa.String(length=256), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_knowledge_documents_knowledge_base_id", "knowledge_documents", ["knowledge_base_id"])
|
||||
|
||||
op.create_table(
|
||||
"ai_run_audits",
|
||||
sa.Column("id", sa.String(length=64), nullable=False),
|
||||
sa.Column("operation", sa.String(length=64), nullable=False),
|
||||
sa.Column("provider_config_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("prompt_template_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("question", sa.Text(), nullable=True),
|
||||
sa.Column("answer", sa.Text(), nullable=True),
|
||||
sa.Column("citations_count", sa.Integer(), nullable=False),
|
||||
sa.Column("latency_ms", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_ai_run_audits_operation", "ai_run_audits", ["operation"])
|
||||
op.create_index("ix_ai_run_audits_created_at", "ai_run_audits", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ai_run_audits_created_at", table_name="ai_run_audits")
|
||||
op.drop_index("ix_ai_run_audits_operation", table_name="ai_run_audits")
|
||||
op.drop_table("ai_run_audits")
|
||||
op.drop_index("ix_knowledge_documents_knowledge_base_id", table_name="knowledge_documents")
|
||||
op.drop_table("knowledge_documents")
|
||||
op.drop_index("ix_prompt_templates_name", table_name="prompt_templates")
|
||||
op.drop_table("prompt_templates")
|
||||
op.drop_index("ix_ai_provider_configs_provider", table_name="ai_provider_configs")
|
||||
op.drop_table("ai_provider_configs")
|
||||
@@ -4,7 +4,9 @@ version = "0.1.0"
|
||||
description = "Yuqei shared AI platform V2 API"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16",
|
||||
"fastapi>=0.115",
|
||||
"httpx>=0.28",
|
||||
"pydantic>=2.10",
|
||||
"pydantic-settings>=2.7",
|
||||
"psycopg[binary]>=3.2",
|
||||
|
||||
@@ -10,10 +10,16 @@ class AiPlatformSettings(BaseSettings):
|
||||
log_level: str = "INFO"
|
||||
api_prefix: str = "/api/v1"
|
||||
database_url: str = "postgresql+psycopg://ai:ai@localhost:5432/yuqei_ai"
|
||||
database_auto_create: bool = True
|
||||
database_auto_create: bool = False
|
||||
redis_url: str = "redis://localhost:6379/1"
|
||||
provider_config_source: Literal["database", "environment"] = "database"
|
||||
repository_backend: Literal["memory", "sqlalchemy"] = "memory"
|
||||
provider_call_mode: Literal["disabled", "enabled"] = "disabled"
|
||||
provider_api_key: str | None = None
|
||||
provider_api_keys: dict[str, str] = {}
|
||||
provider_base_url: str | None = None
|
||||
provider_request_timeout_seconds: float = 30.0
|
||||
provider_fallback_enabled: bool = True
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
||||
@@ -9,6 +9,8 @@ from yuqei_ai_platform_api import __version__
|
||||
from yuqei_ai_platform_api.config import AiPlatformSettings, get_settings
|
||||
from yuqei_ai_platform_api.legal_qa import LegalQaRequest, LegalQaResponse, answer_legal_question
|
||||
from yuqei_ai_platform_api.logging import configure_logging
|
||||
from yuqei_ai_platform_api.prompts import render_legal_qa_prompt
|
||||
from yuqei_ai_platform_api.providers import ProviderRequest, build_provider_adapter
|
||||
from yuqei_ai_platform_api.repository import (
|
||||
AiPlatformRepository,
|
||||
AiRunAudit,
|
||||
@@ -37,6 +39,7 @@ def create_app(
|
||||
) -> FastAPI:
|
||||
resolved_settings = settings or get_settings()
|
||||
store = repository or get_repository(resolved_settings)
|
||||
provider_adapter = build_provider_adapter(resolved_settings)
|
||||
configure_logging(resolved_settings.log_level)
|
||||
|
||||
app = FastAPI(
|
||||
@@ -124,12 +127,34 @@ def create_app(
|
||||
def run_legal_qa(request: LegalQaRequest) -> LegalQaResponse:
|
||||
started_at = perf_counter()
|
||||
documents = _search_citation_documents(store, request)
|
||||
response = answer_legal_question(
|
||||
request,
|
||||
retrieved_citations=[document_to_citation(document) for document in documents],
|
||||
)
|
||||
citations = [document_to_citation(document) for document in documents]
|
||||
provider_config = store.get_default_provider_config()
|
||||
prompt_template = store.get_prompt_template("legal_qa.default")
|
||||
prompt = render_legal_qa_prompt(
|
||||
request,
|
||||
citations=citations,
|
||||
prompt_template=prompt_template,
|
||||
)
|
||||
provider_response = provider_adapter.generate(
|
||||
ProviderRequest(prompt=prompt, provider_config=provider_config)
|
||||
)
|
||||
fallback_response = answer_legal_question(
|
||||
request,
|
||||
retrieved_citations=citations,
|
||||
)
|
||||
if provider_response.status == "succeeded" and provider_response.answer:
|
||||
response = fallback_response.model_copy(update={"answer": provider_response.answer})
|
||||
audit_status = "succeeded"
|
||||
elif resolved_settings.provider_fallback_enabled:
|
||||
response = fallback_response
|
||||
audit_status = "fallback"
|
||||
else:
|
||||
response = LegalQaResponse(
|
||||
answer="AI 服务暂时不可用,请稍后重试。",
|
||||
citations=citations,
|
||||
confidence=0.0,
|
||||
)
|
||||
audit_status = "failed"
|
||||
store.add_ai_run_audit(
|
||||
AiRunAudit(
|
||||
id=f"run-{uuid4().hex[:12]}",
|
||||
@@ -140,7 +165,7 @@ def create_app(
|
||||
answer=response.answer,
|
||||
citations_count=len(response.citations),
|
||||
latency_ms=max(0, int((perf_counter() - started_at) * 1000)),
|
||||
status="succeeded",
|
||||
status=audit_status,
|
||||
)
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuqei_ai_platform_api.legal_qa import LegalCitation, LegalQaRequest
|
||||
from yuqei_ai_platform_api.repository import PromptTemplate
|
||||
|
||||
|
||||
def render_legal_qa_prompt(
|
||||
request: LegalQaRequest,
|
||||
*,
|
||||
citations: list[LegalCitation],
|
||||
prompt_template: PromptTemplate | None,
|
||||
) -> str:
|
||||
template = prompt_template.content if prompt_template else _default_legal_qa_template()
|
||||
variables = {
|
||||
"question": request.question.strip(),
|
||||
"matter_context": (request.matter_context or "").strip() or "无补充背景",
|
||||
"citations": _format_citations(citations),
|
||||
}
|
||||
if "{" not in template:
|
||||
template = f"{template}\n\n{_default_legal_qa_template()}"
|
||||
try:
|
||||
return template.format(**variables)
|
||||
except (KeyError, ValueError):
|
||||
return _default_legal_qa_template().format(**variables)
|
||||
|
||||
|
||||
def _format_citations(citations: list[LegalCitation]) -> str:
|
||||
if not citations:
|
||||
return "未命中明确依据,请在回答中说明需要进一步检索确认。"
|
||||
|
||||
lines: list[str] = []
|
||||
for index, citation in enumerate(citations, start=1):
|
||||
lines.append(
|
||||
f"{index}. [{citation.source_type}] {citation.title} {citation.reference}: {citation.quote}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _default_legal_qa_template() -> str:
|
||||
return (
|
||||
"你是企业合同法律助手。请基于下列检索依据回答用户问题,必须先给结论,再列主要依据。\n"
|
||||
"用户问题:{question}\n"
|
||||
"事项背景:{matter_context}\n"
|
||||
"检索依据:\n{citations}\n"
|
||||
"回答要求:语言简洁、不要编造法条;如果依据不足,要明确提示需要补充材料。"
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
from yuqei_ai_platform_api.config import AiPlatformSettings
|
||||
from yuqei_ai_platform_api.repository import ProviderConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderRequest:
|
||||
prompt: str
|
||||
provider_config: ProviderConfig | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderResponse:
|
||||
answer: str
|
||||
status: str = "succeeded"
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class ModelProviderAdapter(Protocol):
|
||||
def generate(self, request: ProviderRequest) -> ProviderResponse: ...
|
||||
|
||||
|
||||
class DisabledProviderAdapter:
|
||||
def generate(self, request: ProviderRequest) -> ProviderResponse:
|
||||
return ProviderResponse(
|
||||
answer="",
|
||||
status="skipped",
|
||||
error_message="Provider calls are disabled.",
|
||||
)
|
||||
|
||||
|
||||
class OpenAICompatibleProviderAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
settings: AiPlatformSettings,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._transport = transport
|
||||
|
||||
def generate(self, request: ProviderRequest) -> ProviderResponse:
|
||||
config = request.provider_config
|
||||
if config is None:
|
||||
return ProviderResponse(answer="", status="failed", error_message="No enabled provider config.")
|
||||
|
||||
api_key = self._resolve_api_key(config)
|
||||
if not api_key:
|
||||
return ProviderResponse(answer="", status="failed", error_message="Missing provider API key.")
|
||||
|
||||
base_url = (config.base_url or self._settings.provider_base_url or "").rstrip("/")
|
||||
if not base_url:
|
||||
return ProviderResponse(answer="", status="failed", error_message="Missing provider base URL.")
|
||||
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=self._settings.provider_request_timeout_seconds,
|
||||
transport=self._transport,
|
||||
) as client:
|
||||
response = client.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": config.model,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是严谨的企业合同法律助手,回答必须基于给定依据。",
|
||||
},
|
||||
{"role": "user", "content": request.prompt},
|
||||
],
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
return ProviderResponse(answer="", status="failed", error_message=str(exc))
|
||||
|
||||
answer = _extract_answer(response.json())
|
||||
if not answer:
|
||||
return ProviderResponse(answer="", status="failed", error_message="Provider response has no answer.")
|
||||
return ProviderResponse(answer=answer)
|
||||
|
||||
def _resolve_api_key(self, config: ProviderConfig) -> str | None:
|
||||
if config.provider in self._settings.provider_api_keys:
|
||||
return self._settings.provider_api_keys[config.provider]
|
||||
return self._settings.provider_api_key
|
||||
|
||||
|
||||
def build_provider_adapter(settings: AiPlatformSettings) -> ModelProviderAdapter:
|
||||
if settings.provider_call_mode == "enabled":
|
||||
return OpenAICompatibleProviderAdapter(settings)
|
||||
return DisabledProviderAdapter()
|
||||
|
||||
|
||||
def _extract_answer(payload: dict[str, Any]) -> str:
|
||||
choices = payload.get("choices")
|
||||
if not isinstance(choices, list) or not choices:
|
||||
return ""
|
||||
first = choices[0]
|
||||
if not isinstance(first, dict):
|
||||
return ""
|
||||
message = first.get("message")
|
||||
if isinstance(message, dict) and isinstance(message.get("content"), str):
|
||||
return message["content"].strip()
|
||||
text = first.get("text")
|
||||
if isinstance(text, str):
|
||||
return text.strip()
|
||||
return ""
|
||||
@@ -486,9 +486,15 @@ def _default_prompt_template() -> PromptTemplateCreate:
|
||||
name="legal_qa.default",
|
||||
version="v1",
|
||||
content=(
|
||||
"\u8bf7\u5148\u68c0\u7d22\u6cd5\u89c4\u3001\u4f01\u4e1a\u5236\u5ea6\u548c"
|
||||
"\u5408\u540c\u6750\u6599\uff0c\u518d\u7528\u7b80\u6d01\u7ed3\u8bba\u56de\u7b54\u3002"
|
||||
"\u56de\u7b54\u5fc5\u987b\u5217\u660e\u4e3b\u8981\u4f9d\u636e\u548c\u6761\u6b3e\u6765\u6e90\u3002"
|
||||
"\u4f60\u662f\u4f01\u4e1a\u5408\u540c\u6cd5\u5f8b\u52a9\u624b\u3002"
|
||||
"\u8bf7\u5148\u6839\u636e\u68c0\u7d22\u4f9d\u636e\u7ed9\u51fa\u7ed3\u8bba\uff0c"
|
||||
"\u518d\u5217\u660e\u4e3b\u8981\u4f9d\u636e\u548c\u6761\u6b3e\u6765\u6e90\u3002\n"
|
||||
"\u7528\u6237\u95ee\u9898\uff1a{question}\n"
|
||||
"\u4e8b\u9879\u80cc\u666f\uff1a{matter_context}\n"
|
||||
"\u68c0\u7d22\u4f9d\u636e\uff1a\n{citations}\n"
|
||||
"\u56de\u7b54\u8981\u6c42\uff1a\u8bed\u8a00\u7b80\u6d01\uff0c"
|
||||
"\u4e0d\u8981\u7f16\u9020\u6cd5\u6761\uff1b\u5982\u679c\u4f9d\u636e\u4e0d\u8db3\uff0c"
|
||||
"\u660e\u786e\u8bf4\u660e\u9700\u8981\u8865\u5145\u6750\u6599\u3002"
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_legal_qa_uses_knowledge_and_records_audit() -> None:
|
||||
assert audits[0]["operation"] == "legal_qa"
|
||||
assert audits[0]["citations_count"] >= 1
|
||||
assert audits[0]["prompt_template_id"] is not None
|
||||
assert audits[0]["status"] == "succeeded"
|
||||
assert audits[0]["status"] == "fallback"
|
||||
|
||||
|
||||
def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from yuqei_ai_platform_api.config import AiPlatformSettings
|
||||
from yuqei_ai_platform_api.legal_qa import LegalCitation, LegalQaRequest
|
||||
from yuqei_ai_platform_api.main import create_app
|
||||
from yuqei_ai_platform_api.prompts import render_legal_qa_prompt
|
||||
from yuqei_ai_platform_api.providers import OpenAICompatibleProviderAdapter, ProviderRequest
|
||||
from yuqei_ai_platform_api.repository import InMemoryAiPlatformRepository, ProviderConfigCreate
|
||||
|
||||
|
||||
def test_legal_qa_uses_provider_answer_when_enabled(monkeypatch) -> None:
|
||||
repository = InMemoryAiPlatformRepository()
|
||||
repository.seed_defaults()
|
||||
repository.upsert_provider_config(
|
||||
ProviderConfigCreate(
|
||||
provider="openai-compatible",
|
||||
model="legal-pro",
|
||||
display_name="Legal Pro",
|
||||
base_url="https://ai.example.test/v1",
|
||||
enabled=True,
|
||||
is_default=True,
|
||||
)
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/v1/chat/completions"
|
||||
assert request.headers["Authorization"] == "Bearer test-key"
|
||||
payload = request.read().decode("utf-8")
|
||||
assert "试用期如何约定" in payload
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "模型回答:依据检索材料,应先核对合同期限和试用期上限。"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def adapter_factory(settings: AiPlatformSettings) -> OpenAICompatibleProviderAdapter:
|
||||
return OpenAICompatibleProviderAdapter(settings, transport=httpx.MockTransport(handler))
|
||||
|
||||
monkeypatch.setattr("yuqei_ai_platform_api.main.build_provider_adapter", adapter_factory)
|
||||
|
||||
app = create_app(
|
||||
AiPlatformSettings(
|
||||
environment="test",
|
||||
provider_call_mode="enabled",
|
||||
provider_api_key="test-key",
|
||||
),
|
||||
repository=repository,
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/api/v1/legal/qa", json={"question": "试用期如何约定?"})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["answer"].startswith("模型回答")
|
||||
|
||||
audit_response = client.get("/api/v1/audit/ai-runs")
|
||||
assert audit_response.json()[0]["status"] == "succeeded"
|
||||
|
||||
|
||||
def test_provider_failure_falls_back_to_rule_answer_and_audit_status() -> None:
|
||||
repository = InMemoryAiPlatformRepository()
|
||||
repository.seed_defaults()
|
||||
repository.upsert_provider_config(
|
||||
ProviderConfigCreate(
|
||||
provider="openai-compatible",
|
||||
model="legal-pro",
|
||||
display_name="Legal Pro",
|
||||
base_url="https://ai.example.test/v1",
|
||||
enabled=True,
|
||||
is_default=True,
|
||||
)
|
||||
)
|
||||
app = create_app(
|
||||
AiPlatformSettings(
|
||||
environment="test",
|
||||
provider_call_mode="enabled",
|
||||
provider_api_key=None,
|
||||
provider_fallback_enabled=True,
|
||||
),
|
||||
repository=repository,
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/api/v1/legal/qa", json={"question": "试用期如何约定?"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["citations"]
|
||||
assert client.get("/api/v1/audit/ai-runs").json()[0]["status"] == "fallback"
|
||||
|
||||
|
||||
def test_openai_compatible_provider_extracts_chat_answer() -> None:
|
||||
provider_config = InMemoryAiPlatformRepository()
|
||||
created = provider_config.upsert_provider_config(
|
||||
ProviderConfigCreate(
|
||||
provider="deepseek",
|
||||
model="deepseek-chat",
|
||||
display_name="DeepSeek Chat",
|
||||
base_url="https://ai.example.test/v1",
|
||||
enabled=True,
|
||||
is_default=True,
|
||||
)
|
||||
)
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": "OK"}}]})
|
||||
|
||||
adapter = OpenAICompatibleProviderAdapter(
|
||||
AiPlatformSettings(
|
||||
environment="test",
|
||||
provider_call_mode="enabled",
|
||||
provider_api_key="test-key",
|
||||
),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
response = adapter.generate(ProviderRequest(prompt="hello", provider_config=created))
|
||||
|
||||
assert response.status == "succeeded"
|
||||
assert response.answer == "OK"
|
||||
|
||||
|
||||
def test_render_legal_qa_prompt_includes_question_context_and_citations() -> None:
|
||||
prompt = render_legal_qa_prompt(
|
||||
request=LegalQaRequest(
|
||||
question="租赁合同押金如何约定?",
|
||||
matter_context="房屋租赁",
|
||||
),
|
||||
citations=[
|
||||
LegalCitation(
|
||||
source_type="law",
|
||||
title="中华人民共和国民法典",
|
||||
reference="第七百零三条",
|
||||
quote="租赁合同是出租人将租赁物交付承租人使用、收益,承租人支付租金的合同。",
|
||||
)
|
||||
],
|
||||
prompt_template=None,
|
||||
)
|
||||
|
||||
assert "租赁合同押金如何约定" in prompt
|
||||
assert "房屋租赁" in prompt
|
||||
assert "第七百零三条" in prompt
|
||||
@@ -68,9 +68,12 @@ services:
|
||||
environment:
|
||||
AI_PLATFORM_ENVIRONMENT: local
|
||||
AI_PLATFORM_DATABASE_URL: postgresql+psycopg://yuqei:yuqei-local@postgres:5432/yuqei_v2
|
||||
AI_PLATFORM_DATABASE_AUTO_CREATE: "true"
|
||||
AI_PLATFORM_REPOSITORY_BACKEND: sqlalchemy
|
||||
AI_PLATFORM_REDIS_URL: redis://redis:6379/1
|
||||
AI_PLATFORM_PROVIDER_CONFIG_SOURCE: database
|
||||
AI_PLATFORM_PROVIDER_CALL_MODE: disabled
|
||||
AI_PLATFORM_PROVIDER_FALLBACK_ENABLED: "true"
|
||||
ports:
|
||||
- "8101:8101"
|
||||
depends_on:
|
||||
|
||||
@@ -7,7 +7,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /app
|
||||
|
||||
COPY yuqei-ai-platform/services/ai-platform-api/pyproject.toml /tmp/ai-platform-api-pyproject.toml
|
||||
RUN pip install --no-cache-dir fastapi pydantic pydantic-settings "psycopg[binary]" sqlalchemy uvicorn
|
||||
RUN pip install --no-cache-dir alembic fastapi httpx pydantic pydantic-settings "psycopg[binary]" sqlalchemy uvicorn
|
||||
|
||||
COPY yuqei-ai-platform/services/ai-platform-api yuqei-ai-platform/services/ai-platform-api
|
||||
|
||||
|
||||
Reference in New Issue
Block a user