feat: persist ai platform prompt and audit data

This commit is contained in:
2026-06-22 20:44:36 +08:00
parent a1f04c7d7a
commit 3f46b59f76
15 changed files with 1011 additions and 42 deletions
@@ -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. 为知识库增加导入批次、切分结果、检索命中分数和来源定位。
@@ -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:
@@ -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:
@@ -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",
]
@@ -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",
@@ -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(
@@ -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"
),
),
]
@@ -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
@@ -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"
+4
View File
@@ -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.
+20 -1
View File
@@ -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",
]
@@ -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()]
+156 -1
View File
@@ -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
+1
View File
@@ -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:
@@ -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