From 3f46b59f7665435109bd6c10685abaaa630da51a Mon Sep 17 00:00:00 2001 From: yuqei Date: Mon, 22 Jun 2026 20:44:36 +0800 Subject: [PATCH] feat: persist ai platform prompt and audit data --- ...orm-persistence-prompt-audit-2026-06-22.md | 81 ++++ .../packages/ai-openapi/openapi.yaml | 79 +++- .../services/ai-platform-api/README.md | 18 + .../services/ai-platform-api/pyproject.toml | 1 + .../src/yuqei_ai_platform_api/config.py | 2 + .../src/yuqei_ai_platform_api/main.py | 34 +- .../src/yuqei_ai_platform_api/repository.py | 396 ++++++++++++++++-- .../src/yuqei_ai_platform_api/state.py | 28 +- .../tests/test_platform_data.py | 88 +++- yuqei-ai-sdk-python/README.md | 4 + yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py | 21 +- .../src/yuqei_sdk/ai_platform.py | 141 +++++++ yuqei-ai-sdk-python/tests/test_ai_platform.py | 157 ++++++- yuqei-infra/compose/docker-compose.yml | 1 + yuqei-infra/docker/ai-platform-api.Dockerfile | 2 +- 15 files changed, 1011 insertions(+), 42 deletions(-) create mode 100644 project-docs/V2-ai-platform-persistence-prompt-audit-2026-06-22.md diff --git a/project-docs/V2-ai-platform-persistence-prompt-audit-2026-06-22.md b/project-docs/V2-ai-platform-persistence-prompt-audit-2026-06-22.md new file mode 100644 index 00000000..19a8ba90 --- /dev/null +++ b/project-docs/V2-ai-platform-persistence-prompt-audit-2026-06-22.md @@ -0,0 +1,81 @@ +# V2 AI Platform Persistence, Prompt, Knowledge, and Audit Closure + +日期:2026-06-22 + +## 本轮目标 + +本轮继续推进 `yuqei-ai-platform`,把前一轮只停留在内存演示的模型配置、知识库检索和 AI 调用审计,补成可持久化、可由 SDK 调用、可由 OpenAPI 描述的 MVP 闭环。 + +## 已完成 + +1. 仓库层抽象 + - 新增 `AiPlatformRepository` 协议。 + - 保留 `InMemoryAiPlatformRepository` 作为本地和测试默认实现。 + - 新增 `SqlAlchemyAiPlatformRepository`,支持模型配置、Prompt 模板、知识文档、AI 调用审计落库。 + +2. 配置切换 + - 新增 `AI_PLATFORM_REPOSITORY_BACKEND`。 + - 可选值:`memory`、`sqlalchemy`。 + - 新增 `AI_PLATFORM_DATABASE_AUTO_CREATE`,MVP 阶段可自动建表,后续正式部署再切到 Alembic migration。 + +3. Prompt 模板闭环 + - 新增 `PromptTemplateCreate`、`PromptTemplate`。 + - 新增接口: + - `GET /api/v1/prompt-templates` + - `POST /api/v1/prompt-templates` + - 法律问答审计会记录当前 `legal_qa.default` 的 `prompt_template_id`。 + +4. SDK 能力扩展 + - `yuqei-ai-sdk-python` 新增: + - `list_model_configs` + - `upsert_model_config` + - `list_prompt_templates` + - `upsert_prompt_template` + - `add_knowledge_document` + - `search_knowledge_documents` + - `list_ai_run_audits` + +5. OpenAPI 契约同步 + - 新增 Prompt 模板路径和 schema。 + - 修正 AI 审计列表参数为当前实现使用的 `limit`。 + - 补齐健康检查响应字段。 + +## 验证结果 + +已通过: + +```powershell +python -m pytest +``` + +结果: + +```text +16 passed +``` + +新增覆盖: + +- Prompt 模板可热更新并列表读取。 +- SQLite 持久化仓库可跨 repository 实例读取模型配置、Prompt、知识文档和审计记录。 +- SDK 覆盖 AI 中台管理类接口。 +- 法律问答审计可关联 Prompt 模板。 + +## 当前边界 + +本轮还没有做: + +- Alembic migration 文件。 +- PostgreSQL 真库部署演练。 +- 向量检索和混合排序。 +- 真实模型 provider 调用。 +- token、费用、错误重试等更完整的调用审计字段。 + +## 下一轮建议 + +下一轮进入 AI Platform 的真实运行能力: + +1. 建立 Alembic migration,并把自动建表降级为本地开发能力。 +2. 加入 provider adapter 抽象,先实现 OpenAI-compatible / DeepSeek-compatible 调用。 +3. 把法律问答从规则型回答升级为:知识检索 -> Prompt 模板渲染 -> Provider 调用 -> 引用与审计落库。 +4. 为知识库增加导入批次、切分结果、检索命中分数和来源定位。 diff --git a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml index 26c662fe..63b5f715 100644 --- a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml +++ b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml @@ -110,6 +110,35 @@ paths: application/json: schema: $ref: "#/components/schemas/ProviderConfig" + /api/v1/prompt-templates: + get: + operationId: listPromptTemplates + summary: List prompt templates + responses: + "200": + description: Prompt templates + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PromptTemplate" + post: + operationId: upsertPromptTemplate + summary: Create or update a prompt template + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PromptTemplateCreate" + responses: + "200": + description: Prompt template + content: + application/json: + schema: + $ref: "#/components/schemas/PromptTemplate" /api/v1/knowledge-bases: get: operationId: listKnowledgeBases @@ -225,9 +254,14 @@ paths: operationId: listAiRuns summary: List AI audit runs parameters: - - $ref: "#/components/parameters/TenantIdHeader" - - $ref: "#/components/parameters/Page" - - $ref: "#/components/parameters/PageSize" + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + minimum: 1 + maximum: 100 responses: "200": description: AI run audit list @@ -279,12 +313,22 @@ components: type: object required: - status + - service + - version + - environment + - provider_config_source properties: status: type: string enum: [ok] service: type: string + version: + type: string + environment: + type: string + provider_config_source: + type: string LegalCitation: type: object required: @@ -469,6 +513,35 @@ components: updated_at: type: string format: date-time + PromptTemplateCreate: + type: object + required: + - name + - content + properties: + name: + type: string + version: + type: string + default: draft + content: + type: string + enabled: + type: boolean + default: true + PromptTemplate: + allOf: + - $ref: "#/components/schemas/PromptTemplateCreate" + - type: object + required: + - id + - updated_at + properties: + id: + type: string + updated_at: + type: string + format: date-time KnowledgeBase: type: object required: diff --git a/yuqei-ai-platform/services/ai-platform-api/README.md b/yuqei-ai-platform/services/ai-platform-api/README.md index 79f811ff..2205a653 100644 --- a/yuqei-ai-platform/services/ai-platform-api/README.md +++ b/yuqei-ai-platform/services/ai-platform-api/README.md @@ -9,11 +9,29 @@ cd yuqei-ai-platform/services/ai-platform-api python -m uvicorn yuqei_ai_platform_api.main:app --app-dir src --reload --port 8101 ``` +Default local runs use the in-memory repository. To run against a real database-backed +repository: + +```powershell +$env:AI_PLATFORM_REPOSITORY_BACKEND="sqlalchemy" +$env:AI_PLATFORM_DATABASE_URL="postgresql+psycopg://ai:ai@localhost:5432/yuqei_ai" +python -m uvicorn yuqei_ai_platform_api.main:app --app-dir src --reload --port 8101 +``` + Health endpoints: - `GET /health` - `GET /api/v1/health` +Core MVP endpoints: + +- `GET|POST /api/v1/model-configs` +- `GET|POST /api/v1/prompt-templates` +- `POST /api/v1/knowledge-documents` +- `GET /api/v1/knowledge-documents/search` +- `POST /api/v1/legal/qa` +- `GET /api/v1/audit/ai-runs` + ## Test From repository root: diff --git a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml index 88cb86e0..ad9e2956 100644 --- a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml +++ b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "fastapi>=0.115", "pydantic>=2.10", "pydantic-settings>=2.7", + "psycopg[binary]>=3.2", "sqlalchemy>=2.0", "uvicorn>=0.34", ] 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 c9926043..b3a66a1f 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,8 +10,10 @@ 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 redis_url: str = "redis://localhost:6379/1" provider_config_source: Literal["database", "environment"] = "database" + repository_backend: Literal["memory", "sqlalchemy"] = "memory" 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 77d8b13e..7432b408 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 @@ -2,7 +2,7 @@ from typing import Literal from time import perf_counter from uuid import uuid4 -from fastapi import FastAPI +from fastapi import FastAPI, Query from pydantic import BaseModel from yuqei_ai_platform_api import __version__ @@ -10,10 +10,12 @@ 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.repository import ( + AiPlatformRepository, AiRunAudit, - InMemoryAiPlatformRepository, KnowledgeDocument, KnowledgeDocumentCreate, + PromptTemplate, + PromptTemplateCreate, ProviderConfig, ProviderConfigCreate, document_to_citation, @@ -31,10 +33,10 @@ class HealthResponse(BaseModel): def create_app( settings: AiPlatformSettings | None = None, - repository: InMemoryAiPlatformRepository | None = None, + repository: AiPlatformRepository | None = None, ) -> FastAPI: resolved_settings = settings or get_settings() - store = repository or get_repository() + store = repository or get_repository(resolved_settings) configure_logging(resolved_settings.log_level) app = FastAPI( @@ -74,6 +76,22 @@ def create_app( def upsert_model_config(payload: ProviderConfigCreate) -> ProviderConfig: return store.upsert_provider_config(payload) + @app.get( + f"{resolved_settings.api_prefix}/prompt-templates", + response_model=list[PromptTemplate], + tags=["prompts"], + ) + def list_prompt_templates() -> list[PromptTemplate]: + return store.list_prompt_templates() + + @app.post( + f"{resolved_settings.api_prefix}/prompt-templates", + response_model=PromptTemplate, + tags=["prompts"], + ) + def upsert_prompt_template(payload: PromptTemplateCreate) -> PromptTemplate: + return store.upsert_prompt_template(payload) + @app.post( f"{resolved_settings.api_prefix}/knowledge-documents", response_model=KnowledgeDocument, @@ -87,7 +105,7 @@ def create_app( response_model=list[KnowledgeDocument], tags=["knowledge"], ) - def search_knowledge_documents(keyword: str = "", limit: int = 5) -> list[KnowledgeDocument]: + def search_knowledge_documents(keyword: str = "", limit: int = Query(5, ge=1, le=20)) -> list[KnowledgeDocument]: return store.search_knowledge(keyword, limit=limit) @app.get( @@ -95,7 +113,7 @@ def create_app( response_model=list[AiRunAudit], tags=["audit"], ) - def list_ai_runs(limit: int = 20) -> list[AiRunAudit]: + def list_ai_runs(limit: int = Query(20, ge=1, le=100)) -> list[AiRunAudit]: return store.list_ai_run_audits(limit=limit) @app.post( @@ -111,11 +129,13 @@ def create_app( retrieved_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") store.add_ai_run_audit( AiRunAudit( id=f"run-{uuid4().hex[:12]}", operation="legal_qa", provider_config_id=provider_config.id if provider_config else None, + prompt_template_id=prompt_template.id if prompt_template else None, question=request.question, answer=response.answer, citations_count=len(response.citations), @@ -132,7 +152,7 @@ app = create_app() def _search_citation_documents( - repository: InMemoryAiPlatformRepository, + repository: AiPlatformRepository, request: LegalQaRequest, ) -> list[KnowledgeDocument]: documents = repository.search_knowledge( 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 43ce36e7..3580367e 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 @@ -3,11 +3,23 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import UTC, datetime from threading import Lock +from typing import Protocol from uuid import uuid4 from pydantic import BaseModel, Field +from sqlalchemy import create_engine, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool from yuqei_ai_platform_api.legal_qa import LegalCitation +from yuqei_ai_platform_api.models import ( + AiProviderConfigModel, + AiRunAuditModel, + Base, + KnowledgeDocumentModel, + PromptTemplateModel, +) class ProviderConfigCreate(BaseModel): @@ -26,6 +38,18 @@ class ProviderConfig(ProviderConfigCreate): updated_at: datetime +class PromptTemplateCreate(BaseModel): + name: str = Field(min_length=1) + version: str = Field(default="draft", min_length=1) + content: str = Field(min_length=1) + enabled: bool = True + + +class PromptTemplate(PromptTemplateCreate): + id: str + updated_at: datetime + + class KnowledgeDocumentCreate(BaseModel): knowledge_base_id: str = Field(default="default") title: str = Field(min_length=1) @@ -52,9 +76,40 @@ class AiRunAudit(BaseModel): created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) +class AiPlatformRepository(Protocol): + def upsert_provider_config(self, payload: ProviderConfigCreate) -> ProviderConfig: ... + + def list_provider_configs(self) -> list[ProviderConfig]: ... + + def get_default_provider_config(self) -> ProviderConfig | None: ... + + def upsert_prompt_template(self, payload: PromptTemplateCreate) -> PromptTemplate: ... + + def list_prompt_templates(self) -> list[PromptTemplate]: ... + + def get_prompt_template(self, name: str) -> PromptTemplate | None: ... + + def add_knowledge_document(self, payload: KnowledgeDocumentCreate) -> KnowledgeDocument: ... + + def search_knowledge( + self, + keyword: str, + *, + knowledge_base_ids: list[str] | None = None, + limit: int = 5, + ) -> list[KnowledgeDocument]: ... + + def add_ai_run_audit(self, audit: AiRunAudit) -> AiRunAudit: ... + + def list_ai_run_audits(self, *, limit: int = 20) -> list[AiRunAudit]: ... + + def seed_defaults(self) -> None: ... + + @dataclass class InMemoryAiPlatformRepository: provider_configs: dict[str, ProviderConfig] = field(default_factory=dict) + prompt_templates: dict[str, PromptTemplate] = field(default_factory=dict) knowledge_documents: dict[str, KnowledgeDocument] = field(default_factory=dict) ai_run_audits: list[AiRunAudit] = field(default_factory=list) _lock: Lock = field(default_factory=Lock) @@ -96,6 +151,40 @@ class InMemoryAiPlatformRepository: enabled = [config for config in self.provider_configs.values() if config.enabled] return sorted(enabled, key=lambda item: item.updated_at, reverse=True)[0] if enabled else None + def upsert_prompt_template(self, payload: PromptTemplateCreate) -> PromptTemplate: + with self._lock: + existing = next( + ( + template + for template in self.prompt_templates.values() + if template.name == payload.name and template.version == payload.version + ), + None, + ) + template_id = existing.id if existing else f"prompt-{uuid4().hex[:12]}" + template = PromptTemplate( + id=template_id, + updated_at=datetime.now(UTC), + **payload.model_dump(), + ) + self.prompt_templates[template_id] = template + return template + + def list_prompt_templates(self) -> list[PromptTemplate]: + with self._lock: + return sorted(self.prompt_templates.values(), key=lambda item: item.updated_at, reverse=True) + + def get_prompt_template(self, name: str) -> PromptTemplate | None: + with self._lock: + templates = [ + template + for template in self.prompt_templates.values() + if template.enabled and template.name == name + ] + if not templates: + return None + return sorted(templates, key=lambda item: item.updated_at, reverse=True)[0] + def add_knowledge_document(self, payload: KnowledgeDocumentCreate) -> KnowledgeDocument: with self._lock: document = KnowledgeDocument( @@ -135,35 +224,181 @@ class InMemoryAiPlatformRepository: return self.ai_run_audits[:limit] def seed_defaults(self) -> None: - self.upsert_provider_config( - ProviderConfigCreate( - provider="mock", - model="legal-qa-mvp", - display_name="Legal QA MVP", - temperature=0.2, - max_tokens=2048, - enabled=True, - is_default=True, + if not self.list_provider_configs(): + self.upsert_provider_config(_default_provider_config()) + if not self.list_prompt_templates(): + self.upsert_prompt_template(_default_prompt_template()) + if not self.search_knowledge("", limit=1): + for document in _default_knowledge_documents(): + self.add_knowledge_document(document) + + +class SqlAlchemyAiPlatformRepository: + def __init__( + self, + database_url: str, + *, + engine: Engine | None = None, + auto_create: bool = True, + ) -> None: + self._engine = engine or build_sqlalchemy_engine(database_url) + self._session_factory = sessionmaker(bind=self._engine, expire_on_commit=False) + if auto_create: + Base.metadata.create_all(self._engine) + + def upsert_provider_config(self, payload: ProviderConfigCreate) -> ProviderConfig: + now = datetime.now(UTC) + with self._session_factory.begin() as session: + if payload.is_default: + for config in session.scalars( + select(AiProviderConfigModel).where(AiProviderConfigModel.is_default.is_(True)) + ): + config.is_default = False + + config = session.scalar( + select(AiProviderConfigModel).where( + AiProviderConfigModel.provider == payload.provider, + AiProviderConfigModel.model == payload.model, + ) ) - ) - self.add_knowledge_document( - KnowledgeDocumentCreate( - knowledge_base_id="laws-cn", - title="中华人民共和国劳动合同法", - source_type="law", - reference="第十九条", - content="劳动合同期限三个月以上不满一年的,试用期不得超过一个月。", + if config is None: + config = AiProviderConfigModel(id=f"provider-{uuid4().hex[:12]}") + session.add(config) + + for key, value in payload.model_dump().items(): + setattr(config, key, value) + config.updated_at = now + session.flush() + return _provider_config_from_model(config) + + def list_provider_configs(self) -> list[ProviderConfig]: + with self._session_factory() as session: + rows = session.scalars(select(AiProviderConfigModel).order_by(AiProviderConfigModel.updated_at.desc())) + return [_provider_config_from_model(row) for row in rows] + + def get_default_provider_config(self) -> ProviderConfig | None: + with self._session_factory() as session: + config = session.scalar( + select(AiProviderConfigModel) + .where( + AiProviderConfigModel.enabled.is_(True), + AiProviderConfigModel.is_default.is_(True), + ) + .order_by(AiProviderConfigModel.updated_at.desc()) + .limit(1) ) - ) - self.add_knowledge_document( - KnowledgeDocumentCreate( - knowledge_base_id="laws-cn", - title="中华人民共和国民法典", - source_type="law", - reference="第七百零三条", - content="租赁合同是出租人将租赁物交付承租人使用、收益,承租人支付租金的合同。", + if config is None: + config = session.scalar( + select(AiProviderConfigModel) + .where(AiProviderConfigModel.enabled.is_(True)) + .order_by(AiProviderConfigModel.updated_at.desc()) + .limit(1) + ) + return _provider_config_from_model(config) if config else None + + def upsert_prompt_template(self, payload: PromptTemplateCreate) -> PromptTemplate: + now = datetime.now(UTC) + with self._session_factory.begin() as session: + template = session.scalar( + select(PromptTemplateModel).where( + PromptTemplateModel.name == payload.name, + PromptTemplateModel.version == payload.version, + ) ) + if template is None: + template = PromptTemplateModel(id=f"prompt-{uuid4().hex[:12]}") + session.add(template) + + for key, value in payload.model_dump().items(): + setattr(template, key, value) + template.updated_at = now + session.flush() + return _prompt_template_from_model(template) + + def list_prompt_templates(self) -> list[PromptTemplate]: + with self._session_factory() as session: + rows = session.scalars(select(PromptTemplateModel).order_by(PromptTemplateModel.updated_at.desc())) + return [_prompt_template_from_model(row) for row in rows] + + def get_prompt_template(self, name: str) -> PromptTemplate | None: + with self._session_factory() as session: + template = session.scalar( + select(PromptTemplateModel) + .where( + PromptTemplateModel.name == name, + PromptTemplateModel.enabled.is_(True), + ) + .order_by(PromptTemplateModel.updated_at.desc()) + .limit(1) + ) + return _prompt_template_from_model(template) if template else None + + def add_knowledge_document(self, payload: KnowledgeDocumentCreate) -> KnowledgeDocument: + document = KnowledgeDocumentModel( + id=f"doc-{uuid4().hex[:12]}", + created_at=datetime.now(UTC), + **payload.model_dump(), ) + with self._session_factory.begin() as session: + session.add(document) + session.flush() + return _knowledge_document_from_model(document) + + def search_knowledge( + self, + keyword: str, + *, + knowledge_base_ids: list[str] | None = None, + limit: int = 5, + ) -> list[KnowledgeDocument]: + normalized = keyword.strip().lower() + with self._session_factory() as session: + statement = select(KnowledgeDocumentModel).order_by(KnowledgeDocumentModel.created_at.desc()) + if knowledge_base_ids: + statement = statement.where(KnowledgeDocumentModel.knowledge_base_id.in_(knowledge_base_ids)) + rows = session.scalars(statement).all() + + matches: list[KnowledgeDocument] = [] + for row in rows: + document = _knowledge_document_from_model(row) + haystack = f"{document.title}\n{document.reference}\n{document.content}".lower() + if not normalized or normalized in haystack: + matches.append(document) + if len(matches) >= limit: + break + return matches + + def add_ai_run_audit(self, audit: AiRunAudit) -> AiRunAudit: + row = AiRunAuditModel(**audit.model_dump()) + with self._session_factory.begin() as session: + session.add(row) + session.flush() + return _ai_run_audit_from_model(row) + + def list_ai_run_audits(self, *, limit: int = 20) -> list[AiRunAudit]: + with self._session_factory() as session: + rows = session.scalars( + select(AiRunAuditModel).order_by(AiRunAuditModel.created_at.desc()).limit(limit) + ) + return [_ai_run_audit_from_model(row) for row in rows] + + def seed_defaults(self) -> None: + if not self.list_provider_configs(): + self.upsert_provider_config(_default_provider_config()) + if not self.list_prompt_templates(): + self.upsert_prompt_template(_default_prompt_template()) + if not self.search_knowledge("", limit=1): + for document in _default_knowledge_documents(): + self.add_knowledge_document(document) + + +def build_sqlalchemy_engine(database_url: str) -> Engine: + engine_options: dict[str, object] = {"future": True} + if database_url.startswith("sqlite"): + engine_options["connect_args"] = {"check_same_thread": False} + if database_url in {"sqlite://", "sqlite:///:memory:", "sqlite+pysqlite:///:memory:"}: + engine_options["poolclass"] = StaticPool + return create_engine(database_url, **engine_options) def document_to_citation(document: KnowledgeDocument) -> LegalCitation: @@ -173,3 +408,114 @@ def document_to_citation(document: KnowledgeDocument) -> LegalCitation: reference=document.reference, quote=document.content, ) + + +def _provider_config_from_model(row: AiProviderConfigModel) -> ProviderConfig: + return ProviderConfig( + id=row.id, + provider=row.provider, + model=row.model, + display_name=row.display_name, + base_url=row.base_url, + temperature=row.temperature, + max_tokens=row.max_tokens, + enabled=row.enabled, + is_default=row.is_default, + updated_at=_ensure_utc(row.updated_at), + ) + + +def _prompt_template_from_model(row: PromptTemplateModel) -> PromptTemplate: + return PromptTemplate( + id=row.id, + name=row.name, + version=row.version, + content=row.content, + enabled=row.enabled, + updated_at=_ensure_utc(row.updated_at), + ) + + +def _knowledge_document_from_model(row: KnowledgeDocumentModel) -> KnowledgeDocument: + return KnowledgeDocument( + id=row.id, + knowledge_base_id=row.knowledge_base_id, + title=row.title, + source_type=row.source_type, + reference=row.reference, + content=row.content, + created_at=_ensure_utc(row.created_at), + ) + + +def _ai_run_audit_from_model(row: AiRunAuditModel) -> AiRunAudit: + return AiRunAudit( + id=row.id, + operation=row.operation, + provider_config_id=row.provider_config_id, + prompt_template_id=row.prompt_template_id, + question=row.question, + answer=row.answer, + citations_count=row.citations_count, + latency_ms=row.latency_ms, + status=row.status, + created_at=_ensure_utc(row.created_at), + ) + + +def _ensure_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + + +def _default_provider_config() -> ProviderConfigCreate: + return ProviderConfigCreate( + provider="mock", + model="legal-qa-mvp", + display_name="Legal QA MVP", + temperature=0.2, + max_tokens=2048, + enabled=True, + is_default=True, + ) + + +def _default_prompt_template() -> PromptTemplateCreate: + return 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" + ), + enabled=True, + ) + + +def _default_knowledge_documents() -> list[KnowledgeDocumentCreate]: + return [ + KnowledgeDocumentCreate( + knowledge_base_id="laws-cn", + title="\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u52b3\u52a8\u5408\u540c\u6cd5", + source_type="law", + reference="\u7b2c\u5341\u4e5d\u6761", + content=( + "\u52b3\u52a8\u5408\u540c\u671f\u9650\u4e09\u4e2a\u6708\u4ee5\u4e0a" + "\u4e0d\u6ee1\u4e00\u5e74\u7684\uff0c\u8bd5\u7528\u671f\u4e0d\u5f97" + "\u8d85\u8fc7\u4e00\u4e2a\u6708\u3002" + ), + ), + KnowledgeDocumentCreate( + knowledge_base_id="laws-cn", + title="\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u6c11\u6cd5\u5178", + source_type="law", + reference="\u7b2c\u4e03\u767e\u96f6\u4e09\u6761", + content=( + "\u79df\u8d41\u5408\u540c\u662f\u51fa\u79df\u4eba\u5c06\u79df\u8d41\u7269" + "\u4ea4\u4ed8\u627f\u79df\u4eba\u4f7f\u7528\u3001\u6536\u76ca\uff0c" + "\u627f\u79df\u4eba\u652f\u4ed8\u79df\u91d1\u7684\u5408\u540c\u3002" + ), + ), + ] diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/state.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/state.py index 7654f879..37c79fca 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/state.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/state.py @@ -1,10 +1,32 @@ from functools import lru_cache +from typing import Literal -from yuqei_ai_platform_api.repository import InMemoryAiPlatformRepository +from yuqei_ai_platform_api.config import AiPlatformSettings, get_settings +from yuqei_ai_platform_api.repository import ( + AiPlatformRepository, + InMemoryAiPlatformRepository, + SqlAlchemyAiPlatformRepository, +) + + +def get_repository(settings: AiPlatformSettings | None = None) -> AiPlatformRepository: + resolved_settings = settings or get_settings() + return _get_repository( + resolved_settings.repository_backend, + resolved_settings.database_url, + resolved_settings.database_auto_create, + ) @lru_cache -def get_repository() -> InMemoryAiPlatformRepository: - repository = InMemoryAiPlatformRepository() +def _get_repository( + repository_backend: Literal["memory", "sqlalchemy"], + database_url: str, + database_auto_create: bool, +) -> AiPlatformRepository: + if repository_backend == "sqlalchemy": + repository = SqlAlchemyAiPlatformRepository(database_url, auto_create=database_auto_create) + else: + repository = InMemoryAiPlatformRepository() repository.seed_defaults() return repository 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 e8869070..6222408f 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 @@ -2,7 +2,14 @@ from fastapi.testclient import TestClient from yuqei_ai_platform_api.config import AiPlatformSettings from yuqei_ai_platform_api.main import create_app -from yuqei_ai_platform_api.repository import InMemoryAiPlatformRepository +from yuqei_ai_platform_api.repository import ( + AiRunAudit, + InMemoryAiPlatformRepository, + KnowledgeDocumentCreate, + ProviderConfigCreate, + PromptTemplateCreate, + SqlAlchemyAiPlatformRepository, +) def make_client() -> TestClient: @@ -41,6 +48,30 @@ def test_model_config_can_be_hot_updated_and_listed() -> None: assert sum(1 for config in configs if config["is_default"]) == 1 +def test_prompt_template_can_be_hot_updated_and_listed() -> None: + client = make_client() + + response = client.post( + "/api/v1/prompt-templates", + json={ + "name": "legal_qa.default", + "version": "v2", + "content": "先检索依据,再给出结论。", + "enabled": True, + }, + ) + + assert response.status_code == 200 + created = response.json() + assert created["name"] == "legal_qa.default" + assert created["version"] == "v2" + + list_response = client.get("/api/v1/prompt-templates") + assert list_response.status_code == 200 + templates = list_response.json() + assert templates[0]["version"] == "v2" + + def test_knowledge_document_can_be_added_and_searched() -> None: client = make_client() @@ -91,4 +122,59 @@ def test_legal_qa_uses_knowledge_and_records_audit() -> None: audits = audit_response.json() 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" + + +def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None: + database_url = f"sqlite+pysqlite:///{(tmp_path / 'ai-platform.sqlite').as_posix()}" + repository = SqlAlchemyAiPlatformRepository(database_url) + repository.seed_defaults() + + provider = repository.upsert_provider_config( + ProviderConfigCreate( + provider="deepseek", + model="deepseek-chat", + display_name="DeepSeek Chat", + temperature=0.1, + max_tokens=4096, + enabled=True, + is_default=True, + ) + ) + prompt = repository.upsert_prompt_template( + PromptTemplateCreate( + name="legal_qa.default", + version="v2", + content="先检索依据,再回答。", + ) + ) + repository.add_knowledge_document( + KnowledgeDocumentCreate( + knowledge_base_id="laws-cn", + title="中华人民共和国民法典", + source_type="law", + reference="第七百零三条", + content="租赁合同是出租人将租赁物交付承租人使用、收益,承租人支付租金的合同。", + ) + ) + repository.add_ai_run_audit( + AiRunAudit( + id="run-test-001", + operation="legal_qa", + provider_config_id=provider.id, + prompt_template_id=prompt.id, + question="租赁合同如何约定押金?", + answer="应明确押金返还条件。", + citations_count=1, + latency_ms=12, + status="succeeded", + ) + ) + + reloaded = SqlAlchemyAiPlatformRepository(database_url) + + assert reloaded.get_default_provider_config().model == "deepseek-chat" # type: ignore[union-attr] + assert reloaded.list_prompt_templates()[0].version == "v2" + assert reloaded.search_knowledge("租赁合同", knowledge_base_ids=["laws-cn"])[0].reference == "第七百零三条" + assert reloaded.list_ai_run_audits()[0].id == "run-test-001" diff --git a/yuqei-ai-sdk-python/README.md b/yuqei-ai-sdk-python/README.md index d32a7493..08c5b0e6 100644 --- a/yuqei-ai-sdk-python/README.md +++ b/yuqei-ai-sdk-python/README.md @@ -7,3 +7,7 @@ Initial scope: - AI Platform legal QA client. - Request context headers. - Typed request and response models. +- Model provider config management. +- Prompt template management. +- Knowledge document insert/search. +- AI run audit listing. diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py b/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py index 6ce95795..dc5832e3 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py @@ -1,10 +1,29 @@ -from yuqei_sdk.ai_platform import AiPlatformClient, LegalCitation, LegalQaRequest, LegalQaResponse +from yuqei_sdk.ai_platform import ( + AiPlatformClient, + AiRunAudit, + KnowledgeDocument, + KnowledgeDocumentCreate, + LegalCitation, + LegalQaRequest, + LegalQaResponse, + PromptTemplate, + PromptTemplateCreate, + ProviderConfig, + ProviderConfigCreate, +) from yuqei_sdk.context import RequestContext __all__ = [ "AiPlatformClient", + "AiRunAudit", + "KnowledgeDocument", + "KnowledgeDocumentCreate", "LegalCitation", "LegalQaRequest", "LegalQaResponse", + "PromptTemplate", + "PromptTemplateCreate", + "ProviderConfig", + "ProviderConfigCreate", "RequestContext", ] diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py index 38c8fb67..533781d5 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py @@ -26,6 +26,60 @@ class LegalQaResponse(BaseModel): confidence: float | None = None +class ProviderConfigCreate(BaseModel): + provider: str = Field(min_length=1) + model: str = Field(min_length=1) + display_name: str = Field(min_length=1) + base_url: str | None = None + temperature: float = 0.2 + max_tokens: int = 4096 + enabled: bool = True + is_default: bool = False + + +class ProviderConfig(ProviderConfigCreate): + id: str + updated_at: str + + +class PromptTemplateCreate(BaseModel): + name: str = Field(min_length=1) + version: str = Field(default="draft", min_length=1) + content: str = Field(min_length=1) + enabled: bool = True + + +class PromptTemplate(PromptTemplateCreate): + id: str + updated_at: str + + +class KnowledgeDocumentCreate(BaseModel): + knowledge_base_id: str = "default" + title: str = Field(min_length=1) + source_type: str = "knowledge_document" + reference: str = "" + content: str = Field(min_length=1) + + +class KnowledgeDocument(KnowledgeDocumentCreate): + id: str + created_at: str + + +class AiRunAudit(BaseModel): + id: str + operation: str + provider_config_id: str | None = None + prompt_template_id: str | None = None + question: str | None = None + answer: str | None = None + citations_count: int = 0 + latency_ms: int = 0 + status: str + created_at: str + + class AiPlatformClient: def __init__( self, @@ -65,3 +119,90 @@ class AiPlatformClient: response.raise_for_status() payload: dict[str, Any] = response.json() return LegalQaResponse.model_validate(payload) + + def list_model_configs(self, *, context: RequestContext | None = None) -> list[ProviderConfig]: + response = self._client.get( + f"{self._api_prefix}/model-configs", + headers=(context or RequestContext()).to_headers(), + ) + response.raise_for_status() + return [ProviderConfig.model_validate(item) for item in response.json()] + + def upsert_model_config( + self, + config: ProviderConfigCreate, + *, + context: RequestContext | None = None, + ) -> ProviderConfig: + response = self._client.post( + f"{self._api_prefix}/model-configs", + json=config.model_dump(exclude_none=True), + headers=(context or RequestContext()).to_headers(), + ) + response.raise_for_status() + return ProviderConfig.model_validate(response.json()) + + def list_prompt_templates(self, *, context: RequestContext | None = None) -> list[PromptTemplate]: + response = self._client.get( + f"{self._api_prefix}/prompt-templates", + headers=(context or RequestContext()).to_headers(), + ) + response.raise_for_status() + return [PromptTemplate.model_validate(item) for item in response.json()] + + def upsert_prompt_template( + self, + template: PromptTemplateCreate, + *, + context: RequestContext | None = None, + ) -> PromptTemplate: + response = self._client.post( + f"{self._api_prefix}/prompt-templates", + json=template.model_dump(exclude_none=True), + headers=(context or RequestContext()).to_headers(), + ) + response.raise_for_status() + return PromptTemplate.model_validate(response.json()) + + def add_knowledge_document( + self, + document: KnowledgeDocumentCreate, + *, + context: RequestContext | None = None, + ) -> KnowledgeDocument: + response = self._client.post( + f"{self._api_prefix}/knowledge-documents", + json=document.model_dump(exclude_none=True), + headers=(context or RequestContext()).to_headers(), + ) + response.raise_for_status() + return KnowledgeDocument.model_validate(response.json()) + + def search_knowledge_documents( + self, + keyword: str = "", + *, + limit: int = 5, + context: RequestContext | None = None, + ) -> list[KnowledgeDocument]: + response = self._client.get( + f"{self._api_prefix}/knowledge-documents/search", + params={"keyword": keyword, "limit": limit}, + headers=(context or RequestContext()).to_headers(), + ) + response.raise_for_status() + return [KnowledgeDocument.model_validate(item) for item in response.json()] + + def list_ai_run_audits( + self, + *, + limit: int = 20, + context: RequestContext | None = None, + ) -> list[AiRunAudit]: + response = self._client.get( + f"{self._api_prefix}/audit/ai-runs", + params={"limit": limit}, + headers=(context or RequestContext()).to_headers(), + ) + response.raise_for_status() + return [AiRunAudit.model_validate(item) for item in response.json()] diff --git a/yuqei-ai-sdk-python/tests/test_ai_platform.py b/yuqei-ai-sdk-python/tests/test_ai_platform.py index 4aa4250a..3af2c730 100644 --- a/yuqei-ai-sdk-python/tests/test_ai_platform.py +++ b/yuqei-ai-sdk-python/tests/test_ai_platform.py @@ -1,6 +1,15 @@ +from urllib.parse import parse_qs + import httpx -from yuqei_sdk import AiPlatformClient, LegalQaRequest, RequestContext +from yuqei_sdk import ( + AiPlatformClient, + KnowledgeDocumentCreate, + LegalQaRequest, + PromptTemplateCreate, + ProviderConfigCreate, + RequestContext, +) def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None: @@ -41,3 +50,149 @@ def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None: assert captured_request.headers["X-Trace-Id"] == "trace-1" assert captured_request.headers["X-Tenant-Id"] == "tenant-1" assert captured_request.headers["X-User-Id"] == "user-1" + + +def test_ai_platform_client_covers_admin_data_endpoints() -> None: + seen_paths: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_paths.append(request.url.path) + if request.url.path == "/api/v1/model-configs" and request.method == "POST": + return httpx.Response( + 200, + json={ + "id": "provider-1", + "provider": "deepseek", + "model": "deepseek-chat", + "display_name": "DeepSeek Chat", + "base_url": None, + "temperature": 0.1, + "max_tokens": 4096, + "enabled": True, + "is_default": True, + "updated_at": "2026-06-22T00:00:00Z", + }, + ) + if request.url.path == "/api/v1/model-configs": + return httpx.Response( + 200, + json=[ + { + "id": "provider-1", + "provider": "deepseek", + "model": "deepseek-chat", + "display_name": "DeepSeek Chat", + "base_url": None, + "temperature": 0.1, + "max_tokens": 4096, + "enabled": True, + "is_default": True, + "updated_at": "2026-06-22T00:00:00Z", + } + ], + ) + if request.url.path == "/api/v1/prompt-templates" and request.method == "POST": + return httpx.Response( + 200, + json={ + "id": "prompt-1", + "name": "legal_qa.default", + "version": "v2", + "content": "先检索依据,再回答。", + "enabled": True, + "updated_at": "2026-06-22T00:00:00Z", + }, + ) + if request.url.path == "/api/v1/prompt-templates": + return httpx.Response( + 200, + json=[ + { + "id": "prompt-1", + "name": "legal_qa.default", + "version": "v2", + "content": "先检索依据,再回答。", + "enabled": True, + "updated_at": "2026-06-22T00:00:00Z", + } + ], + ) + if request.url.path == "/api/v1/knowledge-documents" and request.method == "POST": + return httpx.Response( + 200, + json={ + "id": "doc-1", + "knowledge_base_id": "laws-cn", + "title": "中华人民共和国民法典", + "source_type": "law", + "reference": "第七百零三条", + "content": "租赁合同是出租人将租赁物交付承租人使用、收益,承租人支付租金的合同。", + "created_at": "2026-06-22T00:00:00Z", + }, + ) + if request.url.path == "/api/v1/knowledge-documents/search": + query = parse_qs(request.url.query.decode()) + assert query["keyword"] == ["租赁"] + assert query["limit"] == ["3"] + return httpx.Response( + 200, + json=[ + { + "id": "doc-1", + "knowledge_base_id": "laws-cn", + "title": "中华人民共和国民法典", + "source_type": "law", + "reference": "第七百零三条", + "content": "租赁合同是出租人将租赁物交付承租人使用、收益,承租人支付租金的合同。", + "created_at": "2026-06-22T00:00:00Z", + } + ], + ) + if request.url.path == "/api/v1/audit/ai-runs": + return httpx.Response( + 200, + json=[ + { + "id": "run-1", + "operation": "legal_qa", + "provider_config_id": "provider-1", + "prompt_template_id": "prompt-1", + "question": "租赁合同如何约定押金?", + "answer": "应明确押金返还条件。", + "citations_count": 1, + "latency_ms": 12, + "status": "succeeded", + "created_at": "2026-06-22T00:00:00Z", + } + ], + ) + return httpx.Response(404) + + client = AiPlatformClient("http://ai-platform.test", transport=httpx.MockTransport(handler)) + + assert client.upsert_model_config( + ProviderConfigCreate( + provider="deepseek", + model="deepseek-chat", + display_name="DeepSeek Chat", + temperature=0.1, + is_default=True, + ) + ).model == "deepseek-chat" + assert client.list_model_configs()[0].provider == "deepseek" + assert client.upsert_prompt_template( + PromptTemplateCreate(name="legal_qa.default", version="v2", content="先检索依据,再回答。") + ).version == "v2" + assert client.list_prompt_templates()[0].name == "legal_qa.default" + assert client.add_knowledge_document( + KnowledgeDocumentCreate( + knowledge_base_id="laws-cn", + title="中华人民共和国民法典", + source_type="law", + reference="第七百零三条", + content="租赁合同是出租人将租赁物交付承租人使用、收益,承租人支付租金的合同。", + ) + ).reference == "第七百零三条" + assert client.search_knowledge_documents("租赁", limit=3)[0].title == "中华人民共和国民法典" + assert client.list_ai_run_audits(limit=5)[0].operation == "legal_qa" + assert "/api/v1/audit/ai-runs" in seen_paths diff --git a/yuqei-infra/compose/docker-compose.yml b/yuqei-infra/compose/docker-compose.yml index 6bbe0794..30403a7d 100644 --- a/yuqei-infra/compose/docker-compose.yml +++ b/yuqei-infra/compose/docker-compose.yml @@ -68,6 +68,7 @@ services: environment: AI_PLATFORM_ENVIRONMENT: local AI_PLATFORM_DATABASE_URL: postgresql+psycopg://yuqei:yuqei-local@postgres:5432/yuqei_v2 + AI_PLATFORM_REPOSITORY_BACKEND: sqlalchemy AI_PLATFORM_REDIS_URL: redis://redis:6379/1 AI_PLATFORM_PROVIDER_CONFIG_SOURCE: database ports: diff --git a/yuqei-infra/docker/ai-platform-api.Dockerfile b/yuqei-infra/docker/ai-platform-api.Dockerfile index 726d045a..001b3150 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 sqlalchemy uvicorn +RUN pip install --no-cache-dir fastapi pydantic pydantic-settings "psycopg[binary]" sqlalchemy uvicorn COPY yuqei-ai-platform/services/ai-platform-api yuqei-ai-platform/services/ai-platform-api