feat: add AI platform config knowledge audit MVP

This commit is contained in:
2026-06-22 19:08:03 +08:00
parent 25d1ae0380
commit 65b4b45b5b
8 changed files with 796 additions and 15 deletions
+172 -9
View File
@@ -81,6 +81,35 @@ paths:
text/event-stream:
schema:
type: string
/api/v1/model-configs:
get:
operationId: listModelConfigs
summary: List model provider configs
responses:
"200":
description: Model provider configs
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ProviderConfig"
post:
operationId: upsertModelConfig
summary: Create or update a model provider config
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ProviderConfigCreate"
responses:
"200":
description: Model provider config
content:
application/json:
schema:
$ref: "#/components/schemas/ProviderConfig"
/api/v1/knowledge-bases:
get:
operationId: listKnowledgeBases
@@ -146,6 +175,51 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/KnowledgeDocument"
/api/v1/knowledge-documents:
post:
operationId: addKnowledgeDocument
summary: Add a text knowledge document
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/KnowledgeDocumentCreate"
responses:
"200":
description: Knowledge document
content:
application/json:
schema:
$ref: "#/components/schemas/KnowledgeDocument"
/api/v1/knowledge-documents/search:
get:
operationId: searchKnowledgeDocuments
summary: Search knowledge documents
parameters:
- name: keyword
in: query
required: false
schema:
type: string
default: ""
- name: limit
in: query
required: false
schema:
type: integer
default: 5
minimum: 1
maximum: 20
responses:
"200":
description: Knowledge document matches
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/KnowledgeDocument"
/api/v1/audit/ai-runs:
get:
operationId: listAiRuns
@@ -156,11 +230,13 @@ paths:
- $ref: "#/components/parameters/PageSize"
responses:
"200":
description: AI run audit page
description: AI run audit list
content:
application/json:
schema:
$ref: "#/components/schemas/AiRunPage"
type: array
items:
$ref: "#/components/schemas/AiRun"
components:
parameters:
TraceIdHeader:
@@ -351,6 +427,48 @@ components:
context:
type: object
additionalProperties: true
ProviderConfigCreate:
type: object
required:
- provider
- model
- display_name
properties:
provider:
type: string
model:
type: string
display_name:
type: string
base_url:
type: string
nullable: true
temperature:
type: number
format: float
default: 0.2
max_tokens:
type: integer
default: 4096
enabled:
type: boolean
default: true
is_default:
type: boolean
default: false
ProviderConfig:
allOf:
- $ref: "#/components/schemas/ProviderConfigCreate"
- type: object
required:
- id
- updated_at
properties:
id:
type: string
updated_at:
type: string
format: date-time
KnowledgeBase:
type: object
required:
@@ -400,19 +518,48 @@ components:
type: object
required:
- id
- file_name
- status
- knowledge_base_id
- title
- source_type
- reference
- content
- created_at
properties:
id:
type: string
file_name:
knowledge_base_id:
type: string
status:
title:
type: string
enum: [uploaded, extracting, indexed, failed]
failure_message:
source_type:
type: string
enum: [law, regulation, judicial_interpretation, internal_policy, contract_clause, knowledge_document]
reference:
type: string
content:
type: string
created_at:
type: string
format: date-time
KnowledgeDocumentCreate:
type: object
required:
- title
- content
properties:
knowledge_base_id:
type: string
default: default
title:
type: string
source_type:
type: string
default: knowledge_document
reference:
type: string
default: ""
content:
type: string
nullable: true
AiRun:
type: object
required:
@@ -425,6 +572,22 @@ components:
type: string
operation:
type: string
provider_config_id:
type: string
nullable: true
prompt_template_id:
type: string
nullable: true
question:
type: string
nullable: true
answer:
type: string
nullable: true
citations_count:
type: integer
latency_ms:
type: integer
status:
type: string
enum: [pending, running, succeeded, failed, cancelled]
@@ -0,0 +1,132 @@
# V2 AI 平台配置、知识库与审计 MVP 记录
日期:2026-06-22
## 1. 本轮目标
进入 AI Platform 的核心底座:模型参数热更新、知识库检索、AI 调用审计。
本轮先完成可测试 MVP,不直接接真实模型 provider,也不做完整向量检索,避免在平台边界未稳定前过早扩大范围。
## 2. 已完成内容
### 数据模型
新增 SQLAlchemy 表模型:
- `AiProviderConfigModel`
- `PromptTemplateModel`
- `KnowledgeDocumentModel`
- `AiRunAuditModel`
说明:
- 当前接口运行使用内存仓库,便于测试和本地开发。
- SQLAlchemy 模型已经定义好,下一步可接 Alembic/PostgreSQL。
### 模型配置热更新
新增接口:
```text
GET /api/v1/model-configs
POST /api/v1/model-configs
```
能力:
- 新增或更新 provider/model 配置。
- 支持 `temperature`、`max_tokens`、`enabled`、`is_default`。
- 新默认配置写入时自动取消旧默认。
### 知识库文本入库与检索
新增接口:
```text
POST /api/v1/knowledge-documents
GET /api/v1/knowledge-documents/search
```
能力:
- 录入法规、企业制度、合同条款等文本资料。
- 按关键词检索标题、引用位置和正文内容。
- 法律问答会优先把检索命中的知识文档转成引用依据。
### AI 调用审计
新增接口:
```text
GET /api/v1/audit/ai-runs
```
法律问答会记录:
- operation
- provider_config_id
- question
- answer
- citations_count
- latency_ms
- status
- created_at
## 3. 法律问答闭环变化
之前:
```text
问题 -> 规则型回答
```
现在:
```text
问题 -> 知识库检索 -> 合并引用依据 -> 规则型回答 -> 写入 AI 调用审计
```
这个流程为后续替换成真实 RAG 和模型生成打好了边界。
## 4. 验证结果
已通过:
```powershell
python -m pytest
```
结果:
```text
13 passed
```
覆盖:
- 模型配置可热更新并列出。
- 知识文档可添加并检索。
- 法律问答会使用知识依据。
- 法律问答会写入 AI 调用审计。
## 5. 当前边界
未完成:
- Alembic migration。
- PostgreSQL 持久化仓库。
- 向量检索。
- Prompt 模板接口。
- 真实 provider 调用。
- 成本、token、失败重试审计。
## 6. 下一步建议
下一步进入 AI Platform 持久化:
1. 建立 Alembic 迁移。
2. 把内存仓库替换为 SQLAlchemy repository。
3. 增加 Prompt Template 接口。
4. 增加真实模型调用适配器的抽象接口。
5. 让法律问答支持“检索依据 + Prompt 模板 + Provider 调用 + 审计落库”。
@@ -21,17 +21,23 @@ class LegalQaResponse(BaseModel):
confidence: float | None = None
def answer_legal_question(request: LegalQaRequest) -> LegalQaResponse:
def answer_legal_question(
request: LegalQaRequest,
*,
retrieved_citations: list[LegalCitation] | None = None,
) -> LegalQaResponse:
question = request.question.strip()
context = request.matter_context.strip() if request.matter_context else ""
question_text = question + "\n" + context
citations_from_search = retrieved_citations or []
if _contains_any(question_text, ["试用期", "劳动合同", "工资", "解除", "竞业"]):
return _labor_answer(question)
return _with_retrieved_citations(_labor_answer(question), citations_from_search)
if _contains_any(question_text, ["租赁", "房屋", "租金", "押金", "保证金"]):
return _lease_answer(question)
return _with_retrieved_citations(_lease_answer(question), citations_from_search)
return LegalQaResponse(
return _with_retrieved_citations(
LegalQaResponse(
answer=(
"当前问题需要结合合同类型、交易背景和适用地区进一步判断。"
"V2 MVP 会先返回可审计的基础意见;正式版本会先检索企业知识库和法规库,"
@@ -46,6 +52,8 @@ def answer_legal_question(request: LegalQaRequest) -> LegalQaResponse:
)
],
confidence=0.45,
),
citations_from_search,
)
@@ -101,3 +109,23 @@ def _lease_answer(question: str) -> LegalQaResponse:
def _contains_any(text: str, keywords: list[str]) -> bool:
return any(keyword in text for keyword in keywords)
def _with_retrieved_citations(
response: LegalQaResponse,
citations: list[LegalCitation],
) -> LegalQaResponse:
if not citations:
return response
merged = [*citations, *response.citations]
deduped: list[LegalCitation] = []
seen: set[tuple[str, str, str]] = set()
for citation in merged:
key = (citation.title, citation.reference, citation.quote)
if key in seen:
continue
seen.add(key)
deduped.append(citation)
return response.model_copy(update={"citations": deduped})
@@ -1,4 +1,6 @@
from typing import Literal
from time import perf_counter
from uuid import uuid4
from fastapi import FastAPI
from pydantic import BaseModel
@@ -7,6 +9,16 @@ from yuqei_ai_platform_api import __version__
from yuqei_ai_platform_api.config import AiPlatformSettings, get_settings
from yuqei_ai_platform_api.legal_qa import LegalQaRequest, LegalQaResponse, answer_legal_question
from yuqei_ai_platform_api.logging import configure_logging
from yuqei_ai_platform_api.repository import (
AiRunAudit,
InMemoryAiPlatformRepository,
KnowledgeDocument,
KnowledgeDocumentCreate,
ProviderConfig,
ProviderConfigCreate,
document_to_citation,
)
from yuqei_ai_platform_api.state import get_repository
class HealthResponse(BaseModel):
@@ -17,8 +29,12 @@ class HealthResponse(BaseModel):
provider_config_source: str
def create_app(settings: AiPlatformSettings | None = None) -> FastAPI:
def create_app(
settings: AiPlatformSettings | None = None,
repository: InMemoryAiPlatformRepository | None = None,
) -> FastAPI:
resolved_settings = settings or get_settings()
store = repository or get_repository()
configure_logging(resolved_settings.log_level)
app = FastAPI(
@@ -42,15 +58,104 @@ def create_app(settings: AiPlatformSettings | None = None) -> FastAPI:
def api_health() -> HealthResponse:
return health()
@app.get(
f"{resolved_settings.api_prefix}/model-configs",
response_model=list[ProviderConfig],
tags=["model-configs"],
)
def list_model_configs() -> list[ProviderConfig]:
return store.list_provider_configs()
@app.post(
f"{resolved_settings.api_prefix}/model-configs",
response_model=ProviderConfig,
tags=["model-configs"],
)
def upsert_model_config(payload: ProviderConfigCreate) -> ProviderConfig:
return store.upsert_provider_config(payload)
@app.post(
f"{resolved_settings.api_prefix}/knowledge-documents",
response_model=KnowledgeDocument,
tags=["knowledge"],
)
def add_knowledge_document(payload: KnowledgeDocumentCreate) -> KnowledgeDocument:
return store.add_knowledge_document(payload)
@app.get(
f"{resolved_settings.api_prefix}/knowledge-documents/search",
response_model=list[KnowledgeDocument],
tags=["knowledge"],
)
def search_knowledge_documents(keyword: str = "", limit: int = 5) -> list[KnowledgeDocument]:
return store.search_knowledge(keyword, limit=limit)
@app.get(
f"{resolved_settings.api_prefix}/audit/ai-runs",
response_model=list[AiRunAudit],
tags=["audit"],
)
def list_ai_runs(limit: int = 20) -> list[AiRunAudit]:
return store.list_ai_run_audits(limit=limit)
@app.post(
f"{resolved_settings.api_prefix}/legal/qa",
response_model=LegalQaResponse,
tags=["legal"],
)
def run_legal_qa(request: LegalQaRequest) -> LegalQaResponse:
return answer_legal_question(request)
started_at = perf_counter()
documents = _search_citation_documents(store, request)
response = answer_legal_question(
request,
retrieved_citations=[document_to_citation(document) for document in documents],
)
provider_config = store.get_default_provider_config()
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,
question=request.question,
answer=response.answer,
citations_count=len(response.citations),
latency_ms=max(0, int((perf_counter() - started_at) * 1000)),
status="succeeded",
)
)
return response
return app
app = create_app()
def _search_citation_documents(
repository: InMemoryAiPlatformRepository,
request: LegalQaRequest,
) -> list[KnowledgeDocument]:
documents = repository.search_knowledge(
request.question,
knowledge_base_ids=request.knowledge_base_ids,
limit=3,
)
if documents:
return documents
question_text = f"{request.question}\n{request.matter_context or ''}"
fallback_keywords: list[str] = []
if any(keyword in question_text for keyword in ["试用期", "劳动合同", "工资", "解除", "竞业"]):
fallback_keywords.extend(["试用期", "劳动合同"])
if any(keyword in question_text for keyword in ["租赁", "房屋", "租金", "押金", "保证金"]):
fallback_keywords.extend(["租赁", "民法典"])
for keyword in fallback_keywords:
documents = repository.search_knowledge(
keyword,
knowledge_base_ids=request.knowledge_base_ids,
limit=3,
)
if documents:
return documents
return []
@@ -0,0 +1,74 @@
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class AiProviderConfigModel(Base):
__tablename__ = "ai_provider_configs"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
provider: Mapped[str] = mapped_column(String(64), index=True)
model: Mapped[str] = mapped_column(String(128))
display_name: Mapped[str] = mapped_column(String(128))
base_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
temperature: Mapped[float] = mapped_column(Float, default=0.2)
max_tokens: Mapped[int] = mapped_column(Integer, default=4096)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
)
class PromptTemplateModel(Base):
__tablename__ = "prompt_templates"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(128), index=True)
version: Mapped[str] = mapped_column(String(32))
content: Mapped[str] = mapped_column(Text)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
)
class KnowledgeDocumentModel(Base):
__tablename__ = "knowledge_documents"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
knowledge_base_id: Mapped[str] = mapped_column(String(64), index=True)
title: Mapped[str] = mapped_column(String(256))
source_type: Mapped[str] = mapped_column(String(64), default="knowledge_document")
reference: Mapped[str] = mapped_column(String(256))
content: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
)
class AiRunAuditModel(Base):
__tablename__ = "ai_run_audits"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
operation: Mapped[str] = mapped_column(String(64), index=True)
provider_config_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
prompt_template_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
question: Mapped[str | None] = mapped_column(Text, nullable=True)
answer: Mapped[str | None] = mapped_column(Text, nullable=True)
citations_count: Mapped[int] = mapped_column(Integer, default=0)
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(32), default="succeeded")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
index=True,
)
@@ -0,0 +1,175 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from threading import Lock
from uuid import uuid4
from pydantic import BaseModel, Field
from yuqei_ai_platform_api.legal_qa import LegalCitation
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: datetime
class KnowledgeDocumentCreate(BaseModel):
knowledge_base_id: str = Field(default="default")
title: str = Field(min_length=1)
source_type: str = "knowledge_document"
reference: str = Field(default="")
content: str = Field(min_length=1)
class KnowledgeDocument(KnowledgeDocumentCreate):
id: str
created_at: datetime
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 = "succeeded"
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@dataclass
class InMemoryAiPlatformRepository:
provider_configs: dict[str, ProviderConfig] = 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)
def upsert_provider_config(self, payload: ProviderConfigCreate) -> ProviderConfig:
with self._lock:
if payload.is_default:
self.provider_configs = {
config_id: config.model_copy(update={"is_default": False})
for config_id, config in self.provider_configs.items()
}
existing = next(
(
config
for config in self.provider_configs.values()
if config.provider == payload.provider and config.model == payload.model
),
None,
)
config_id = existing.id if existing else f"provider-{uuid4().hex[:12]}"
config = ProviderConfig(
id=config_id,
updated_at=datetime.now(UTC),
**payload.model_dump(),
)
self.provider_configs[config_id] = config
return config
def list_provider_configs(self) -> list[ProviderConfig]:
with self._lock:
return sorted(self.provider_configs.values(), key=lambda item: item.updated_at, reverse=True)
def get_default_provider_config(self) -> ProviderConfig | None:
with self._lock:
defaults = [config for config in self.provider_configs.values() if config.enabled and config.is_default]
if defaults:
return sorted(defaults, key=lambda item: item.updated_at, reverse=True)[0]
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 add_knowledge_document(self, payload: KnowledgeDocumentCreate) -> KnowledgeDocument:
with self._lock:
document = KnowledgeDocument(
id=f"doc-{uuid4().hex[:12]}",
created_at=datetime.now(UTC),
**payload.model_dump(),
)
self.knowledge_documents[document.id] = document
return document
def search_knowledge(
self,
keyword: str,
*,
knowledge_base_ids: list[str] | None = None,
limit: int = 5,
) -> list[KnowledgeDocument]:
normalized = keyword.strip().lower()
allowed_base_ids = set(knowledge_base_ids or [])
def matches(document: KnowledgeDocument) -> bool:
if allowed_base_ids and document.knowledge_base_id not in allowed_base_ids:
return False
haystack = f"{document.title}\n{document.reference}\n{document.content}".lower()
return not normalized or normalized in haystack
with self._lock:
return [document for document in self.knowledge_documents.values() if matches(document)][:limit]
def add_ai_run_audit(self, audit: AiRunAudit) -> AiRunAudit:
with self._lock:
self.ai_run_audits.insert(0, audit)
return audit
def list_ai_run_audits(self, *, limit: int = 20) -> list[AiRunAudit]:
with self._lock:
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,
)
)
self.add_knowledge_document(
KnowledgeDocumentCreate(
knowledge_base_id="laws-cn",
title="中华人民共和国劳动合同法",
source_type="law",
reference="第十九条",
content="劳动合同期限三个月以上不满一年的,试用期不得超过一个月。",
)
)
self.add_knowledge_document(
KnowledgeDocumentCreate(
knowledge_base_id="laws-cn",
title="中华人民共和国民法典",
source_type="law",
reference="第七百零三条",
content="租赁合同是出租人将租赁物交付承租人使用、收益,承租人支付租金的合同。",
)
)
def document_to_citation(document: KnowledgeDocument) -> LegalCitation:
return LegalCitation(
source_type=document.source_type,
title=document.title,
reference=document.reference,
quote=document.content,
)
@@ -0,0 +1,10 @@
from functools import lru_cache
from yuqei_ai_platform_api.repository import InMemoryAiPlatformRepository
@lru_cache
def get_repository() -> InMemoryAiPlatformRepository:
repository = InMemoryAiPlatformRepository()
repository.seed_defaults()
return repository
@@ -0,0 +1,94 @@
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
def make_client() -> TestClient:
repository = InMemoryAiPlatformRepository()
repository.seed_defaults()
app = create_app(AiPlatformSettings(environment="test"), repository=repository)
return TestClient(app)
def test_model_config_can_be_hot_updated_and_listed() -> None:
client = make_client()
response = client.post(
"/api/v1/model-configs",
json={
"provider": "openai-compatible",
"model": "legal-pro",
"display_name": "Legal Pro",
"base_url": "https://ai.example.test/v1",
"temperature": 0.1,
"max_tokens": 8192,
"enabled": True,
"is_default": True,
},
)
assert response.status_code == 200
created = response.json()
assert created["model"] == "legal-pro"
assert created["is_default"] is True
list_response = client.get("/api/v1/model-configs")
assert list_response.status_code == 200
configs = list_response.json()
assert configs[0]["model"] == "legal-pro"
assert sum(1 for config in configs if config["is_default"]) == 1
def test_knowledge_document_can_be_added_and_searched() -> None:
client = make_client()
add_response = client.post(
"/api/v1/knowledge-documents",
json={
"knowledge_base_id": "enterprise",
"title": "公司租赁审查规范",
"source_type": "internal_policy",
"reference": "租赁-001",
"content": "房屋租赁合同必须明确押金返还条件。",
},
)
assert add_response.status_code == 200
search_response = client.get("/api/v1/knowledge-documents/search", params={"keyword": "押金"})
assert search_response.status_code == 200
assert search_response.json()[0]["title"] == "公司租赁审查规范"
def test_legal_qa_uses_knowledge_and_records_audit() -> None:
client = make_client()
client.post(
"/api/v1/knowledge-documents",
json={
"knowledge_base_id": "laws-cn",
"title": "中华人民共和国劳动合同法",
"source_type": "law",
"reference": "第十九条",
"content": "试用期不得超过法定上限。",
},
)
response = client.post(
"/api/v1/legal/qa",
json={
"question": "劳动合同试用期怎么约定?",
"knowledge_base_ids": ["laws-cn"],
},
)
assert response.status_code == 200
payload = response.json()
assert payload["citations"][0]["reference"] == "第十九条"
audit_response = client.get("/api/v1/audit/ai-runs")
assert audit_response.status_code == 200
audits = audit_response.json()
assert audits[0]["operation"] == "legal_qa"
assert audits[0]["citations_count"] >= 1
assert audits[0]["status"] == "succeeded"