feat: auth

This commit is contained in:
2026-04-15 14:05:33 +08:00
parent 321c550a66
commit 6f263e365f
15 changed files with 833 additions and 17 deletions

0
.codex Normal file
View File

View File

@@ -16,6 +16,20 @@ AMAP_MCP_AUTH_HEADER_VALUE=
# Route planning guardrails # Route planning guardrails
ROUTE_MAX_PERMUTATIONS=20 ROUTE_MAX_PERMUTATIONS=20
# Incoming HTTP API authorization
AGENT_HTTP_AUTH_KEY=replace-with-a-strong-random-key
# Load planning business API
LOAD_PLAN_API_HOST=http://8.148.215.233:9494/
LOAD_PLAN_AGENT_ACCESS_KEY=hophopkk
LOAD_PLAN_API_TIMEOUT_SECONDS=20
# Load planning LLM
LOAD_PLAN_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
LOAD_PLAN_API_KEY=your-load-plan-api-key-here
LOAD_PLAN_MODEL=doubao-seed-2-0-pro-260215
LOAD_PLAN_REQUEST_TIMEOUT_SECONDS=120
# CORS # CORS
CORS_ALLOW_ORIGINS=http://localhost,http://127.0.0.1 CORS_ALLOW_ORIGINS=http://localhost,http://127.0.0.1
CORS_ALLOW_ORIGIN_REGEX=https?://(localhost|127\.0\.0\.1)(:\d+)?$ CORS_ALLOW_ORIGIN_REGEX=https?://(localhost|127\.0\.0\.1)(:\d+)?$

28
agent/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
from .load_plan import (
LoadPlanConfigurationError,
LoadPlanGuardrailError,
create_load_plan_agent,
run_load_plan,
)
from .load_plan_tools import (
LoadPlanToolConfigurationError,
LoadPlanToolRequestError,
get_transport_vehicle_by_license_plate,
list_unshipped_shipments,
)
from .route_plan import ConfigurationError, GuardrailError, create_geo_agent, run_route_plan
__all__ = [
"ConfigurationError",
"GuardrailError",
"LoadPlanConfigurationError",
"LoadPlanGuardrailError",
"LoadPlanToolConfigurationError",
"LoadPlanToolRequestError",
"create_load_plan_agent",
"create_geo_agent",
"get_transport_vehicle_by_license_plate",
"list_unshipped_shipments",
"run_load_plan",
"run_route_plan",
]

107
agent/load_plan.py Normal file
View File

@@ -0,0 +1,107 @@
import json
from pydantic_ai import Agent
from pydantic_ai_skills import SkillsToolset
from openai import AsyncOpenAI
from pydantic_ai.models.openai import OpenAIModel
from pydantic_ai.providers.openai import OpenAIProvider
from schemas import LoadPlanRequest, LoadPlanResult
from .load_plan_tools import (
get_transport_vehicle_by_license_plate,
list_unshipped_shipments,
)
class LoadPlanConfigurationError(RuntimeError):
pass
class LoadPlanGuardrailError(RuntimeError):
pass
def _required_env(name: str) -> str:
import os
value = os.getenv(name, "").strip()
if not value:
raise LoadPlanConfigurationError(f"Missing required environment variable: {name}")
return value
def _env_positive_float(name: str, default: float) -> float:
import os
raw_value = os.getenv(name, str(default)).strip()
try:
parsed = float(raw_value)
except ValueError as exc:
raise LoadPlanConfigurationError(f"Environment variable {name} must be a number") from exc
if parsed <= 0:
raise LoadPlanConfigurationError(f"Environment variable {name} must be greater than 0")
return parsed
def _build_model() -> OpenAIModel:
openai_client = AsyncOpenAI(
base_url=_required_env("LOAD_PLAN_BASE_URL"),
api_key=_required_env("LOAD_PLAN_API_KEY"),
timeout=_env_positive_float("LOAD_PLAN_REQUEST_TIMEOUT_SECONDS", 120.0),
)
provider = OpenAIProvider(
openai_client=openai_client,
)
return OpenAIModel(
_required_env("LOAD_PLAN_MODEL"),
provider=provider,
)
INSTRUCTIONS = (
"你是一个装载规划 Agent。"
"你的任务是根据请求中的 merchant_id、area 和 license_plate"
"调用工具获取待出货出货单与车辆容量,并严格遵守已加载的 skill 来选择出货单。"
"你必须先获取出货单,再获取车辆容量。"
"车辆容量按销售品条数计算,不按 quantity 米数计算。"
"目标是在不超过车辆容量的前提下尽量多装;若无法满载,允许欠载,但必须给出 warning。"
"不要臆造 sales_items、printing_job_width、面料类型、数量或容量。"
"如果无法依据真实返回判断装载结果,就返回空的 selected_shipment_ids并在 summary/warnings 中说明。"
)
def _build_user_prompt(request: LoadPlanRequest) -> str:
request_json = json.dumps(request.model_dump(mode="json"), ensure_ascii=False, indent=2)
return (
"请根据下面的 LoadPlanRequest JSON 执行装载规划。\n"
"你必须调用工具获取真实数据,并根据 skill 中的规则完成判断。\n\n"
"LoadPlanRequest JSON:\n"
f"{request_json}"
)
def create_load_plan_agent() -> Agent[None, LoadPlanResult]:
return Agent(
model=_build_model(),
instructions=INSTRUCTIONS,
output_type=LoadPlanResult,
tools=[
get_transport_vehicle_by_license_plate,
list_unshipped_shipments,
],
toolsets=[
SkillsToolset(
directories=["./skills"],
auto_reload=True,
exclude_tools={"run_skill_script"},
)
],
)
async def run_load_plan(request: LoadPlanRequest) -> LoadPlanResult:
agent = create_load_plan_agent()
async with agent:
result = await agent.run(_build_user_prompt(request))
return result.output

