diff --git a/infra/docker/contract-api.Dockerfile b/infra/docker/contract-api.Dockerfile index fdfd150f..879ee936 100644 --- a/infra/docker/contract-api.Dockerfile +++ b/infra/docker/contract-api.Dockerfile @@ -7,9 +7,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app COPY services/contract-api/pyproject.toml /tmp/contract-api-pyproject.toml -RUN pip install --no-cache-dir fastapi pydantic pydantic-settings sqlalchemy uvicorn +COPY sdks/python/pyproject.toml /tmp/yuqei-python-sdk-pyproject.toml +RUN pip install --no-cache-dir fastapi httpx pydantic pydantic-settings sqlalchemy uvicorn COPY services/contract-api services/contract-api +COPY sdks/python sdks/python EXPOSE 8100 CMD ["python", "-m", "uvicorn", "yuqei_contract_api.main:app", "--host", "0.0.0.0", "--port", "8100"] diff --git a/packages/contract-openapi/openapi.yaml b/packages/contract-openapi/openapi.yaml index b2c26c46..604e9426 100644 --- a/packages/contract-openapi/openapi.yaml +++ b/packages/contract-openapi/openapi.yaml @@ -145,6 +145,25 @@ paths: application/json: schema: $ref: "#/components/schemas/ApprovalTimeline" + /api/v1/legal/qa: + post: + operationId: proxyLegalQa + summary: Answer a legal question through the shared AI platform + parameters: + - $ref: "#/components/parameters/TraceIdHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LegalQaRequest" + responses: + "200": + description: Legal QA answer + content: + application/json: + schema: + $ref: "#/components/schemas/LegalQaResponse" /api/v1/search: get: operationId: search @@ -420,6 +439,56 @@ components: created_at: type: string format: date-time + LegalCitation: + type: object + required: + - source_type + - title + - reference + - quote + properties: + source_type: + type: string + enum: [law, regulation, judicial_interpretation, internal_policy, contract_clause, knowledge_document] + title: + type: string + reference: + type: string + quote: + type: string + url: + type: string + nullable: true + LegalQaRequest: + type: object + required: + - question + properties: + question: + type: string + matter_context: + type: string + nullable: true + knowledge_base_ids: + type: array + items: + type: string + LegalQaResponse: + type: object + required: + - answer + - citations + properties: + answer: + type: string + citations: + type: array + items: + $ref: "#/components/schemas/LegalCitation" + confidence: + type: number + format: float + nullable: true SearchResult: type: object required: diff --git a/project-docs/V2法律问答AI平台与SDK最小闭环记录-2026-06-22.md b/project-docs/V2法律问答AI平台与SDK最小闭环记录-2026-06-22.md new file mode 100644 index 00000000..9b5d5732 --- /dev/null +++ b/project-docs/V2法律问答AI平台与SDK最小闭环记录-2026-06-22.md @@ -0,0 +1,108 @@ +# V2 法律问答 AI 平台与 SDK 最小闭环记录 + +日期:2026-06-22 + +## 1. 本轮目标 + +把法律问答能力放到正确的 V2 架构边界里: + +- AI 能力归属 `services/ai-platform-api`。 +- 合同系统不直接写模型 provider 或 AI 规则。 +- 合同系统通过 `sdks/python` 调用 AI Platform。 +- OpenAPI 契约同步记录这个边界。 + +## 2. 已完成内容 + +### AI Platform + +目录:`services/ai-platform-api` + +新增: + +- `POST /api/v1/legal/qa` +- `LegalQaRequest` +- `LegalQaResponse` +- `LegalCitation` +- 规则型 MVP 法律问答实现。 +- 法律问答测试。 + +当前 MVP 会根据问题关键词返回: + +- 劳动合同相关依据。 +- 房屋租赁相关依据。 +- 默认民法典履行义务依据。 + +### Python SDK + +目录:`sdks/python` + +新增: + +- `AiPlatformClient` +- `RequestContext` +- `LegalQaRequest` +- `LegalQaResponse` +- `LegalCitation` +- SDK 单元测试。 + +SDK 负责: + +- 拼接 `/api/v1/legal/qa`。 +- 传递 `X-Trace-Id`、`X-Tenant-Id`、`X-User-Id`。 +- 解析 AI Platform 响应。 + +### Contract API + +目录:`services/contract-api` + +新增: + +- `POST /api/v1/legal/qa` + +该接口通过 Python SDK 调用 AI Platform,不直接访问 AI Platform 内部实现。 + +### OpenAPI + +已更新: + +- `packages/contract-openapi/openapi.yaml` + +新增合同系统法律问答代理契约: + +```text +POST /api/v1/legal/qa +``` + +## 3. 验证结果 + +已通过: + +```powershell +python -m pytest +npm audit --audit-level=moderate +``` + +结果: + +- Python 测试:10 passed。 +- npm audit:0 vulnerabilities。 + +## 4. 当前边界 + +当前仍是 MVP,不包含: + +- 法规库数据库检索。 +- RAG 相似度检索。 +- 模型真实调用。 +- AI 调用审计入库。 +- Prompt 模板版本管理。 +- 多 provider 路由。 + +## 5. 下一步建议 + +继续执行 S2: + +1. 建立 AI Platform 数据模型:provider config、prompt template、knowledge document、ai run audit。 +2. 实现数据库驱动的模型参数热更新。 +3. 实现法律知识库检索接口。 +4. 把当前规则型回答替换为“检索依据 + 模型生成 + 引用审计”的流程。 diff --git a/pyproject.toml b/pyproject.toml index 397e0ebe..84359975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,12 @@ testpaths = [ "services/contract-api/tests", "services/ai-platform-api/tests", "services/worker/tests", + "sdks/python/tests", ] pythonpath = [ "services/contract-api/src", "services/ai-platform-api/src", "services/worker/src", + "sdks/python/src", ] addopts = "-q --import-mode=importlib" diff --git a/sdks/python/.gitkeep b/sdks/python/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/sdks/python/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sdks/python/README.md b/sdks/python/README.md new file mode 100644 index 00000000..d32a7493 --- /dev/null +++ b/sdks/python/README.md @@ -0,0 +1,9 @@ +# Yuqei Python SDK + +Python client for V2 service-to-service calls. + +Initial scope: + +- AI Platform legal QA client. +- Request context headers. +- Typed request and response models. diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml new file mode 100644 index 00000000..a668fa11 --- /dev/null +++ b/sdks/python/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "yuqei-python-sdk" +version = "0.1.0" +description = "Python SDK for Yuqei V2 services" +requires-python = ">=3.12" +dependencies = [ + "httpx>=0.28", + "pydantic>=2.10", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/sdks/python/src/yuqei_sdk/__init__.py b/sdks/python/src/yuqei_sdk/__init__.py new file mode 100644 index 00000000..6ce95795 --- /dev/null +++ b/sdks/python/src/yuqei_sdk/__init__.py @@ -0,0 +1,10 @@ +from yuqei_sdk.ai_platform import AiPlatformClient, LegalCitation, LegalQaRequest, LegalQaResponse +from yuqei_sdk.context import RequestContext + +__all__ = [ + "AiPlatformClient", + "LegalCitation", + "LegalQaRequest", + "LegalQaResponse", + "RequestContext", +] diff --git a/sdks/python/src/yuqei_sdk/ai_platform.py b/sdks/python/src/yuqei_sdk/ai_platform.py new file mode 100644 index 00000000..38c8fb67 --- /dev/null +++ b/sdks/python/src/yuqei_sdk/ai_platform.py @@ -0,0 +1,67 @@ +from typing import Any + +import httpx +from pydantic import BaseModel, Field + +from yuqei_sdk.context import RequestContext + + +class LegalQaRequest(BaseModel): + question: str = Field(min_length=1) + matter_context: str | None = None + knowledge_base_ids: list[str] = Field(default_factory=list) + + +class LegalCitation(BaseModel): + source_type: str + title: str + reference: str + quote: str + url: str | None = None + + +class LegalQaResponse(BaseModel): + answer: str + citations: list[LegalCitation] + confidence: float | None = None + + +class AiPlatformClient: + def __init__( + self, + base_url: str, + *, + api_prefix: str = "/api/v1", + timeout: float = 30.0, + transport: httpx.BaseTransport | None = None, + ) -> None: + self._api_prefix = api_prefix.rstrip("/") + self._client = httpx.Client( + base_url=base_url.rstrip("/"), + timeout=timeout, + transport=transport, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> "AiPlatformClient": + return self + + def __exit__(self, *_exc_info: object) -> None: + self.close() + + def run_legal_qa( + self, + request: LegalQaRequest, + *, + context: RequestContext | None = None, + ) -> LegalQaResponse: + response = self._client.post( + f"{self._api_prefix}/legal/qa", + json=request.model_dump(exclude_none=True), + headers=(context or RequestContext()).to_headers(), + ) + response.raise_for_status() + payload: dict[str, Any] = response.json() + return LegalQaResponse.model_validate(payload) diff --git a/sdks/python/src/yuqei_sdk/context.py b/sdks/python/src/yuqei_sdk/context.py new file mode 100644 index 00000000..c055d75e --- /dev/null +++ b/sdks/python/src/yuqei_sdk/context.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +class RequestContext(BaseModel): + trace_id: str | None = None + tenant_id: str | None = None + user_id: str | None = None + + def to_headers(self) -> dict[str, str]: + headers: dict[str, str] = {} + if self.trace_id: + headers["X-Trace-Id"] = self.trace_id + if self.tenant_id: + headers["X-Tenant-Id"] = self.tenant_id + if self.user_id: + headers["X-User-Id"] = self.user_id + return headers diff --git a/sdks/python/tests/__init__.py b/sdks/python/tests/__init__.py new file mode 100644 index 00000000..7420ecb4 --- /dev/null +++ b/sdks/python/tests/__init__.py @@ -0,0 +1 @@ +"""Yuqei SDK test package.""" diff --git a/sdks/python/tests/test_ai_platform.py b/sdks/python/tests/test_ai_platform.py new file mode 100644 index 00000000..4aa4250a --- /dev/null +++ b/sdks/python/tests/test_ai_platform.py @@ -0,0 +1,43 @@ +import httpx + +from yuqei_sdk import AiPlatformClient, LegalQaRequest, RequestContext + + +def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None: + captured_request: httpx.Request | None = None + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal captured_request + captured_request = request + return httpx.Response( + 200, + json={ + "answer": "依据劳动合同法第十九条,需要核对合同期限。", + "citations": [ + { + "source_type": "law", + "title": "中华人民共和国劳动合同法", + "reference": "第十九条", + "quote": "劳动合同期限三个月以上不满一年的,试用期不得超过一个月。", + } + ], + "confidence": 0.72, + }, + ) + + client = AiPlatformClient( + "http://ai-platform.test", + transport=httpx.MockTransport(handler), + ) + + response = client.run_legal_qa( + LegalQaRequest(question="试用期如何约定?"), + context=RequestContext(trace_id="trace-1", tenant_id="tenant-1", user_id="user-1"), + ) + + assert response.citations[0].reference == "第十九条" + assert captured_request is not None + assert captured_request.url.path == "/api/v1/legal/qa" + assert captured_request.headers["X-Trace-Id"] == "trace-1" + assert captured_request.headers["X-Tenant-Id"] == "tenant-1" + assert captured_request.headers["X-User-Id"] == "user-1" 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 new file mode 100644 index 00000000..f9e00eb1 --- /dev/null +++ b/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py @@ -0,0 +1,103 @@ +from pydantic import BaseModel, Field + + +class LegalQaRequest(BaseModel): + question: str = Field(min_length=1) + matter_context: str | None = None + knowledge_base_ids: list[str] = Field(default_factory=list) + + +class LegalCitation(BaseModel): + source_type: str + title: str + reference: str + quote: str + url: str | None = None + + +class LegalQaResponse(BaseModel): + answer: str + citations: list[LegalCitation] + confidence: float | None = None + + +def answer_legal_question(request: LegalQaRequest) -> LegalQaResponse: + question = request.question.strip() + context = request.matter_context.strip() if request.matter_context else "" + question_text = question + "\n" + context + + if _contains_any(question_text, ["试用期", "劳动合同", "工资", "解除", "竞业"]): + return _labor_answer(question) + if _contains_any(question_text, ["租赁", "房屋", "租金", "押金", "保证金"]): + return _lease_answer(question) + + return LegalQaResponse( + answer=( + "当前问题需要结合合同类型、交易背景和适用地区进一步判断。" + "V2 MVP 会先返回可审计的基础意见;正式版本会先检索企业知识库和法规库," + "再给出更精确的法律依据、条文引用和处理建议。" + ), + citations=[ + LegalCitation( + source_type="law", + title="中华人民共和国民法典", + reference="第五百零九条", + quote="当事人应当按照约定全面履行自己的义务。", + ) + ], + confidence=0.45, + ) + + +def _labor_answer(question: str) -> LegalQaResponse: + return LegalQaResponse( + answer=( + f"针对“{question}”,应优先核对劳动合同期限、试用期约定、工资支付记录和解除理由。" + "如果试用期超过法定上限,或者解除缺少事实和制度依据,企业侧会面临补足工资、" + "违法解除赔偿或继续履行等风险。建议在处理前形成证据清单,并把结论写入审批意见。" + ), + citations=[ + LegalCitation( + source_type="law", + title="中华人民共和国劳动合同法", + reference="第十九条", + quote="劳动合同期限三个月以上不满一年的,试用期不得超过一个月。", + ), + LegalCitation( + source_type="law", + title="中华人民共和国劳动合同法", + reference="第三十九条", + quote="劳动者在试用期间被证明不符合录用条件的,用人单位可以解除劳动合同。", + ), + ], + confidence=0.72, + ) + + +def _lease_answer(question: str) -> LegalQaResponse: + return LegalQaResponse( + answer=( + f"针对“{question}”,应先确认租赁标的、租赁期限、租金支付、押金返还、维修义务和解除条件。" + "如果合同没有明确交付状态、押金扣除条件或提前解除责任,后续很容易产生举证争议。" + "建议在起草或审查时把交付清单、付款节点和违约责任写成可执行条款。" + ), + citations=[ + LegalCitation( + source_type="law", + title="中华人民共和国民法典", + reference="第七百零三条", + quote="租赁合同是出租人将租赁物交付承租人使用、收益,承租人支付租金的合同。", + ), + LegalCitation( + source_type="law", + title="中华人民共和国民法典", + reference="第七百零七条", + quote="租赁期限六个月以上的,应当采用书面形式。", + ), + ], + confidence=0.7, + ) + + +def _contains_any(text: str, keywords: list[str]) -> bool: + return any(keyword in text for keyword in keywords) 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 da02b548..61b4304a 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 @@ -5,6 +5,7 @@ from pydantic import BaseModel 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 @@ -41,6 +42,14 @@ def create_app(settings: AiPlatformSettings | None = None) -> FastAPI: def api_health() -> HealthResponse: return health() + @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) + return app diff --git a/services/ai-platform-api/tests/test_legal_qa.py b/services/ai-platform-api/tests/test_legal_qa.py new file mode 100644 index 00000000..799caa31 --- /dev/null +++ b/services/ai-platform-api/tests/test_legal_qa.py @@ -0,0 +1,33 @@ +from fastapi.testclient import TestClient + +from yuqei_ai_platform_api.config import AiPlatformSettings +from yuqei_ai_platform_api.main import create_app + + +def test_legal_qa_returns_answer_with_citations() -> None: + app = create_app(AiPlatformSettings(environment="test")) + client = TestClient(app) + + response = client.post( + "/api/v1/legal/qa", + json={"question": "劳动合同试用期最长可以约定多久?"}, + headers={"X-Tenant-Id": "tenant-001", "X-User-Id": "user-001"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert "试用期" in payload["answer"] + assert payload["citations"] + assert payload["citations"][0]["title"] == "中华人民共和国劳动合同法" + assert payload["confidence"] > 0.5 + + +def test_legal_qa_uses_default_citation_for_unknown_question() -> None: + app = create_app(AiPlatformSettings(environment="test")) + client = TestClient(app) + + response = client.post("/api/v1/legal/qa", json={"question": "这个合同需要注意什么?"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["citations"][0]["title"] == "中华人民共和国民法典" diff --git a/services/contract-api/pyproject.toml b/services/contract-api/pyproject.toml index 01f1d78f..391d0eff 100644 --- a/services/contract-api/pyproject.toml +++ b/services/contract-api/pyproject.toml @@ -5,6 +5,7 @@ description = "Yuqei AI Contract Platform V2 contract API" requires-python = ">=3.12" dependencies = [ "fastapi>=0.115", + "httpx>=0.28", "pydantic>=2.10", "pydantic-settings>=2.7", "sqlalchemy>=2.0", diff --git a/services/contract-api/src/yuqei_contract_api/main.py b/services/contract-api/src/yuqei_contract_api/main.py index 4735bcce..98c11587 100644 --- a/services/contract-api/src/yuqei_contract_api/main.py +++ b/services/contract-api/src/yuqei_contract_api/main.py @@ -2,6 +2,7 @@ from typing import Literal from fastapi import FastAPI from pydantic import BaseModel +from yuqei_sdk import AiPlatformClient, LegalQaRequest, LegalQaResponse, RequestContext from yuqei_contract_api import __version__ from yuqei_contract_api.config import ContractApiSettings, get_settings @@ -39,6 +40,18 @@ def create_app(settings: ContractApiSettings | None = None) -> FastAPI: def api_health() -> HealthResponse: return health() + @app.post( + f"{resolved_settings.api_prefix}/legal/qa", + response_model=LegalQaResponse, + tags=["legal"], + ) + def proxy_legal_qa(request: LegalQaRequest) -> LegalQaResponse: + with AiPlatformClient(resolved_settings.ai_platform_base_url) as client: + return client.run_legal_qa( + request, + context=RequestContext(tenant_id="system", user_id="contract-api"), + ) + return app diff --git a/services/contract-api/tests/test_legal_qa_proxy.py b/services/contract-api/tests/test_legal_qa_proxy.py new file mode 100644 index 00000000..77f9b659 --- /dev/null +++ b/services/contract-api/tests/test_legal_qa_proxy.py @@ -0,0 +1,47 @@ +import httpx +from fastapi.testclient import TestClient + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.main import create_app + + +def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None: + from yuqei_sdk import ai_platform + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/v1/legal/qa" + return httpx.Response( + 200, + json={ + "answer": "依据民法典第五百零九条,应当按约履行。", + "citations": [ + { + "source_type": "law", + "title": "中华人民共和国民法典", + "reference": "第五百零九条", + "quote": "当事人应当按照约定全面履行自己的义务。", + } + ], + "confidence": 0.6, + }, + ) + + original_init = ai_platform.AiPlatformClient.__init__ + + def init_with_mock_transport(self, base_url: str, **kwargs) -> None: + original_init( + self, + base_url, + transport=httpx.MockTransport(handler), + **kwargs, + ) + + monkeypatch.setattr(ai_platform.AiPlatformClient, "__init__", init_with_mock_transport) + + app = create_app(ContractApiSettings(environment="test")) + client = TestClient(app) + response = client.post("/api/v1/legal/qa", json={"question": "合同履行要注意什么?"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["citations"][0]["reference"] == "第五百零九条"