diff --git a/project-docs/V2-ai-platform-migration-provider-flow-2026-06-22.md b/project-docs/V2-ai-platform-migration-provider-flow-2026-06-22.md new file mode 100644 index 00000000..bf45b3cc --- /dev/null +++ b/project-docs/V2-ai-platform-migration-provider-flow-2026-06-22.md @@ -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。 diff --git a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml index 63b5f715..83181a79 100644 --- a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml +++ b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml @@ -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 diff --git a/yuqei-ai-platform/services/ai-platform-api/README.md b/yuqei-ai-platform/services/ai-platform-api/README.md index 2205a653..636c0fc6 100644 --- a/yuqei-ai-platform/services/ai-platform-api/README.md +++ b/yuqei-ai-platform/services/ai-platform-api/README.md @@ -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` diff --git a/yuqei-ai-platform/services/ai-platform-api/alembic.ini b/yuqei-ai-platform/services/ai-platform-api/alembic.ini new file mode 100644 index 00000000..ce3d2312 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/alembic.ini @@ -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 diff --git a/yuqei-ai-platform/services/ai-platform-api/migrations/env.py b/yuqei-ai-platform/services/ai-platform-api/migrations/env.py new file mode 100644 index 00000000..5b16a95d --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/migrations/env.py @@ -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() diff --git a/yuqei-ai-platform/services/ai-platform-api/migrations/script.py.mako b/yuqei-ai-platform/services/ai-platform-api/migrations/script.py.mako new file mode 100644 index 00000000..04ea8276 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/migrations/script.py.mako @@ -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"} diff --git a/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260622_0001_create_ai_platform_tables.py b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260622_0001_create_ai_platform_tables.py new file mode 100644 index 00000000..513b6072 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260622_0001_create_ai_platform_tables.py @@ -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") diff --git a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml index ad9e2956..25a61ecd 100644 --- a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml +++ b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml @@ -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", diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py index b3a66a1f..60ac4073 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py @@ -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", diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py index 7432b408..2d101885 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py @@ -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 diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py new file mode 100644 index 00000000..3cbb36eb --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py @@ -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" + "回答要求:语言简洁、不要编造法条;如果依据不足,要明确提示需要补充材料。" + ) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py new file mode 100644 index 00000000..19dc7c7a --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py @@ -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 "" diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py index 3580367e..f2a65050 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py @@ -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, ) diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py index 6222408f..055a8472 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py @@ -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: diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py new file mode 100644 index 00000000..dc8a5888 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py @@ -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 diff --git a/yuqei-infra/compose/docker-compose.yml b/yuqei-infra/compose/docker-compose.yml index 30403a7d..b0a06853 100644 --- a/yuqei-infra/compose/docker-compose.yml +++ b/yuqei-infra/compose/docker-compose.yml @@ -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: diff --git a/yuqei-infra/docker/ai-platform-api.Dockerfile b/yuqei-infra/docker/ai-platform-api.Dockerfile index 001b3150..0437753b 100644 --- a/yuqei-infra/docker/ai-platform-api.Dockerfile +++ b/yuqei-infra/docker/ai-platform-api.Dockerfile @@ -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