112
agent/load_plan_tools.py Normal file
View File

@@ -0,0 +1,112 @@
import os
from urllib.parse import quote
import httpx
from dotenv import load_dotenv
from schemas import ShipmentPage, TransportVehicle
load_dotenv()
class LoadPlanToolConfigurationError(RuntimeError):
pass
class LoadPlanToolRequestError(RuntimeError):
pass
def _required_env(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise LoadPlanToolConfigurationError(f"Missing required environment variable: {name}")
return value
def _env_positive_float(name: str, default: float) -> float:
raw_value = os.getenv(name, str(default)).strip()
try:
parsed = float(raw_value)
except ValueError as exc:
raise LoadPlanToolConfigurationError(f"Environment variable {name} must be a number") from exc
if parsed <= 0:
raise LoadPlanToolConfigurationError(f"Environment variable {name} must be greater than 0")
return parsed
def _api_base_url() -> str:
return _required_env("LOAD_PLAN_API_HOST").rstrip("/")
def _api_headers() -> dict[str, str]:
return {
"Authorization": _required_env("LOAD_PLAN_AGENT_ACCESS_KEY"),
"Accept": "application/json",
}
def _build_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=_api_base_url(),
headers=_api_headers(),
timeout=_env_positive_float("LOAD_PLAN_API_TIMEOUT_SECONDS", 20.0),
)
def _raise_for_response(response: httpx.Response) -> None:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
detail = body or f"status={exc.response.status_code}"
raise LoadPlanToolRequestError(
f"Load-plan API request failed for {exc.request.method} {exc.request.url}: {detail}"
) from exc
async def get_transport_vehicle_by_license_plate(
*,
merchant_id: int,
license_plate: str,
) -> TransportVehicle:
"""Fetch a specific transport vehicle for one merchant by license plate."""
encoded_plate = quote(license_plate, safe="")
path = f"/api/v2/ai/transport-vehicles/{encoded_plate}/"
async with _build_client() as client:
response = await client.get(
path,
params={"merchant_id": merchant_id},
)
_raise_for_response(response)
return TransportVehicle.model_validate(response.json())
async def list_unshipped_shipments(
*,
merchant_id: int,
area: str,
limit: int | None = None,
offset: int | None = None,
) -> ShipmentPage:
"""List unshipped shipments for one merchant and one exact area."""
params: dict[str, int | str] = {
"merchant_id": merchant_id,
"area": area,
}
if limit is not None:
params["limit"] = limit
if offset is not None:
params["offset"] = offset
async with _build_client() as client:
response = await client.get(
"/api/v2/ai/shipments/unshipped/",
params=params,
)
_raise_for_response(response)
return ShipmentPage.model_validate(response.json())

View File

