From 65b4b45b5b1511f52153a26ba0cfc924f2de7053 Mon Sep 17 00:00:00 2001 From: yuqei Date: Mon, 22 Jun 2026 19:08:03 +0800 Subject: [PATCH] feat: add AI platform config knowledge audit MVP --- packages/ai-openapi/openapi.yaml | 181 +++++++++++++++++- ...-AI平台配置知识库审计MVP记录-2026-06-22.md | 132 +++++++++++++ .../src/yuqei_ai_platform_api/legal_qa.py | 36 +++- .../src/yuqei_ai_platform_api/main.py | 109 ++++++++++- .../src/yuqei_ai_platform_api/models.py | 74 +++++++ .../src/yuqei_ai_platform_api/repository.py | 175 +++++++++++++++++ .../src/yuqei_ai_platform_api/state.py | 10 + .../tests/test_platform_data.py | 94 +++++++++ 8 files changed, 796 insertions(+), 15 deletions(-) create mode 100644 project-docs/V2-AI平台配置知识库审计MVP记录-2026-06-22.md create mode 100644 services/ai-platform-api/src/yuqei_ai_platform_api/models.py create mode 100644 services/ai-platform-api/src/yuqei_ai_platform_api/repository.py create mode 100644 services/ai-platform-api/src/yuqei_ai_platform_api/state.py create mode 100644 services/ai-platform-api/tests/test_platform_data.py diff --git a/packages/ai-openapi/openapi.yaml b/packages/ai-openapi/openapi.yaml index 0ebad364..26c662fe 100644 --- a/packages/ai-openapi/openapi.yaml +++ b/packages/ai-openapi/openapi.yaml @@ -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] diff --git a/project-docs/V2-AI平台配置知识库审计MVP记录-2026-06-22.md b/project-docs/V2-AI平台配置知识库审计MVP记录-2026-06-22.md new file mode 100644 index 00000000..a9921187 --- /dev/null +++ b/project-docs/V2-AI平台配置知识库审计MVP记录-2026-06-22.md @@ -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 调用 + 审计落库”。 diff --git a/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py b/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py index f9e00eb1..249acd57 100644 --- a/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py +++ b/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py @@ -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}) diff --git a/services/ai-platform-api/src/yuqei_ai_platform_api/main.py b/services/ai-platform-api/src/yuqei_ai_platform_api/main.py index 61b4304a..77d8b13e 100644 --- a/services/ai-platform-api/src/yuqei_ai_platform_api/main.py +++ b/services/ai-platform-api/src/yuqei_ai_platform_api/main.py @@ -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 [] diff --git a/services/ai-platform-api/src/yuqei_ai_platform_api/models.py b/services/ai-platform-api/src/yuqei_ai_platform_api/models.py new file mode 100644 index 00000000..027db01f --- /dev/null +++ b/services/ai-platform-api/src/yuqei_ai_platform_api/models.py @@ -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, + ) diff --git a/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py b/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py new file mode 100644 index 00000000..43ce36e7 --- /dev/null +++ b/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py @@ -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, + ) diff --git a/services/ai-platform-api/src/yuqei_ai_platform_api/state.py b/services/ai-platform-api/src/yuqei_ai_platform_api/state.py new file mode 100644 index 00000000..7654f879 --- /dev/null +++ b/services/ai-platform-api/src/yuqei_ai_platform_api/state.py @@ -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 diff --git a/services/ai-platform-api/tests/test_platform_data.py b/services/ai-platform-api/tests/test_platform_data.py new file mode 100644 index 00000000..e8869070 --- /dev/null +++ b/services/ai-platform-api/tests/test_platform_data.py @@ -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"