From 6f263e365f866603292c2410e81c205d6dfa837a Mon Sep 17 00:00:00 2001 From: stupid_run Date: Wed, 15 Apr 2026 14:05:33 +0800 Subject: [PATCH] feat: auth --- .codex | 0 .env.example | 14 +++ agent/__init__.py | 28 +++++ agent/load_plan.py | 107 ++++++++++++++++++ agent/load_plan_tools.py | 112 +++++++++++++++++++ agent/load_plan_tools_cli.py | 53 +++++++++ agent.py => agent/route_plan.py | 17 ++- docs/agent_design.md | 53 +++++++-- docs/api_v2_agent_api.md | 160 +++++++++++++++++++++++++++ docs/frontend_api.md | 104 ++++++++++++++++- main.py | 50 ++++++++- pyproject.toml | 1 + schemas.py | 79 +++++++++++++ skills/fabric-load-planning/SKILL.md | 56 ++++++++++ uv.lock | 16 +++ 15 files changed, 833 insertions(+), 17 deletions(-) create mode 100644 .codex create mode 100644 agent/__init__.py create mode 100644 agent/load_plan.py create mode 100644 agent/load_plan_tools.py create mode 100644 agent/load_plan_tools_cli.py rename agent.py => agent/route_plan.py (98%) create mode 100644 docs/api_v2_agent_api.md create mode 100644 skills/fabric-load-planning/SKILL.md diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.env.example b/.env.example index 5387bff..8599ee6 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,20 @@ AMAP_MCP_AUTH_HEADER_VALUE= # Route planning guardrails 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_ALLOW_ORIGINS=http://localhost,http://127.0.0.1 CORS_ALLOW_ORIGIN_REGEX=https?://(localhost|127\.0\.0\.1)(:\d+)?$ diff --git a/agent/__init__.py b/agent/__init__.py new file mode 100644 index 0000000..56223d9 --- /dev/null +++ b/agent/__init__.py @@ -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", +] diff --git a/agent/load_plan.py b/agent/load_plan.py new file mode 100644 index 0000000..854a36e --- /dev/null +++ b/agent/load_plan.py @@ -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 diff --git a/agent/load_plan_tools.py b/agent/load_plan_tools.py new file mode 100644 index 0000000..23cc991 --- /dev/null +++ b/agent/load_plan_tools.py @@ -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()) diff --git a/agent/load_plan_tools_cli.py b/agent/load_plan_tools_cli.py new file mode 100644 index 0000000..e41b838 --- /dev/null +++ b/agent/load_plan_tools_cli.py @@ -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() diff --git a/agent.py b/agent/route_plan.py similarity index 98% rename from agent.py rename to agent/route_plan.py index 75c03e2..4606f3e 100644 --- a/agent.py +++ b/agent/route_plan.py @@ -28,6 +28,7 @@ class GuardrailError(RuntimeError): def _tool_args(**kwargs: Any) -> dict[str, Any]: return {key: value for key, value in kwargs.items() if value is not None} + def _required_env(name: str) -> str: value = os.getenv(name, "").strip() 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" ) + # --------------------------------------------------------------------------- # Agent # --------------------------------------------------------------------------- @@ -270,7 +272,11 @@ def _select_precise_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: return await server.direct_call_tool(name, args) except ModelRetry as exc: @@ -375,7 +381,13 @@ async def _resolve_request_points( server = _build_mcp_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) cached = cache.get(cache_key) if cached is None: @@ -669,6 +681,7 @@ def _validate_result(request: RoutePlanRequest, result: RoutePlanResult) -> Rout return result + def create_geo_agent() -> Agent[None, RoutePlanResult]: return Agent( model=_build_model(), diff --git a/docs/agent_design.md b/docs/agent_design.md index 55f0a8a..3971ed1 100644 --- a/docs/agent_design.md +++ b/docs/agent_design.md @@ -2,7 +2,7 @@ ## 1. 项目目标 -本项目用于实现一个“多目标地点最优路线规划”服务。 +本项目用于实现一个“多目标地点最优路线规划 + 货物装载规划”服务。 核心能力如下: @@ -13,6 +13,8 @@ - 根据策略选出最佳路线 - 返回结构化 JSON,供前端直接消费 - 在需要时生成高德 deep link +- 接收区域和车牌号,获取待出货出货单与车辆容量 +- 根据针织/梭织规则选择可装载的出货单 当前阶段的目标不是构建一个复杂的调度系统,而是先交付一个可工作的第一版 Agent API。 @@ -30,25 +32,36 @@ - `main.py` - FastAPI 入口 - - 暴露 `/healthz` 和 `/route/plan` - - 负责 HTTP 错误映射 + - 暴露 `/healthz`、`/route/plan` 和 `/load/plan` + - 负责 Authorization 鉴权与 HTTP 错误映射 - `schemas.py` - 定义请求和响应模型 - 实现输入校验 -- `agent.py` +- `agent/route_plan.py` + - 路线规划 Agent - 负责 LLM、MCP、prompt、运行护栏、结果护栏 +- `agent/load_plan.py` + - 装载规划 Agent + - 负责装载模型、skills、工具接入与结构化输出 +- `agent/load_plan_tools.py` + - 装载规划业务 API 工具 + - 当前提供车辆详情和未出货出货单查询 +- `skills/fabric-load-planning/SKILL.md` + - 装载规划 skill + - 定义面料判断、欠载与不混装规则 - `.env` - 管理模型配置、MCP 配置和运行护栏配置 运行链路: 1. 前端请求进入 FastAPI -2. Pydantic 校验请求结构 -3. 服务侧执行预护栏检查 -4. Agent 运行,并由 `system_prompt` 驱动模型调用高德 MCP 工具 -5. Agent 返回结构化结果 -6. 服务侧执行结果护栏检查 -7. 返回 JSON 给前端 +2. `/route/plan` 和 `/load/plan` 先通过 Authorization 鉴权 +3. Pydantic 校验请求结构 +4. 根据不同入口进入对应 Agent +5. Agent 调用地图 MCP 或装载业务 API 工具 +6. Agent 返回结构化结果 +7. 服务侧执行错误映射 +8. 返回 JSON 给前端 ## 4. 关键技术选型与决策依据 @@ -100,6 +113,22 @@ - `maps_schema_navi` - `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 组织方式 `system_prompt` 中定义了以下行为约束: @@ -211,6 +240,9 @@ - 超限请求可返回 422,并中止模型执行 - 已改为严格 deep-link 模式:成功结果必须包含 deep link,否则直接失败 - 已增加前置点位解析与 POI 校验阶段,缺少 `poi_id` 或命中模糊时直接失败 +- 已增加 `/load/plan` HTTP 入口 +- 已接入装载规划 Agent、业务 API tools 与本地 skills +- 已验证 `merchant_id + area + license_plate` 可返回装载规划结果 当前尚未完成: @@ -219,6 +251,7 @@ - 深链策略的更强一致性校验 - 自动化测试 - 前端展示层 HTML 输出 +- 多出货单组合场景的稳定性增强 ## 8. 关键取舍 diff --git a/docs/api_v2_agent_api.md b/docs/api_v2_agent_api.md new file mode 100644 index 0000000..8df6db0 --- /dev/null +++ b/docs/api_v2_agent_api.md @@ -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` 由后端部署时通过同名环境变量配置。未配置时所有请求均返回 `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//` +- 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`。 diff --git a/docs/frontend_api.md b/docs/frontend_api.md index 7227760..0fd5011 100644 --- a/docs/frontend_api.md +++ b/docs/frontend_api.md @@ -10,6 +10,7 @@ - `GET /healthz` - `POST /route/plan` +- `POST /load/plan` 默认本地开发地址示例: @@ -17,7 +18,24 @@ http://127.0.0.1:8000 ``` -### 2.1 CORS +### 2.1 Authorization + +除 `GET /healthz` 外,当前业务接口都需要通过 `Authorization` 请求头进行鉴权。 + +请求头格式: + +```text +Authorization: +``` + +说明: + +- 不使用 `Bearer` 前缀 +- 服务端从 `.env` 中读取 `AGENT_HTTP_AUTH_KEY` +- 如果服务端未配置该值,请求会返回 `503` +- 如果请求头缺失或值不匹配,请求会返回 `401` + +### 2.2 CORS 当前服务已启用 CORS。 @@ -48,6 +66,7 @@ GET /healthz ```http POST /route/plan +Authorization: Content-Type: application/json ``` @@ -444,3 +463,86 @@ Content-Type: application/json - 前端确认 deep link 的按钮交互形式 - 前后端统一 422 错误展示文案 - 后续若输出结构调整,需要同步更新本文档 + +## 11. 装载规划接口 + +### 11.1 请求 + +```http +POST /load/plan +Authorization: +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` 中明确提示 diff --git a/main.py b/main.py index 397d636..24a5e20 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,29 @@ import os +from secrets import compare_digest import httpx from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException +from fastapi import Depends, FastAPI, HTTPException, Security from openai import APITimeoutError from fastapi.middleware.cors import CORSMiddleware +from fastapi.security.api_key import APIKeyHeader -from schemas import RoutePlanRequest, RoutePlanResult -from agent import ConfigurationError, GuardrailError, run_route_plan +from schemas import LoadPlanRequest, LoadPlanResult, RoutePlanRequest, RoutePlanResult +from agent import ( + ConfigurationError, + GuardrailError, + LoadPlanConfigurationError, + LoadPlanGuardrailError, + LoadPlanToolConfigurationError, + LoadPlanToolRequestError, + run_load_plan, + run_route_plan, +) load_dotenv() +api_key_header = APIKeyHeader(name="Authorization", auto_error=False) + def _csv_env(name: str, default: str) -> list[str]: 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() 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.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: try: 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 +@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") async def healthz() -> dict[str, str]: return {"status": "ok"} diff --git a/pyproject.toml b/pyproject.toml index 1c27099..f4e4676 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,4 +9,5 @@ dependencies = [ "fastapi[standard]", "python-dotenv", "openai", + "pydantic-ai-skills>=0.7.0", ] diff --git a/schemas.py b/schemas.py index f2b4d63..e30c42e 100644 --- a/schemas.py +++ b/schemas.py @@ -149,3 +149,82 @@ class RoutePlanResult(BaseModel): deep_links: DeepLinks | None = None summary: 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) diff --git a/skills/fabric-load-planning/SKILL.md b/skills/fabric-load-planning/SKILL.md new file mode 100644 index 0000000..d08bce2 --- /dev/null +++ b/skills/fabric-load-planning/SKILL.md @@ -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"`,但它只表示长度或米数,装载计算时必须忽略 +- 如果车辆容量名称里出现 `针织`,对应针织容量;出现 `梭织`,对应梭织容量 +- 不能把 `针织` 和 `梭织` 的出货单混在一个结果里 +- 若多个组合都不超载,优先选择装载条数最大的组合 +- 若装载条数相同,优先选择出货单数量更少的组合 diff --git a/uv.lock b/uv.lock index 27fdbf6..639b73e 100644 --- a/uv.lock +++ b/uv.lock @@ -740,6 +740,7 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "openai" }, { name = "pydantic-ai" }, + { name = "pydantic-ai-skills" }, { name = "python-dotenv" }, ] @@ -748,6 +749,7 @@ requires-dist = [ { name = "fastapi", extras = ["standard"] }, { name = "openai" }, { name = "pydantic-ai" }, + { name = "pydantic-ai-skills", specifier = ">=0.7.0" }, { 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" }, ] +[[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]] name = "pydantic-ai-slim" version = "1.80.0"