feat: persist ai platform prompt and audit data
This commit is contained in:
@@ -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(
|
||||
|
||||
+371
-25
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user