@@ -0,0 +1,53 @@
import argparse
import asyncio
import json
from .load_plan_tools import (
get_transport_vehicle_by_license_plate,
list_unshipped_shipments,
)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Standalone CLI for load-plan API tools")
subparsers = parser.add_subparsers(dest="command", required=True)
vehicle_parser = subparsers.add_parser("vehicle", help="Fetch a vehicle by license plate")
vehicle_parser.add_argument("--merchant-id", type=int, required=True)
vehicle_parser.add_argument("--license-plate", required=True)
shipments_parser = subparsers.add_parser("shipments", help="List unshipped shipments by area")
shipments_parser.add_argument("--merchant-id", type=int, required=True)
shipments_parser.add_argument("--area", required=True)
shipments_parser.add_argument("--limit", type=int)
shipments_parser.add_argument("--offset", type=int)
return parser
async def _run() -> None:
parser = _build_parser()
args = parser.parse_args()
if args.command == "vehicle":
result = await get_transport_vehicle_by_license_plate(
merchant_id=args.merchant_id,
license_plate=args.license_plate,
)
else:
result = await list_unshipped_shipments(
merchant_id=args.merchant_id,
area=args.area,
limit=args.limit,
offset=args.offset,
)
print(json.dumps(result.model_dump(mode="json"), ensure_ascii=False, indent=2))
def main() -> None:
asyncio.run(_run())
if __name__ == "__main__":
main()

View File

