feat: add AI platform config knowledge audit MVP
This commit is contained in:
@@ -81,6 +81,35 @@ paths:
|
|||||||
text/event-stream:
|
text/event-stream:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
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:
|
/api/v1/knowledge-bases:
|
||||||
get:
|
get:
|
||||||
operationId: listKnowledgeBases
|
operationId: listKnowledgeBases
|
||||||
@@ -146,6 +175,51 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/KnowledgeDocument"
|
$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:
|
/api/v1/audit/ai-runs:
|
||||||
get:
|
get:
|
||||||
operationId: listAiRuns
|
operationId: listAiRuns
|
||||||
@@ -156,11 +230,13 @@ paths:
|
|||||||
- $ref: "#/components/parameters/PageSize"
|
- $ref: "#/components/parameters/PageSize"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: AI run audit page
|
description: AI run audit list
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/AiRunPage"
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AiRun"
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
TraceIdHeader:
|
TraceIdHeader:
|
||||||
@@ -351,6 +427,48 @@ components:
|
|||||||
context:
|
context:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
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:
|
KnowledgeBase:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -400,19 +518,48 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- file_name
|
- knowledge_base_id
|
||||||
- status
|
- title
|
||||||
|
- source_type
|
||||||
|
- reference
|
||||||
|
- content
|
||||||
|
- created_at
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
file_name:
|
knowledge_base_id:
|
||||||
type: string
|
type: string
|
||||||
status:
|
title:
|
||||||
type: string
|
type: string
|
||||||
enum: [uploaded, extracting, indexed, failed]
|
source_type:
|
||||||
failure_message:
|
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
|
type: string
|
||||||
nullable: true
|
|
||||||
AiRun:
|
AiRun:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -425,6 +572,22 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
operation:
|
operation:
|
||||||
type: string
|
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:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, running, succeeded, failed, cancelled]
|
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
|
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()
|
question = request.question.strip()
|
||||||
context = request.matter_context.strip() if request.matter_context else ""
|
context = request.matter_context.strip() if request.matter_context else ""
|
||||||
question_text = question + "\n" + context
|
question_text = question + "\n" + context
|
||||||
|
citations_from_search = retrieved_citations or []
|
||||||
|
|
||||||
if _contains_any(question_text, ["试用期", "劳动合同", "工资", "解除", "竞业"]):
|
if _contains_any(question_text, ["试用期", "劳动合同", "工资", "解除", "竞业"]):
|
||||||
return _labor_answer(question)
|
return _with_retrieved_citations(_labor_answer(question), citations_from_search)
|
||||||
if _contains_any(question_text, ["租赁", "房屋", "租金", "押金", "保证金"]):
|
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=(
|
answer=(
|
||||||
"当前问题需要结合合同类型、交易背景和适用地区进一步判断。"
|
"当前问题需要结合合同类型、交易背景和适用地区进一步判断。"
|
||||||
"V2 MVP 会先返回可审计的基础意见;正式版本会先检索企业知识库和法规库,"
|
"V2 MVP 会先返回可审计的基础意见;正式版本会先检索企业知识库和法规库,"
|
||||||
@@ -46,6 +52,8 @@ def answer_legal_question(request: LegalQaRequest) -> LegalQaResponse:
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
confidence=0.45,
|
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:
|
def _contains_any(text: str, keywords: list[str]) -> bool:
|
||||||
return any(keyword in text for keyword in keywords)
|
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 typing import Literal
|
||||||
|
from time import perf_counter
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from pydantic import BaseModel
|
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.config import AiPlatformSettings, get_settings
|
||||||
from yuqei_ai_platform_api.legal_qa import LegalQaRequest, LegalQaResponse, answer_legal_question
|
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.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):
|
class HealthResponse(BaseModel):
|
||||||
@@ -17,8 +29,12 @@ class HealthResponse(BaseModel):
|
|||||||
provider_config_source: str
|
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()
|
resolved_settings = settings or get_settings()
|
||||||
|
store = repository or get_repository()
|
||||||
configure_logging(resolved_settings.log_level)
|
configure_logging(resolved_settings.log_level)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -42,15 +58,104 @@ def create_app(settings: AiPlatformSettings | None = None) -> FastAPI:
|
|||||||
def api_health() -> HealthResponse:
|
def api_health() -> HealthResponse:
|
||||||
return health()
|
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(
|
@app.post(
|
||||||
f"{resolved_settings.api_prefix}/legal/qa",
|
f"{resolved_settings.api_prefix}/legal/qa",
|
||||||
response_model=LegalQaResponse,
|
response_model=LegalQaResponse,
|
||||||
tags=["legal"],
|
tags=["legal"],
|
||||||
)
|
)
|
||||||
def run_legal_qa(request: LegalQaRequest) -> LegalQaResponse:
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
app = create_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