feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
+4
View File
@@ -0,0 +1,4 @@
.venv
__pycache__
.pytest_cache
tests
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml README.md ./
COPY app ./app
RUN pip install --no-cache-dir .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+12 -1
View File
@@ -1,3 +1,14 @@
# AI Service
Python 3.11+、FastAPI 和 LangGraph。首期仅处理自然语言到请假草稿的结构化转换,以及流程进度查询规划;不拥有业务写权限。
FastAPI 服务负责把自然语言转换为受约束的请假草稿建议。当前使用千问 OpenAI 兼容接口,但不拥有数据库、Flowable 或业务写权限。
```bash
cd ai-service
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
export QWEN_API_KEY='...'
uvicorn app.main:app --reload --port 8000
```
接口:`POST /v1/leave-drafts/suggest`。模型输出会经过 Pydantic 白名单、枚举、长度、时区和时间范围校验,结果始终要求用户确认。
+1
View File
@@ -0,0 +1 @@
"""AIOA AI service."""
+17
View File
@@ -0,0 +1,17 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
qwen_api_key: str = ""
qwen_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
qwen_model: str = "qwen-plus"
request_timeout_seconds: float = 20.0
@lru_cache
def get_settings() -> Settings:
return Settings()
+57
View File
@@ -0,0 +1,57 @@
from fastapi import Depends, FastAPI, HTTPException
from app.config import get_settings
from app.models import LeaveDraftSuggestionRequest, LeaveDraftSuggestionResponse, LeaveProgressAnswerRequest, LeaveProgressAnswerResponse
from app.qwen import (
QwenConfigurationError,
QwenSuggestionGateway,
QwenUpstreamError,
SuggestionGateway,
ProgressGateway,
)
app = FastAPI(title="AIOA AI Service", version="0.1.0")
def get_gateway() -> SuggestionGateway:
return QwenSuggestionGateway(get_settings())
def get_progress_gateway() -> ProgressGateway:
return QwenSuggestionGateway(get_settings())
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "UP"}
@app.post("/v1/leave-drafts/suggest", response_model=LeaveDraftSuggestionResponse)
async def suggest_leave_draft(
request: LeaveDraftSuggestionRequest,
gateway: SuggestionGateway = Depends(get_gateway),
) -> LeaveDraftSuggestionResponse:
try:
suggestion = await gateway.suggest(request)
except QwenConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return LeaveDraftSuggestionResponse(
suggestion=suggestion,
model=get_settings().qwen_model,
)
@app.post("/v1/leave-progress/answer", response_model=LeaveProgressAnswerResponse)
async def answer_leave_progress(
request: LeaveProgressAnswerRequest,
gateway: ProgressGateway = Depends(get_progress_gateway),
) -> LeaveProgressAnswerResponse:
try:
answer = await gateway.answer_progress(request)
except QwenConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return LeaveProgressAnswerResponse(answer=answer, model=get_settings().qwen_model)
+67
View File
@@ -0,0 +1,67 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, model_validator
class LeaveType(StrEnum):
PERSONAL = "PERSONAL"
SICK = "SICK"
ANNUAL = "ANNUAL"
class LeaveDraftSuggestionRequest(BaseModel):
text: str = Field(min_length=1, max_length=2000)
timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64)
now: datetime | None = None
class LeaveDraftSuggestion(BaseModel):
type: LeaveType | None = None
startsAt: datetime | None = None
endsAt: datetime | None = None
reason: str | None = Field(default=None, max_length=2000)
assumptions: list[str] = Field(default_factory=list, max_length=10)
needsClarification: list[str] = Field(default_factory=list, max_length=10)
@model_validator(mode="after")
def validate_time_range(self) -> "LeaveDraftSuggestion":
if self.startsAt is not None and self.endsAt is not None:
if self.startsAt.tzinfo is None or self.endsAt.tzinfo is None:
raise ValueError("startsAt and endsAt must include timezone offsets")
if self.endsAt <= self.startsAt:
raise ValueError("endsAt must be later than startsAt")
if self.reason is not None:
normalized = self.reason.strip()
self.reason = normalized or None
return self
class LeaveDraftSuggestionResponse(BaseModel):
suggestion: LeaveDraftSuggestion
model: str
requiresUserConfirmation: bool = True
class LeaveProgressContext(BaseModel):
model_config = ConfigDict(extra="forbid")
requestId: str
type: LeaveType
status: str
startsAt: datetime
endsAt: datetime
activeTaskNames: list[str] = Field(default_factory=list, max_length=20)
completedTaskNames: list[str] = Field(default_factory=list, max_length=50)
processEnded: bool
timelineEventTypes: list[str] = Field(default_factory=list, max_length=50)
class LeaveProgressAnswerRequest(BaseModel):
question: str = Field(min_length=1, max_length=2000)
timezone: str = Field(min_length=1, max_length=64)
context: LeaveProgressContext
class LeaveProgressAnswerResponse(BaseModel):
answer: str = Field(min_length=1, max_length=2000)
model: str
+106
View File
@@ -0,0 +1,106 @@
import json
from datetime import datetime, timezone
from typing import Protocol
import httpx
from app.config import Settings
from app.models import LeaveDraftSuggestion, LeaveDraftSuggestionRequest, LeaveProgressAnswerRequest
class SuggestionGateway(Protocol):
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion: ...
class ProgressGateway(Protocol):
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str: ...
class QwenSuggestionGateway:
def __init__(self, settings: Settings) -> None:
self.settings = settings
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion:
if not self.settings.qwen_api_key:
raise QwenConfigurationError("QWEN_API_KEY is not configured")
reference_time = request.now or datetime.now(timezone.utc)
payload = {
"model": self.settings.qwen_model,
"temperature": 0.1,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": json.dumps(
{
"text": request.text,
"timezone": request.timezone,
"referenceTime": reference_time.isoformat(),
},
ensure_ascii=False,
),
},
],
}
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.post(
f"{self.settings.qwen_base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"},
json=payload,
)
if response.status_code >= 400:
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
try:
content = response.json()["choices"][0]["message"]["content"]
return LeaveDraftSuggestion.model_validate_json(content)
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise QwenUpstreamError("Qwen returned an invalid structured response") from exc
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str:
if not self.settings.qwen_api_key:
raise QwenConfigurationError("QWEN_API_KEY is not configured")
payload = {
"model": self.settings.qwen_model,
"temperature": 0.1,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": PROGRESS_SYSTEM_PROMPT},
{"role": "user", "content": request.model_dump_json()},
],
}
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.post(f"{self.settings.qwen_base_url}/chat/completions", headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"}, json=payload)
if response.status_code >= 400:
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
try:
content = response.json()["choices"][0]["message"]["content"]
answer = json.loads(content)["answer"]
if not isinstance(answer, str) or not answer.strip() or len(answer) > 2000:
raise ValueError("invalid answer")
return answer.strip()
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise QwenUpstreamError("Qwen returned an invalid progress answer") from exc
SYSTEM_PROMPT = """
你是企业 OA 请假表单解析器。只把用户自然语言转换为 JSON 建议值,不执行任何业务动作。
输出必须是一个 JSON 对象,只允许字段:type、startsAt、endsAt、reason、assumptions、needsClarification。
type 只能是 PERSONAL、SICK、ANNUAL 或 null。时间必须是带时区偏移的 ISO-8601。
不能确定的值输出 null,并把需要用户补充的问题写入 needsClarification。
不得输出申请人、审批人、租户、权限、流程或隐藏字段。不得使用 Markdown。
""".strip()
PROGRESS_SYSTEM_PROMPT = """
你是企业 OA 只读流程进度助手。数据库记录已经由业务后端鉴权并选定,你只能依据输入 context 回答 question。
输出必须是 JSON 对象且只包含 answer 字段。回答必须明确当前 status;有 activeTaskNames 时说明当前节点;流程结束时说明已结束。
不得猜测审批人、原因、流程变量或预计完成时间,不得给出批准、驳回、撤回、提交等写操作指令,不得使用 Markdown。
""".strip()
class QwenConfigurationError(RuntimeError):
pass
class QwenUpstreamError(RuntimeError):
pass
+19
View File
@@ -0,0 +1,19 @@
[project]
name = "aioa-ai-service"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.116.1",
"httpx==0.28.1",
"pydantic-settings==2.10.1",
"uvicorn[standard]==0.35.0",
]
[project.optional-dependencies]
dev = [
"pytest==8.4.1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+57
View File
@@ -0,0 +1,57 @@
from fastapi.testclient import TestClient
from app.main import app, get_progress_gateway
from app.models import LeaveProgressAnswerRequest
class FakeProgressGateway:
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str:
assert request.context.requestId == "leave-1"
assert request.context.activeTaskNames == ["主管审批"]
return "当前状态为审批中,正在等待主管审批。"
def test_answers_only_from_authorized_structured_context() -> None:
app.dependency_overrides[get_progress_gateway] = lambda: FakeProgressGateway()
try:
response = TestClient(app).post(
"/v1/leave-progress/answer",
json={
"question": "我的请假到哪一步了?",
"timezone": "Asia/Shanghai",
"context": {
"requestId": "leave-1",
"type": "ANNUAL",
"status": "PENDING",
"startsAt": "2026-07-20T01:00:00Z",
"endsAt": "2026-07-20T09:00:00Z",
"activeTaskNames": ["主管审批"],
"completedTaskNames": [],
"processEnded": False,
"timelineEventTypes": ["LEAVE_REQUEST_SUBMITTED"],
},
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"answer": "当前状态为审批中,正在等待主管审批。", "model": "qwen-plus"}
assert "processVariables" not in response.text
def test_rejects_unknown_context_fields() -> None:
response = TestClient(app).post(
"/v1/leave-progress/answer",
json={
"question": "进度?",
"timezone": "Asia/Shanghai",
"context": {
"requestId": "leave-1", "type": "ANNUAL", "status": "PENDING",
"startsAt": "2026-07-20T01:00:00Z", "endsAt": "2026-07-20T09:00:00Z",
"activeTaskNames": [], "completedTaskNames": [], "processEnded": False,
"timelineEventTypes": [], "processVariables": {"approverId": "secret"},
},
},
)
assert response.status_code == 422
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from fastapi.testclient import TestClient
from app.main import app, get_gateway
from app.models import LeaveDraftSuggestion, LeaveDraftSuggestionRequest, LeaveType
class FakeGateway:
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion:
assert request.text == "明天下午请事假四小时"
return LeaveDraftSuggestion(
type=LeaveType.PERSONAL,
startsAt=datetime.fromisoformat("2026-07-19T13:30:00+08:00"),
endsAt=datetime.fromisoformat("2026-07-19T17:30:00+08:00"),
reason="办理个人事务",
assumptions=["下午按 13:30 开始计算"],
)
def test_returns_structured_suggestion_without_executing_business_action() -> None:
app.dependency_overrides[get_gateway] = lambda: FakeGateway()
try:
response = TestClient(app).post(
"/v1/leave-drafts/suggest",
json={"text": "明天下午请事假四小时", "timezone": "Asia/Shanghai"},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["suggestion"]["type"] == "PERSONAL"
assert body["requiresUserConfirmation"] is True
assert "applicantId" not in body["suggestion"]
def test_rejects_invalid_time_range_from_model() -> None:
try:
LeaveDraftSuggestion(
startsAt=datetime.fromisoformat("2026-07-19T17:30:00+08:00"),
endsAt=datetime.fromisoformat("2026-07-19T13:30:00+08:00"),
)
assert False, "validation should fail"
except ValueError:
pass