@@ -28,6 +28,7 @@ class GuardrailError(RuntimeError):
def _tool_args(**kwargs: Any) -> dict[str, Any]: def _tool_args(**kwargs: Any) -> dict[str, Any]:
return {key: value for key, value in kwargs.items() if value is not None} return {key: value for key, value in kwargs.items() if value is not None}
def _required_env(name: str) -> str: def _required_env(name: str) -> str:
value = os.getenv(name, "").strip() value = os.getenv(name, "").strip()
if not value: if not value:
@@ -110,6 +111,7 @@ def _build_mcp_server() -> MCPServerHTTP | MCPServerSSE | MCPServerStreamableHTT
"AMAP_MCP_TRANSPORT must be one of: streamable_http, http, sse" "AMAP_MCP_TRANSPORT must be one of: streamable_http, http, sse"
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Agent # Agent
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -270,7 +272,11 @@ def _select_precise_poi(
return best_poi return best_poi
async def _call_mcp_tool(server: MCPServerHTTP | MCPServerSSE | MCPServerStreamableHTTP, name: str, args: dict[str, Any]) -> Any: async def _call_mcp_tool(
server: MCPServerHTTP | MCPServerSSE | MCPServerStreamableHTTP,
name: str,
args: dict[str, Any],
) -> Any:
try: try:
return await server.direct_call_tool(name, args) return await server.direct_call_tool(name, args)
except ModelRetry as exc: except ModelRetry as exc:
@@ -375,7 +381,13 @@ async def _resolve_request_points(
server = _build_mcp_server() server = _build_mcp_server()
async with server: async with server:
async def resolve_cached(*, role: str, input_name: str | None, input_address: str, city: str | None) -> ResolvedPoint: async def resolve_cached(
*,
role: str,
input_name: str | None,
input_address: str,
city: str | None,
) -> ResolvedPoint:
cache_key = (input_name, input_address, city) cache_key = (input_name, input_address, city)
cached = cache.get(cache_key) cached = cache.get(cache_key)
if cached is None: if cached is None:
@@ -669,6 +681,7 @@ def _validate_result(request: RoutePlanRequest, result: RoutePlanResult) -> Rout
return result return result
def create_geo_agent() -> Agent[None, RoutePlanResult]: def create_geo_agent() -> Agent[None, RoutePlanResult]:
return Agent( return Agent(
model=_build_model(), model=_build_model(),

View File

@@ -2,7 +2,7 @@
## 1. 项目目标 ## 1. 项目目标
本项目用于实现一个“多目标地点最优路线规划”服务。 本项目用于实现一个“多目标地点最优路线规划 + 货物装载规划”服务。
核心能力如下: 核心能力如下:
@@ -13,6 +13,8 @@
- 根据策略选出最佳路线 - 根据策略选出最佳路线
- 返回结构化 JSON供前端直接消费 - 返回结构化 JSON供前端直接消费
- 在需要时生成高德 deep link - 在需要时生成高德 deep link
- 接收区域和车牌号,获取待出货出货单与车辆容量
- 根据针织/梭织规则选择可装载的出货单
当前阶段的目标不是构建一个复杂的调度系统,而是先交付一个可工作的第一版 Agent API。 当前阶段的目标不是构建一个复杂的调度系统,而是先交付一个可工作的第一版 Agent API。
@@ -30,25 +32,36 @@
- `main.py` - `main.py`
- FastAPI 入口 - FastAPI 入口
- 暴露 `/healthz``/route/plan` - 暴露 `/healthz``/route/plan``/load/plan`
- 负责 HTTP 错误映射 - 负责 Authorization 鉴权与 HTTP 错误映射
- `schemas.py` - `schemas.py`
- 定义请求和响应模型 - 定义请求和响应模型
- 实现输入校验 - 实现输入校验
- `agent.py` - `agent/route_plan.py`
- 路线规划 Agent
- 负责 LLM、MCP、prompt、运行护栏、结果护栏 - 负责 LLM、MCP、prompt、运行护栏、结果护栏
- `agent/load_plan.py`
- 装载规划 Agent
- 负责装载模型、skills、工具接入与结构化输出
- `agent/load_plan_tools.py`
- 装载规划业务 API 工具
- 当前提供车辆详情和未出货出货单查询
- `skills/fabric-load-planning/SKILL.md`
- 装载规划 skill
- 定义面料判断、欠载与不混装规则
- `.env` - `.env`
- 管理模型配置、MCP 配置和运行护栏配置 - 管理模型配置、MCP 配置和运行护栏配置
运行链路: 运行链路:
1. 前端请求进入 FastAPI 1. 前端请求进入 FastAPI
2. Pydantic 校验请求结构 2. `/route/plan``/load/plan` 先通过 Authorization 鉴权
3. 服务侧执行预护栏检查 3. Pydantic 校验请求结构
4. Agent 运行,并由 `system_prompt` 驱动模型调用高德 MCP 工具 4. 根据不同入口进入对应 Agent
5. Agent 返回结构化结果 5. Agent 调用地图 MCP 或装载业务 API 工具
6. 服务侧执行结果护栏检查 6. Agent 返回结构化结果
7. 返回 JSON 给前端 7. 服务侧执行错误映射
8. 返回 JSON 给前端
## 4. 关键技术选型与决策依据 ## 4. 关键技术选型与决策依据
@@ -100,6 +113,22 @@
- `maps_schema_navi` - `maps_schema_navi`
- `maps_weather` - `maps_weather`
### 5.2 装载规划 Agent 接入
- 装载规划 Agent 使用独立模型配置:
- `LOAD_PLAN_BASE_URL`
- `LOAD_PLAN_API_KEY`
- `LOAD_PLAN_MODEL`
- `LOAD_PLAN_REQUEST_TIMEOUT_SECONDS`
- 装载规划业务 API 使用独立配置:
- `LOAD_PLAN_API_HOST`
- `LOAD_PLAN_AGENT_ACCESS_KEY`
- `LOAD_PLAN_API_TIMEOUT_SECONDS`
- 当前装载规划已接入两个业务工具:
- 按车牌查询车辆详情
- 按区域查询未出货出货单
- 装载规则通过 `pydantic-ai-skills` 从本地 `skills/` 目录注入
### 5.2 Prompt 组织方式 ### 5.2 Prompt 组织方式
`system_prompt` 中定义了以下行为约束: `system_prompt` 中定义了以下行为约束:
@@ -211,6 +240,9 @@
- 超限请求可返回 422并中止模型执行 - 超限请求可返回 422并中止模型执行
- 已改为严格 deep-link 模式:成功结果必须包含 deep link否则直接失败 - 已改为严格 deep-link 模式:成功结果必须包含 deep link否则直接失败
- 已增加前置点位解析与 POI 校验阶段,缺少 `poi_id` 或命中模糊时直接失败 - 已增加前置点位解析与 POI 校验阶段,缺少 `poi_id` 或命中模糊时直接失败
- 已增加 `/load/plan` HTTP 入口
- 已接入装载规划 Agent、业务 API tools 与本地 skills
- 已验证 `merchant_id + area + license_plate` 可返回装载规划结果
当前尚未完成: 当前尚未完成:
@@ -219,6 +251,7 @@
- 深链策略的更强一致性校验 - 深链策略的更强一致性校验
- 自动化测试 - 自动化测试
- 前端展示层 HTML 输出 - 前端展示层 HTML 输出
- 多出货单组合场景的稳定性增强
## 8. 关键取舍 ## 8. 关键取舍

160
docs/api_v2_agent_api.md Normal file
View File

@@ -0,0 +1,160 @@
# API v2 Agent 接口文档
本文档面向 AI Agent 及外部自动化调用方,描述 `/api/v2/ai/` 前缀下的所有接口。
## 基本约定
- Base URL: `/api/v2/ai`
- 认证:所有接口使用固定 API Key通过 `Authorization` 请求头直接传入(无 `Bearer` 前缀)
- 多商户隔离:每个接口均需传入 `merchant_id` 查询参数,后端以此确定数据范围
### 认证方式
```
Authorization: <AGENT_ACCESS_KEY>
```
`AGENT_ACCESS_KEY` 由后端部署时通过同名环境变量配置。未配置时所有请求均返回 `401`
---
## 数据结构
### TransportVehicle
```json
{
"id": 1,
"merchant_id": 10,
"name": "大卡车",
"license_plate": "粤A12345",
"material_capacities": [
{"id": 1, "material_name": "坯布", "capacity": 500},
{"id": 2, "material_name": "成品", "capacity": 300}
],
"created_at": "2026-04-01T08:00:00+08:00",
"updated_at": "2026-04-01T08:00:00+08:00"
}
```
### Shipment出货单
```json
{
"id": 100,
"merchant_id": 10,
"customer": 5,
"customer_name": "客户A",
"shipment_date": "2026-04-14",
"address": "广州市天河区",
"contact_name": "张三",
"contact_phone": "13800000000",
"area": "华东",
"remark": "备注",
"status": "pending",
"status_display": "待出货",
"external_id": null,
"geo_coordinates": {"lat": 23.1291, "lng": 113.2644},
"delivery": null,
"sales_items": [
{
"id": 201,
"name": "销售品甲",
"quantity": "50.00",
"unit": 1,
"unit_display": "件",
"position": "A-01",
"remark": "",
"printing_job_id": 88,
"printing_job_width": "150cm"
},
{
"id": 202,
"name": "销售品乙",
"quantity": "10.00",
"unit": 1,
"unit_display": "件",
"position": "",
"remark": "",
"printing_job_id": null,
"printing_job_width": null
}
],
"created_at": "2026-04-14T10:00:00+08:00",
"updated_at": "2026-04-14T10:00:00+08:00"
}
```
---
## 运输车辆列表
- URL: `/api/v2/ai/transport-vehicles/`
- Method: `GET`
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `merchant_id` | int | 是 | 商户 ID |
| `limit` | int | 否 | 分页每页数量(默认由服务端决定) |
| `offset` | int | 否 | 分页偏移量 |
响应为分页结构,`results` 中每条为 `TransportVehicle`,包含该车辆的所有物料容量。
说明:
- `material_capacities` 表示该车辆对不同物料的最大装载量(单位:条)
- 只返回 `merchant_id` 对应商户的车辆
- `VehicleType`(旧版车辆类型字典)和 `VehicleTransportRecord`(旧版司机车次记录)是已废弃的模型,不在此接口返回
## 运输车辆详情
- URL: `/api/v2/ai/transport-vehicles/<license_plate>/`
- Method: `GET`
路径参数:
| 参数 | 说明 |
|------|------|
| `license_plate` | 车牌号码URL 编码,如 `粤A12345``%E7%B2%A4A12345` |
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `merchant_id` | int | 是 | 商户 ID |
说明:
- 同一商户内 `license_plate` 唯一(数据库 `unique_together: merchant + license_plate`),因此 `merchant_id + license_plate` 可精确定位一辆车
- 不同商户可能存在相同车牌号,`merchant_id` 参数是必须的
- 车辆不存在时返回 `404`
成功响应:`TransportVehicle`
---
## 未出货出货单列表
- URL: `/api/v2/ai/shipments/unshipped/`
- Method: `GET`
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `merchant_id` | int | 是 | 商户 ID |
| `area` | string | 是 | 地区筛选(精确匹配) |
| `limit` | int | 否 | 分页每页数量 |
| `offset` | int | 否 | 分页偏移量 |
说明:
- "未出货"定义:`delivery` 为空,即尚未关联送货单
- 结果按 `created_at` 降序排列
- 只返回 `merchant_id` 对应商户的数据
- `sales_items` 包含该出货单的所有销售品(软删除的条目自动排除)
- `sales_items[].printing_job_width`:对应 `PrintingJob → PrintingOrder.width`;若无关联印染任务则为 `null`
响应为分页结构,`results` 中每条为 `Shipment`

View File

@@ -10,6 +10,7 @@
- `GET /healthz` - `GET /healthz`
- `POST /route/plan` - `POST /route/plan`
- `POST /load/plan`
默认本地开发地址示例: 默认本地开发地址示例:
@@ -17,7 +18,24 @@
http://127.0.0.1:8000 http://127.0.0.1:8000
``` ```
### 2.1 CORS ### 2.1 Authorization
`GET /healthz` 外,当前业务接口都需要通过 `Authorization` 请求头进行鉴权。
请求头格式:
```text
Authorization: <AGENT_HTTP_AUTH_KEY>
```
说明:
- 不使用 `Bearer` 前缀
- 服务端从 `.env` 中读取 `AGENT_HTTP_AUTH_KEY`
- 如果服务端未配置该值,请求会返回 `503`
- 如果请求头缺失或值不匹配,请求会返回 `401`
### 2.2 CORS
当前服务已启用 CORS。 当前服务已启用 CORS。
@@ -48,6 +66,7 @@ GET /healthz
```http ```http
POST /route/plan POST /route/plan
Authorization: <AGENT_HTTP_AUTH_KEY>
Content-Type: application/json Content-Type: application/json
``` ```
@@ -444,3 +463,86 @@ Content-Type: application/json
- 前端确认 deep link 的按钮交互形式 - 前端确认 deep link 的按钮交互形式
- 前后端统一 422 错误展示文案 - 前后端统一 422 错误展示文案
- 后续若输出结构调整,需要同步更新本文档 - 后续若输出结构调整,需要同步更新本文档
## 11. 装载规划接口
### 11.1 请求
```http
POST /load/plan
Authorization: <AGENT_HTTP_AUTH_KEY>
Content-Type: application/json
```
### 11.2 请求体
```json
{
"merchant_id": 1,
"area": "中大",
"license_plate": "粤A4Y0Y5"
}
```
### 11.3 请求字段说明
- `merchant_id`
- 必填
- 商户 ID
- `area`
- 必填
- 按区域筛选待出货出货单,精确匹配
- `license_plate`
- 必填
- 目标运输车辆车牌号
### 11.4 成功响应示例
```json
{
"success": true,
"license_plate": "粤A4Y0Y5",
"selected_shipment_ids": [13],
"summary": "待出货出货单共1个出货单ID13的销售品幅宽均为170cm判定为针织面料包含2条销售品未超过车辆针织最大装载容量120条因此选择该出货单进行装载。",
"warnings": [
"当前装载条数为2条未达到针织面料最大运输容量120条属于欠载方案。"
]
}
```
### 11.5 响应字段说明
- `success`
- 是否成功完成装载规划
- `license_plate`
- 本次规划对应的车牌号
- `selected_shipment_ids`
- 被选中的出货单 ID 列表
- `summary`
- 可直接展示给用户的装载说明
- `warnings`
- 欠载、字段缺失、无法判定面料等提示信息
### 11.6 错误语义
- `401`
- `Authorization` 请求头缺失或值不正确
- `422`
- 装载规则护栏错误或输入不满足规划要求
- `503`
- 装载模型配置、业务 API 配置或服务端鉴权配置缺失
- `502`
- 上游业务 API 返回错误,例如车辆不存在或接口请求失败
- `504`
- 装载模型或业务 API 超时
- `500`
- 未预期的内部错误
### 11.7 当前行为说明
- Agent 会先查询指定 `area` 的未出货出货单,再查询指定车牌的车辆容量
- 面料类型仅通过 `sales_items[].printing_job_width` 判断
- 装载量按销售品条数计算,不按 `quantity` 长度或米数计算
- 默认不允许混装针织和梭织
- 优先选择不超载且尽量接近最大容量的方案
- 如果只能欠载,会在 `warnings` 中明确提示

50
main.py
View File

@@ -1,16 +1,29 @@
import os import os
from secrets import compare_digest
import httpx import httpx
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException from fastapi import Depends, FastAPI, HTTPException, Security
from openai import APITimeoutError from openai import APITimeoutError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security.api_key import APIKeyHeader
from schemas import RoutePlanRequest, RoutePlanResult from schemas import LoadPlanRequest, LoadPlanResult, RoutePlanRequest, RoutePlanResult
from agent import ConfigurationError, GuardrailError, run_route_plan from agent import (
ConfigurationError,
GuardrailError,
LoadPlanConfigurationError,
LoadPlanGuardrailError,
LoadPlanToolConfigurationError,
LoadPlanToolRequestError,
run_load_plan,
run_route_plan,
)
load_dotenv() load_dotenv()
api_key_header = APIKeyHeader(name="Authorization", auto_error=False)
def _csv_env(name: str, default: str) -> list[str]: def _csv_env(name: str, default: str) -> list[str]:
raw_value = os.getenv(name, default) raw_value = os.getenv(name, default)
@@ -21,6 +34,19 @@ def _bool_env(name: str, default: bool) -> bool:
raw_value = os.getenv(name, "true" if default else "false").strip().lower() raw_value = os.getenv(name, "true" if default else "false").strip().lower()
return raw_value in {"1", "true", "yes", "on"} return raw_value in {"1", "true", "yes", "on"}
def _required_env(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise HTTPException(status_code=503, detail=f"Missing required environment variable: {name}")
return value
def require_authorization(api_key: str | None = Security(api_key_header)) -> None:
expected_key = _required_env("AGENT_HTTP_AUTH_KEY")
if api_key is None or not compare_digest(api_key, expected_key):
raise HTTPException(status_code=401, detail="Unauthorized")
app = FastAPI(title="Geo Route Agent", version="0.1.0") app = FastAPI(title="Geo Route Agent", version="0.1.0")
app.add_middleware( app.add_middleware(
@@ -36,7 +62,7 @@ app.add_middleware(
) )
@app.post("/route/plan", response_model=RoutePlanResult) @app.post("/route/plan", response_model=RoutePlanResult, dependencies=[Depends(require_authorization)])
async def route_plan(request: RoutePlanRequest) -> RoutePlanResult: async def route_plan(request: RoutePlanRequest) -> RoutePlanResult:
try: try:
return await run_route_plan(request) return await run_route_plan(request)
@@ -50,6 +76,22 @@ async def route_plan(request: RoutePlanRequest) -> RoutePlanResult:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/load/plan", response_model=LoadPlanResult, dependencies=[Depends(require_authorization)])
async def load_plan(request: LoadPlanRequest) -> LoadPlanResult:
try:
return await run_load_plan(request)
except (LoadPlanConfigurationError, LoadPlanToolConfigurationError) as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except LoadPlanGuardrailError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except LoadPlanToolRequestError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except (httpx.TimeoutException, APITimeoutError, TimeoutError) as exc:
raise HTTPException(status_code=504, detail=f"Upstream request timed out: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/healthz") @app.get("/healthz")
async def healthz() -> dict[str, str]: async def healthz() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}

View File

@@ -9,4 +9,5 @@ dependencies = [
"fastapi[standard]", "fastapi[standard]",
"python-dotenv", "python-dotenv",
"openai", "openai",
"pydantic-ai-skills>=0.7.0",
] ]

View File

@@ -149,3 +149,82 @@ class RoutePlanResult(BaseModel):
deep_links: DeepLinks | None = None deep_links: DeepLinks | None = None
summary: str summary: str
warnings: list[str] warnings: list[str]
class LoadPlanRequest(BaseModel):
merchant_id: int
area: str
license_plate: str
class LoadPlanResult(BaseModel):
success: bool
license_plate: str
selected_shipment_ids: list[int] = Field(default_factory=list)
summary: str
warnings: list[str] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Load planning API integration
# ---------------------------------------------------------------------------
class TransportVehicleMaterialCapacity(BaseModel):
id: int
material_name: str
capacity: int
class TransportVehicle(BaseModel):
id: int
merchant_id: int
name: str
license_plate: str
material_capacities: list[TransportVehicleMaterialCapacity] = Field(default_factory=list)
created_at: str
updated_at: str
class ShipmentGeoCoordinates(BaseModel):
lat: float
lng: float
class ShipmentSalesItem(BaseModel):
id: int
name: str
quantity: str
unit: int | None = None
unit_display: str | None = None
position: str | None = None
remark: str | None = None
printing_job_id: int | None = None
printing_job_width: str | None = None
class Shipment(BaseModel):
id: int
merchant_id: int
customer: int | None = None
customer_name: str | None = None
shipment_date: str
address: str
contact_name: str | None = None
contact_phone: str | None = None
area: str | None = None
remark: str | None = None
status: int | str
status_display: str | None = None
external_id: str | None = None
geo_coordinates: ShipmentGeoCoordinates | None = None
delivery: int | None = None
sales_items: list[ShipmentSalesItem] = Field(default_factory=list)
created_at: str
updated_at: str
class ShipmentPage(BaseModel):
count: int | None = None
next: str | None = None
previous: str | None = None
results: list[Shipment] = Field(default_factory=list)

View File

@@ -0,0 +1,56 @@
---
name: fabric-load-planning
description: 依据车辆对针织/梭织的装载容量,从指定区域的待出货出货单中选择同一种面料且可装满车辆的出货单组合。
---
你负责执行“按面料类型装载出货单”的任务。
必须遵守以下规则:
1. 针织和梭织是两种不同的面料工艺。
2. 判断销售品面料类型的唯一方法是 `sales_items[].printing_job_width`
- 当幅宽小于 `158cm``1.58m` 时,该销售品属于 `梭织`
- 当幅宽大于等于 `158cm``1.58m` 时,该销售品属于 `针织`
3. 除了 `printing_job_width` 之外,没有其它可靠办法判断销售品属于针织还是梭织。
4. 不同面料类型不能混装。
5. 任务开始时一定会提供 `merchant_id``area``license_plate`
6. 你必须先获取指定 `area` 的待出货出货单,再获取指定车牌对应车辆的装载量。
7. 车辆的 `material_capacities` 表示不同面料类型的最大装载量,单位是“条”。
8. 只有当你能明确判断某个出货单属于单一面料类型时,才能把它纳入候选。
9. 如果一个出货单满足以下任一情况,则不能被选中:
- `sales_items` 为空
- 任一销售品缺少 `printing_job_width`
- 任一销售品的 `printing_job_width` 无法解析出面料类型
- 同一个出货单内的销售品被判断为两种不同面料
10. 一个出货单的装载量等于其 `sales_items` 的数量,也就是销售品条数/卷数。
11. `quantity` 表示长度/米数,不表示条数,不能用于车辆装载量计算。
12. 你需要选择一组“同一种面料”的出货单,在不超过该车辆对该面料容量的前提下尽量接近最大装载量。
13. 如果存在刚好装满的组合,优先选择刚好装满的组合。
14. 如果不存在刚好装满的组合,可以选择“不超载且最接近容量”的欠载方案。
15. 如果选择的是欠载方案,必须在 `warnings` 中明确提示尚未达到最大运输量,并说明当前装载条数与最大容量。
16. 如果不存在任何可装载的有效出货单,不要猜测,返回空列表并说明原因。
17. 最终只返回:
- `license_plate`
- `selected_shipment_ids`
- `summary`
- `warnings`
执行顺序要求:
1. 先读取区域待出货出货单
2. 再读取车辆容量
3. 根据 `printing_job_width` 判断每个出货单的面料类型
4. 只在同一种面料内组合出货单
5. 优先找“刚好装满”的组合;如果没有,再找“不超载且最接近容量”的组合
6. 只有在完全不存在可装载有效组合时,才返回空结果并说明原因
判断细节:
- `158cm``158 cm``1.58m``1.58 m` 都应视为同一个阈值
- 如果是纯数字但未注明单位,默认按 `cm` 理解
- `sales_items` 中每一个条目代表 1 条/1 卷销售品
- `quantity` 可能是字符串,例如 `"50.00"`,但它只表示长度或米数,装载计算时必须忽略
- 如果车辆容量名称里出现 `针织`,对应针织容量;出现 `梭织`,对应梭织容量
- 不能把 `针织``梭织` 的出货单混在一个结果里
- 若多个组合都不超载,优先选择装载条数最大的组合
- 若装载条数相同,优先选择出货单数量更少的组合

16
uv.lock generated
View File

@@ -740,6 +740,7 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "openai" }, { name = "openai" },
{ name = "pydantic-ai" }, { name = "pydantic-ai" },
{ name = "pydantic-ai-skills" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
] ]
@@ -748,6 +749,7 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"] }, { name = "fastapi", extras = ["standard"] },
{ name = "openai" }, { name = "openai" },
{ name = "pydantic-ai" }, { name = "pydantic-ai" },
{ name = "pydantic-ai-skills", specifier = ">=0.7.0" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
] ]
@@ -1675,6 +1677,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/9a/6d65ea560f8e84f57cf5cf7f5b611d48bd5d1869120bc7fcc3b40aed0556/pydantic_ai-1.80.0-py3-none-any.whl", hash = "sha256:71d0117a7c55116c00d7dfdc973f0bead056709608b251f83a884821341177ee", size = 7549, upload-time = "2026-04-10T23:31:09.674Z" }, { url = "https://files.pythonhosted.org/packages/2c/9a/6d65ea560f8e84f57cf5cf7f5b611d48bd5d1869120bc7fcc3b40aed0556/pydantic_ai-1.80.0-py3-none-any.whl", hash = "sha256:71d0117a7c55116c00d7dfdc973f0bead056709608b251f83a884821341177ee", size = 7549, upload-time = "2026-04-10T23:31:09.674Z" },
] ]
[[package]]
name = "pydantic-ai-skills"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "pydantic-ai-slim" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/3d/9f41e66bbc4e5a64703251ea805c906ee5da92640c314b39cded343a3d69/pydantic_ai_skills-0.7.0.tar.gz", hash = "sha256:69eb76e9335768fd8c87096675d096d4649836b3c94ba07734549c4a36502b64", size = 9006425, upload-time = "2026-04-12T19:05:45.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/95/51ed743ca7ea4b9cd4e4f7a42bc7ea2b18a3015550be0504074af509135e/pydantic_ai_skills-0.7.0-py3-none-any.whl", hash = "sha256:06dd7e8de23c8543e82b3ef79eb40106dce4c9962367293e230191745e10d436", size = 53916, upload-time = "2026-04-12T19:05:43.641Z" },
]
[[package]] [[package]]
name = "pydantic-ai-slim" name = "pydantic-ai-slim"
version = "1.80.0" version = "1.80.0"