feat: add AI platform config knowledge audit MVP
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user