feat: add legal QA platform SDK loop
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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. 把当前规则型回答替换为“检索依据 + 模型生成 + 引用审计”的流程。
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Yuqei SDK test package."""
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"] == "中华人民共和国民法典"
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"] == "第五百零九条"
|
||||
Reference in New Issue
Block a user