Spaces:
Sleeping
Sleeping
update space
Browse files- .env.example +8 -1
- Makefile +3 -3
- README.md +24 -4
- data/kb/04_禁寄限寄与危险品.md +1 -1
- data/seed/teacher_kb_qa.json +157 -1
- eval/cases.jsonl +35 -0
- logicllm/agent/prompts.py +8 -1
- logicllm/config.py +3 -1
- logicllm/tools/__init__.py +9 -0
- logicllm/tools/geo.py +37 -0
- logicllm/tools/pricing.py +10 -5
- logicllm/tools/tms.py +297 -0
- logicllm/tools/waybill.py +11 -5
- scripts/check_adapter.py +6 -2
- scripts/eval.py +24 -5
- scripts/fuse.sh +1 -1
- scripts/gen_sft.py +186 -35
- scripts/publish_hf.py +1 -1
- scripts/seed_tms_demo.py +115 -0
- scripts/serve_mlx.sh +10 -3
- scripts/train.sh +5 -3
.env.example
CHANGED
|
@@ -2,6 +2,13 @@
|
|
| 2 |
LLM_BACKEND=mlx
|
| 3 |
# LLM_BASE_URL=http://127.0.0.1:8080/v1
|
| 4 |
# LLM_MODEL=default_model # mlx_lm.server 用此名指代命令行加载的模型+LoRA
|
| 5 |
-
BASE_MODEL=mlx-community/
|
| 6 |
EMBED_MODEL=BAAI/bge-m3
|
| 7 |
RAG_TOP_K=4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
LLM_BACKEND=mlx
|
| 3 |
# LLM_BASE_URL=http://127.0.0.1:8080/v1
|
| 4 |
# LLM_MODEL=default_model # mlx_lm.server 用此名指代命令行加载的模型+LoRA
|
| 5 |
+
BASE_MODEL=mlx-community/Qwen3-14B-4bit
|
| 6 |
EMBED_MODEL=BAAI/bge-m3
|
| 7 |
RAG_TOP_K=4
|
| 8 |
+
|
| 9 |
+
# 工具后端:mock(内置演示库)| tms(对接本地零担通 TMS)
|
| 10 |
+
# TOOLS_BACKEND=tms
|
| 11 |
+
# TMS_BASE_URL=http://127.0.0.1:8000/api/v1
|
| 12 |
+
# TMS_PHONE=13800000001 # 开发种子账号(seed_dev.py),生产请改专用只读账号
|
| 13 |
+
# TMS_PASSWORD=Dev@12345
|
| 14 |
+
# TMS_TENANT=Dev Tenant
|
Makefile
CHANGED
|
@@ -4,9 +4,9 @@ install: ## 安装依赖
|
|
| 4 |
uv sync
|
| 5 |
kb: ## 构建知识库向量索引
|
| 6 |
uv run python scripts/build_kb.py
|
| 7 |
-
data: ## 生成 SFT 数据(
|
| 8 |
-
uv run python scripts/gen_sft.py
|
| 9 |
-
train: ## LoRA 微调(默认
|
| 10 |
bash scripts/train.sh $(ITERS)
|
| 11 |
serve-mlx: ## 启动 mlx 推理服务(8080,带 LoRA)
|
| 12 |
bash scripts/serve_mlx.sh
|
|
|
|
| 4 |
uv sync
|
| 5 |
kb: ## 构建知识库向量索引
|
| 6 |
uv run python scripts/build_kb.py
|
| 7 |
+
data: ## 生成 SFT 数据(知识问答复用 data/seed/teacher_kb_qa.json 缓存;题量与发布版一致)
|
| 8 |
+
uv run python scripts/gen_sft.py --n-tool 400 --n-extract 240 --n-route 120
|
| 9 |
+
train: ## LoRA 微调(默认 800 iters ≈ 1 epoch)
|
| 10 |
bash scripts/train.sh $(ITERS)
|
| 11 |
serve-mlx: ## 启动 mlx 推理服务(8080,带 LoRA)
|
| 12 |
bash scripts/serve_mlx.sh
|
README.md
CHANGED
|
@@ -7,7 +7,7 @@ sdk: gradio
|
|
| 7 |
app_file: app.py
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
| 10 |
-
short_description:
|
| 11 |
models:
|
| 12 |
- Qwen/Qwen3-14B
|
| 13 |
- zhatrix/logistics-qwen3-lora
|
|
@@ -126,19 +126,39 @@ eval/cases.jsonl 评测用例
|
|
| 126 |
| `EMBED_MODEL` | `BAAI/bge-m3` | 向量模型 |
|
| 127 |
| `RAG_TOP_K` / `RAG_MIN_SCORE` | 4 / 0.35 | 检索参数 |
|
| 128 |
|
| 129 |
-
## 评测结果(eval/cases.jsonl,
|
| 130 |
|
| 131 |
| 模型 | 总分 | 客服 | 知识 | 抽取 | 调度 |
|
| 132 |
|---|---|---|---|---|---|
|
| 133 |
| Qwen2.5-7B 基座 + RAG + 工具 | 0.825 | 0.833 | 0.938 | 0.000 | 1.000 |
|
| 134 |
| Qwen2.5-7B + LoRA(200 步) | 0.880 | 0.833 | 0.875 | 0.800 | 1.000 |
|
|
|
|
| 135 |
| Qwen3-14B + LoRA(200 步) | 0.940 | 1.000 | 0.875 | 0.900 | 1.000 |
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
当前默认基座:`mlx-community/Qwen3-14B-4bit`(训练时 `BASE_MODEL` 可换回 7B)。教师问答由人工精编(`data/seed/teacher_kb_qa.json`,127 条,含数值比较靶向题)。
|
| 139 |
|
| 140 |
微调解决了:抽取按约定输出 JSON、运费不漏尺寸、未指明服务类型时默认标准快递并说明、"53 度 vs 70%"类数值比较。
|
| 141 |
-
剩余:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
## 部署到 ModelScope 创空间
|
| 144 |
|
|
|
|
| 7 |
app_file: app.py
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
| 10 |
+
short_description: Qwen3-14B + 物流 LoRA + RAG + 工具调用
|
| 11 |
models:
|
| 12 |
- Qwen/Qwen3-14B
|
| 13 |
- zhatrix/logistics-qwen3-lora
|
|
|
|
| 126 |
| `EMBED_MODEL` | `BAAI/bge-m3` | 向量模型 |
|
| 127 |
| `RAG_TOP_K` / `RAG_MIN_SCORE` | 4 / 0.35 | 检索参数 |
|
| 128 |
|
| 129 |
+
## 评测结果(eval/cases.jsonl,规则打分)
|
| 130 |
|
| 131 |
| 模型 | 总分 | 客服 | 知识 | 抽取 | 调度 |
|
| 132 |
|---|---|---|---|---|---|
|
| 133 |
| Qwen2.5-7B 基座 + RAG + 工具 | 0.825 | 0.833 | 0.938 | 0.000 | 1.000 |
|
| 134 |
| Qwen2.5-7B + LoRA(200 步) | 0.880 | 0.833 | 0.875 | 0.800 | 1.000 |
|
| 135 |
+
| Qwen3-14B 基座 + RAG + 工具 | 0.800 | 0.833 | 0.875 | 0.000 | 1.000 |
|
| 136 |
| Qwen3-14B + LoRA(200 步) | 0.940 | 1.000 | 0.875 | 0.900 | 1.000 |
|
| 137 |
+
| Qwen3-14B + LoRA(600 步,验证集有泄漏时 Val 0.031) | 0.990 | 1.000 | 1.000 | 0.900 | 1.000 |
|
| 138 |
+
| Qwen3-14B + LoRA v2.1(数据修复版:真实区划 / 失败轨迹 / 零泄漏切分,Val 0.064) | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 |
|
| 139 |
+
| **Qwen3-14B + LoRA v2.2(组合调用 / 多轮 / 边界变体,800 步,Val 0.032)** | **1.000** | 1.000 | 1.000 | 1.000 | 1.000 |
|
| 140 |
+
|
| 141 |
+
2026-09-02 评测集从 20 例扩充到 **55 例**(数值边界、失败场景、多轮对话、组合调用、分布外问法;评分器支持
|
| 142 |
+
`expect_tools`/`keywords_any`/`forbid`/`turns`)。v2.1 在扩充集上 **0.909**(客服 0.889 / 知识 0.875 / 抽取 1.0 / 调度 1.0);
|
| 143 |
+
v2.2(2026-09-05)**0.982**(客服 1.0 / 知识 0.958 / 抽取 1.0 / 调度 1.0),仅剩"1600 公里几天到"套错档。
|
| 144 |
+
v2.2 改动:修复 `calc_freight` 对未收录城市静默按跨区计价(导致失败样本与工具结果不一致、模型对正常报价也回失败模板);
|
| 145 |
+
训练数据新增运费+时效组合调用、三类多轮追问、境外目的地失败轨迹,人工知识问答加 39 条分档/边界/SLA 变体(166 条);1472/134 条,800 步。
|
| 146 |
|
| 147 |
当前默认基座:`mlx-community/Qwen3-14B-4bit`(训练时 `BASE_MODEL` 可换回 7B)。教师问答由人工精编(`data/seed/teacher_kb_qa.json`,127 条,含数值比较靶向题)。
|
| 148 |
|
| 149 |
微调解决了:抽取按约定输出 JSON、运费不漏尺寸、未指明服务类型时默认标准快递并说明、"53 度 vs 70%"类数值比较。
|
| 150 |
+
剩余:训练分布外的数值套档仍可能出错(1600km 仍答 3 天,即使已加 1650km 变体),可继续加变体样本或在提示词里给出分档口诀。
|
| 151 |
+
|
| 152 |
+
## 对接真实 TMS(零担通)
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
uv run python scripts/seed_tms_demo.py # 可选:往 Dev Tenant 种演示数据(网点/客户/路由/运单)
|
| 156 |
+
TOOLS_BACKEND=tms LLM_BACKEND=mlx make serve # 运单查询/轨迹/异常/报价 切到真实接口
|
| 157 |
+
```
|
| 158 |
+
`logicllm/tools/tms.py` 覆盖同名工具(query/track/search/report_exception/calc_freight,另加 waybill_status_counts);
|
| 159 |
+
鉴权走 手机号+密码 → 选租户 → Bearer(TMS_* 环境变量配置)。要点:写接口需 `X-Idempotency-Key` 头;列表响应为
|
| 160 |
+
`{data, pagination}`;calc_freight 用 `/pricing/trial-quote` 真实报价引擎,结果映射为模型熟悉的 `total_fee/breakdown` 字段,
|
| 161 |
+
且 TMS 模式下系统提示会声明"运费以报价引擎为准"(避免模型套用知识库里的快递首重续重公式)。
|
| 162 |
|
| 163 |
## 部署到 ModelScope 创空间
|
| 164 |
|
data/kb/04_禁寄限寄与危险品.md
CHANGED
|
@@ -21,7 +21,7 @@
|
|
| 21 |
- **药品**:处方药需提供处方,不得寄递麻醉、精神类药品。
|
| 22 |
- **磁性物品**:需消磁或屏蔽包装后方可航空。
|
| 23 |
- **刀具**:菜刀、水果刀可陆运,需固定刀刃;管制刀具禁寄。
|
| 24 |
-
- **酒类**:≤ 24% 不限;24–70% 每件 ≤ 5L,需防碎包装,只能陆运;
|
| 25 |
|
| 26 |
## 危险品分类(UN 九大类)
|
| 27 |
| 类别 | 名称 | 示例 |
|
|
|
|
| 21 |
- **药品**:处方药需提供处方,不得寄递麻醉、精神类药品。
|
| 22 |
- **磁性物品**:需消磁或屏蔽包装后方可航空。
|
| 23 |
- **刀具**:菜刀、水果刀可陆运,需固定刀刃;管制刀具禁寄。
|
| 24 |
+
- **酒类**:≤ 24% 不限;24%–70%(含 70%)每件 ≤ 5L,需防碎包装,只能陆运;超过 70% 禁寄。
|
| 25 |
|
| 26 |
## 危险品分类(UN 九大类)
|
| 27 |
| 类别 | 名称 | 示例 |
|
data/seed/teacher_kb_qa.json
CHANGED
|
@@ -465,7 +465,7 @@
|
|
| 465 |
},
|
| 466 |
{
|
| 467 |
"q": "多少度以上的酒不能寄?",
|
| 468 |
-
"a": "
|
| 469 |
},
|
| 470 |
{
|
| 471 |
"q": "42 度的酒和 78 度的酒都能寄吗?",
|
|
@@ -506,5 +506,161 @@
|
|
| 506 |
{
|
| 507 |
"q": "价值 1500 元的物品不保价行吗?",
|
| 508 |
"a": "不行。单件价值超过 1000 元必须保价,1500 元已超门槛;保价费也只要 7.5 元。"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
}
|
| 510 |
]
|
|
|
|
| 465 |
},
|
| 466 |
{
|
| 467 |
"q": "多少度以上的酒不能寄?",
|
| 468 |
+
"a": "超过 70 度的酒精饮品禁寄;24–70 度(含 70 度)限每件 5L 且只能陆运;24 度以下不限。判断方法:把酒精度和 70 比,不超过 70 就能按限寄规则寄。"
|
| 469 |
},
|
| 470 |
{
|
| 471 |
"q": "42 度的酒和 78 度的酒都能寄吗?",
|
|
|
|
| 506 |
{
|
| 507 |
"q": "价值 1500 元的物品不保价行吗?",
|
| 508 |
"a": "不行。单件价值超过 1000 元必须保价,1500 元已超门槛;保价费也只要 7.5 元。"
|
| 509 |
+
},
|
| 510 |
+
{
|
| 511 |
+
"q": "里程正好落在分档边界上,时效按哪一档?",
|
| 512 |
+
"a": "边界值归较低的那一档:≤500km 是 2 天,所以 500km 整算 2 天;1500km 整算 3 天(500–1500km 档);2500km 整算 4 天(1500–2500km 档);只有超过 2500km 才是 5 天。计费区域同理:跨省公路里程 ≤800km 是邻近,800km 整仍算邻近,801km 起才是跨区。"
|
| 513 |
+
},
|
| 514 |
+
{
|
| 515 |
+
"q": "两地相距 800 公里整,跨省寄件按邻近还是跨区计费?",
|
| 516 |
+
"a": "按邻近。邻近的定义是跨省且公路里程 ≤ 800km,800km 整没有超过,首重 10 元、续重 4 元/kg;超过 800km(如 801km)才按跨区(首重 12 元、续重 6 元/kg)。"
|
| 517 |
+
},
|
| 518 |
+
{
|
| 519 |
+
"q": "820 公里算邻近吗?",
|
| 520 |
+
"a": "不算,是跨区。邻近要求公路里程 ≤ 800km,820km 已超过,按跨区计费:首重 12 元、续重 6 元/kg。"
|
| 521 |
+
},
|
| 522 |
+
{
|
| 523 |
+
"q": "1500 公里整标准快递几天?",
|
| 524 |
+
"a": "3 天。1500km 落在 500–1500km 档(边界含在本档),超过 1500km 才进入 4 天档。特快 2 天,经济 5 天。"
|
| 525 |
+
},
|
| 526 |
+
{
|
| 527 |
+
"q": "2500 公里的线路,标准快递要几天?",
|
| 528 |
+
"a": "4 天。2500km 属于 1500–2500km 档(边界值含在本档);超过 2500km 才是 5 天。若目的地在偏远六省区再加 2 天。"
|
| 529 |
+
},
|
| 530 |
+
{
|
| 531 |
+
"q": "2600 公里到新疆要几天?",
|
| 532 |
+
"a": "7 天。2600km 超过 2500km 是 5 天档,新疆属于偏远六省区再 +2 天,标准快递共 7 天;特快 6 天。"
|
| 533 |
+
},
|
| 534 |
+
{
|
| 535 |
+
"q": "1200 公里几天能到?",
|
| 536 |
+
"a": "3 天。1200km 在 500–1500km 档,标准快递 3 天;特快 2 天,经济 5 天。"
|
| 537 |
+
},
|
| 538 |
+
{
|
| 539 |
+
"q": "1800 公里标准快递几天?",
|
| 540 |
+
"a": "4 天。1800km 在 1500–2500km 档;1500km 以内才是 3 天,超过 2500km 才是 5 天。"
|
| 541 |
+
},
|
| 542 |
+
{
|
| 543 |
+
"q": "1650 公里大概要几天?",
|
| 544 |
+
"a": "4 天。1650km 已超过 1500km,进入 1500–2500km 档,标准快递 4 天(不是 3 天);特快 3 天。"
|
| 545 |
+
},
|
| 546 |
+
{
|
| 547 |
+
"q": "70 度整的白酒算不算超标?",
|
| 548 |
+
"a": "不算,可以寄。限寄区间是 24%–70%(含 70%),70 度正好在区间内:每件不超过 5L、只能陆运、需防碎包装。超过 70 度(如 71 度)才禁寄。"
|
| 549 |
+
},
|
| 550 |
+
{
|
| 551 |
+
"q": "71 度的酒能寄吗?",
|
| 552 |
+
"a": "不能。禁寄线是超过 70%,71 度已超过,属于高浓度易燃液体,禁寄。"
|
| 553 |
+
},
|
| 554 |
+
{
|
| 555 |
+
"q": "酒精度刚好 24 度的酒受限制吗?",
|
| 556 |
+
"a": "不受限。≤24% 的酒类不限量,24 度整属于不限区间;超过 24% 才进入每件 ≤5L、仅陆运的限寄区间。"
|
| 557 |
+
},
|
| 558 |
+
{
|
| 559 |
+
"q": "正好 100Wh 的充电宝可以寄吗?",
|
| 560 |
+
"a": "可以。锂电池上限是单块 ≤100Wh,100Wh 整没有超过,标识清晰、无鼓包即可陆运;超过 100Wh(如 101Wh)就不能寄。"
|
| 561 |
+
},
|
| 562 |
+
{
|
| 563 |
+
"q": "101Wh 的移动电源能寄吗?",
|
| 564 |
+
"a": "不能。单块锂电池限值是 100Wh,101Wh 已超过,不可寄递。"
|
| 565 |
+
},
|
| 566 |
+
{
|
| 567 |
+
"q": "价值 1000 元整的物品必须保价吗?",
|
| 568 |
+
"a": "不强制。必须保价的门槛是单件价值超过 1000 元,1000 元整未超过;但强烈建议保价,只需 5 元,不保价丢失最多只赔 300 元。"
|
| 569 |
+
},
|
| 570 |
+
{
|
| 571 |
+
"q": "1001 元的东西可以不保价吗?",
|
| 572 |
+
"a": "不可以。单件价值超过 1000 元必须保价,1001 元已超门槛,保价费约 5 元。"
|
| 573 |
+
},
|
| 574 |
+
{
|
| 575 |
+
"q": "999 元的物品要不要保价?",
|
| 576 |
+
"a": "不强制(门槛是超过 1000 元),但建议保价:保价费约 5 元(999×0.5%),不保价丢失最多赔运费 3 倍且不超过 300 元。"
|
| 577 |
+
},
|
| 578 |
+
{
|
| 579 |
+
"q": "单件正好 50 公斤要收超重操作费吗?",
|
| 580 |
+
"a": "不用。超重操作费的条件是单件超过 50kg,50kg 整不加收;超过 50kg(如 51kg)加收 30 元。"
|
| 581 |
+
},
|
| 582 |
+
{
|
| 583 |
+
"q": "包裹最长边 150cm 算超长吗?",
|
| 584 |
+
"a": "不算。超长的定义是单边超过 150cm,150cm 整不加收;151cm 起加收 30 元操作费。"
|
| 585 |
+
},
|
| 586 |
+
{
|
| 587 |
+
"q": "最长边 160cm 的包裹怎么收费?",
|
| 588 |
+
"a": "在正常运费基础上加收 30 元超长超重操作费(单边 >150cm)。"
|
| 589 |
+
},
|
| 590 |
+
{
|
| 591 |
+
"q": "各类投诉的响应和解决时限分别是多少?",
|
| 592 |
+
"a": "延误:2 小时响应、24 小时解决;破损/丢失:2 小时响应、7 个工作日解决;服务态度:4 小时响应、48 小时解决;虚假签收:1 小时响应、24 小时解决;乱收费:4 小时响应、48 小时解决。"
|
| 593 |
+
},
|
| 594 |
+
{
|
| 595 |
+
"q": "投诉快递员多收费,多长时间能处理完?",
|
| 596 |
+
"a": "乱收费投诉 4 小时内响应、48 小时内解决,多收部分会退还。"
|
| 597 |
+
},
|
| 598 |
+
{
|
| 599 |
+
"q": "虚假签收投诉多久有人响应?",
|
| 600 |
+
"a": "1 小时内响应,24 小时内解决。虚假签收是快递员未送达就点了签收,属严重违规,是所有投诉类型里响应要求最快的。"
|
| 601 |
+
},
|
| 602 |
+
{
|
| 603 |
+
"q": "被虚假签收了,投诉后多快处理?",
|
| 604 |
+
"a": "虚假签收投诉的响应时效是 1 小时,解决时效 24 小时;先确认是否代签或放驿站/快递柜,确认未收到即按此流程处理。"
|
| 605 |
+
},
|
| 606 |
+
{
|
| 607 |
+
"q": "投诉快递员态度差多久能有结果?",
|
| 608 |
+
"a": "服务态度投诉 4 小时内响应、48 小时内解决。"
|
| 609 |
+
},
|
| 610 |
+
{
|
| 611 |
+
"q": "快递延误投诉多久解决?",
|
| 612 |
+
"a": "延误投诉 2 小时内响应、24 小时内解决;如果是特快超承诺时效 1 天以上,还可退还特快与标准快递的差价。"
|
| 613 |
+
},
|
| 614 |
+
{
|
| 615 |
+
"q": "破损投诉一般多久能处理完?",
|
| 616 |
+
"a": "破损/丢失投诉 2 小时内响应、7 个工作日内解决;理赔审核 3–7 个工作日,赔款 15 个工作日内到账。"
|
| 617 |
+
},
|
| 618 |
+
{
|
| 619 |
+
"q": "快递还没派送,改收件地址要钱吗?",
|
| 620 |
+
"a": "看是否同城:派送前联系客服改同城地址免费;改到跨城地址需要补运费差价,时效也会重新计算。"
|
| 621 |
+
},
|
| 622 |
+
{
|
| 623 |
+
"q": "我想把包裹改寄到另一个城市,怎么收费?",
|
| 624 |
+
"a": "跨城改址需补运费差价并重新计算时效;如果只是同城换地址,派送前修改是免费的。"
|
| 625 |
+
},
|
| 626 |
+
{
|
| 627 |
+
"q": "到付的快递收件人不付钱怎么办?",
|
| 628 |
+
"a": "收件人拒付时包裹退回寄件人,往返运费由寄件人承担。建议到付前和收件人确认好。"
|
| 629 |
+
},
|
| 630 |
+
{
|
| 631 |
+
"q": "可以寄到国外吗?运费怎么算?",
|
| 632 |
+
"a": "本系统的首重/续重计费只覆盖国内已开通城市,国际件不能用这套规则算,也不要套用国内价格。国际业务按方式选:国际快递(DHL/FedEx/UPS)3–7 天但贵,适合文件样品;空运 5–10 天适合批量货;海运 20–45 天最便宜适合大宗;跨境电商小包适合轻小件。"
|
| 633 |
+
},
|
| 634 |
+
{
|
| 635 |
+
"q": "40 尺高柜能装多少立方?",
|
| 636 |
+
"a": "40HQ 约 76m³;对比 40GP 约 67m³,20GP 约 33m³(载重约 21t)。"
|
| 637 |
+
},
|
| 638 |
+
{
|
| 639 |
+
"q": "仓库库容利用率保持在多少合适?",
|
| 640 |
+
"a": "75–85% 是健康区间;超过 90% 会严重拖慢作业,过低则浪费成本。"
|
| 641 |
+
},
|
| 642 |
+
{
|
| 643 |
+
"q": "拣货准确率要达到多少?",
|
| 644 |
+
"a": "≥ 99.9%。相关指标还有订单准时出库率 ≥ 99%、库存准确率 ≥ 99.5%。"
|
| 645 |
+
},
|
| 646 |
+
{
|
| 647 |
+
"q": "冷冻货运输要求多少度?",
|
| 648 |
+
"a": "冷冻 ≤ −18℃,冷藏 0–8℃,全程温控记录且开门次数最少;签收时温度不达标可拒签。"
|
| 649 |
+
},
|
| 650 |
+
{
|
| 651 |
+
"q": "13.5 米半挂能装多少吨?",
|
| 652 |
+
"a": "25–30 吨,容积 90–130m³(17.5 米半挂同档),用于长途干线。"
|
| 653 |
+
},
|
| 654 |
+
{
|
| 655 |
+
"q": "司机连续开车不能超过多长时间?",
|
| 656 |
+
"a": "连续驾驶不超过 4 小时,每次休息 ≥ 20 分钟,24 小时累计驾驶不超过 8 小时。"
|
| 657 |
+
},
|
| 658 |
+
{
|
| 659 |
+
"q": "木箱包装出口需要什么处理?",
|
| 660 |
+
"a": "木质包装出口须熏蒸并加盖 IPPC 标识,否则目的国可能拒绝入境。"
|
| 661 |
+
},
|
| 662 |
+
{
|
| 663 |
+
"q": "没说要什么服务类型,运费按哪种算?",
|
| 664 |
+
"a": "按默认的标准快递(系数 ×1.0)计算,并向客户说明一句“未指明服务类型,按标准快递计算,如需特快/经济可再算”;不要自行猜成特快或经济。"
|
| 665 |
}
|
| 666 |
]
|
eval/cases.jsonl
CHANGED
|
@@ -18,3 +18,38 @@
|
|
| 18 |
{"cat":"调度","q":"从武汉出发给长沙、郑州、合肥、南昌送货,怎么排顺序最省路","expect_tool":"optimize_delivery_order","keywords":["武汉"]}
|
| 19 |
{"cat":"调度","q":"一辆 9.6 米厢货能装多重","keywords":["10"]}
|
| 20 |
{"cat":"调度","q":"司机连续驾驶不能超过几小时","keywords":["4"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
{"cat":"调度","q":"从武汉出发给长沙、郑州、合肥、南昌送货,怎么排顺序最省路","expect_tool":"optimize_delivery_order","keywords":["武汉"]}
|
| 19 |
{"cat":"调度","q":"一辆 9.6 米厢货能装多重","keywords":["10"]}
|
| 20 |
{"cat":"调度","q":"司机连续驾驶不能超过几小时","keywords":["4"]}
|
| 21 |
+
{"cat":"客服","q":"我想查下单号LL2026080091","expect_tool":"query_waybill","keywords":["LL2026080091"]}
|
| 22 |
+
{"cat":"客服","q":"帮我查 LL2028999999","expect_tool":"query_waybill","keywords_any":["未找到","没有找到","没查到","查不到"]}
|
| 23 |
+
{"cat":"客服","q":"我手机号 13826600539,名下有什么快递","expect_tool":"search_waybills","keywords":["LL2026080091"]}
|
| 24 |
+
{"cat":"客服","q":"用 13000000000 查一下我的运单","expect_tool":"search_waybills","keywords_any":["没有","未找到","没查到","0 个"]}
|
| 25 |
+
{"cat":"客服","q":"从上海寄5公斤东西到北京要多少钱","expect_tool":"calc_freight","keywords":["36","标准快递"],"forbid":["特快","经济型"]}
|
| 26 |
+
{"cat":"客服","q":"从杭州寄一台60公斤的机器到南京,运费多少","expect_tool":"calc_freight","keywords":["276"]}
|
| 27 |
+
{"cat":"客服","q":"深圳寄10公斤到成都,多少钱几天能到","expect_tools":["calc_freight","estimate_eta"],"keywords":["66","4 天"]}
|
| 28 |
+
{"cat":"客服","turns":["我的快递收到的时候摔坏了,怎么办","运单号是 LL2026080258,帮我登记投诉"],"expect_tool":"report_exception","keywords":["TK"]}
|
| 29 |
+
{"cat":"客服","turns":["帮我查个运单","LL2026080315"],"expect_tool":"query_waybill","keywords":["LL2026080315"]}
|
| 30 |
+
{"cat":"客服","turns":["从北京寄2公斤文件到上海多少钱?","改成特快呢?"],"expect_tool":"calc_freight","keywords":["28.8"]}
|
| 31 |
+
{"cat":"客服","q":"包裹还没派送,我想改一下同城的收货地址,收费吗","expect_no_tool":true,"keywords":["免费"]}
|
| 32 |
+
{"cat":"客服","q":"到付件收件人不肯付运费怎么办","keywords":["寄件人"]}
|
| 33 |
+
{"cat":"知识","q":"1600 公里大概几天到","keywords":["4 天"],"forbid":["5 天","3 天"]}
|
| 34 |
+
{"cat":"知识","q":"公路里程正好 800 公里,算邻近还是跨区","keywords":["邻近"]}
|
| 35 |
+
{"cat":"知识","q":"2500 公里整,标准快递几天","keywords":["4 天"]}
|
| 36 |
+
{"cat":"知识","q":"正好 70 度的酒能寄吗","keywords":["5L"],"forbid":["禁寄,"]}
|
| 37 |
+
{"cat":"知识","q":"100Wh 的充电宝能寄吗","keywords":["陆运"]}
|
| 38 |
+
{"cat":"知识","q":"101Wh 的充电宝呢","keywords_any":["不能","不可以","超过","超出"]}
|
| 39 |
+
{"cat":"知识","q":"价值 999 元的东西必须保价吗","keywords":["1000"]}
|
| 40 |
+
{"cat":"知识","q":"投诉乱收费多久能解决","keywords":["48"]}
|
| 41 |
+
{"cat":"知识","q":"虚假签收的投诉多久响应","keywords":["1 小时"]}
|
| 42 |
+
{"cat":"知识","q":"40 高柜能装多少方","keywords":["76"]}
|
| 43 |
+
{"cat":"知识","q":"跨境电商零售进口单次免税额度是多少","keywords":["5000"]}
|
| 44 |
+
{"cat":"知识","q":"木箱出口有什么要求","keywords_any":["熏蒸","IPPC"]}
|
| 45 |
+
{"cat":"知识","q":"仓库库容利用率多少比较健康","keywords_any":["75","85"]}
|
| 46 |
+
{"cat":"知识","q":"拣货准确率的目标是多少","keywords":["99.9"]}
|
| 47 |
+
{"cat":"知识","q":"冷冻件运输温度要求","keywords_any":["-18","−18","18"]}
|
| 48 |
+
{"cat":"知识","q":"从上海寄东西到纽约多少钱","keywords_any":["国际","未收录","无法","不支持","不在"],"forbid":["总运费","合计"]}
|
| 49 |
+
{"cat":"抽取","q":"麻烦发这里:广西壮族自治区南宁市青秀区民族大道100号 李雷 13900001111","expect_json":{"name":"李雷","phone":"13900001111","province":"广西","city":"南宁","district":"青秀区"}}
|
| 50 |
+
{"cat":"抽取","q":"13512345678,重庆市渝中区解放碑步行街8号,收件人吴丹","expect_json":{"name":"吴丹","phone":"13512345678","city":"重庆","district":"渝中区"}}
|
| 51 |
+
{"cat":"抽取","q":"内蒙古自治区呼和浩特市新城区成吉思汗大街1号 马超 15811112222","expect_json":{"name":"马超","phone":"15811112222","province":"内蒙古","city":"呼和浩特","district":"新城区"}}
|
| 52 |
+
{"cat":"调度","q":"从成都出发给重庆、贵阳、昆明送货,怎么排最顺","expect_tool":"optimize_delivery_order","keywords":["成都"]}
|
| 53 |
+
{"cat":"调度","q":"杭州到西安的干线要经过哪些城市","expect_tool":"plan_route","keywords":["郑州"]}
|
| 54 |
+
{"cat":"调度","q":"从洛阳到北京的干线怎么走","expect_tool":"plan_route","keywords_any":["洛阳","未收录","不在","没有收录"]}
|
| 55 |
+
{"cat":"调度","q":"13.5 米半挂能拉多少吨","keywords_any":["25","30"]}
|
logicllm/agent/prompts.py
CHANGED
|
@@ -10,6 +10,9 @@ SYSTEM_BASE = """你是「物流通」,一个专业的物流行业智能助手
|
|
| 10 |
- 信息抽取任务输出 JSON,字段缺失填空字符串。
|
| 11 |
- 不确定的内容明确说明不确定,不要杜撰法规条文。"""
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
RAG_BLOCK = """
|
| 14 |
|
| 15 |
## 参考资料(来自公司知识库,优先依据此内容回答)
|
|
@@ -17,7 +20,11 @@ RAG_BLOCK = """
|
|
| 17 |
|
| 18 |
|
| 19 |
def build_system(context: str | None = None) -> str:
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def openai_tools(specs: list[dict]) -> list[dict]:
|
|
|
|
| 10 |
- 信息抽取任务输出 JSON,字段缺失填空字符串。
|
| 11 |
- 不确定的内容明确说明不确定,不要杜撰法规条文。"""
|
| 12 |
|
| 13 |
+
TMS_NOTE = """
|
| 14 |
+
- 当前对接的是零担 TMS 真实系统:运费一律以 calc_freight 返回的 total_fee 为准直接引用,禁止用首重/续重公式自行计算(那套规则只适用于快递小件示例);运单号形如 W 开头。"""
|
| 15 |
+
|
| 16 |
RAG_BLOCK = """
|
| 17 |
|
| 18 |
## 参考资料(来自公司知识库,优先依据此内容回答)
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def build_system(context: str | None = None) -> str:
|
| 23 |
+
from logicllm import config
|
| 24 |
+
s = SYSTEM_BASE
|
| 25 |
+
if config.TOOLS_BACKEND == "tms":
|
| 26 |
+
s += TMS_NOTE
|
| 27 |
+
return s + (RAG_BLOCK.format(context=context) if context else "")
|
| 28 |
|
| 29 |
|
| 30 |
def openai_tools(specs: list[dict]) -> list[dict]:
|
logicllm/config.py
CHANGED
|
@@ -21,7 +21,7 @@ LLM_MODEL = os.getenv(
|
|
| 21 |
LLM_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0.1"))
|
| 22 |
LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "1024"))
|
| 23 |
|
| 24 |
-
BASE_MODEL = os.getenv("BASE_MODEL", "mlx-community/
|
| 25 |
# mlx 后端:请求里显式带 adapters 路径(mlx_lm server 用 default_model 时不会应用 --adapter-path;
|
| 26 |
# 且 fuse 进 4bit 基座会让 LoRA 失效,所以不合并、直接挂 adapter)
|
| 27 |
_ADAPTER = ROOT / "adapters" / "logistics-lora"
|
|
@@ -56,6 +56,8 @@ MODEL_SOURCE = os.getenv("MODEL_SOURCE", "modelscope") # modelscope | hf:非
|
|
| 56 |
RAG_TOP_K = int(os.getenv("RAG_TOP_K", "4"))
|
| 57 |
RAG_MIN_SCORE = float(os.getenv("RAG_MIN_SCORE", "0.35"))
|
| 58 |
MAX_TOOL_ROUNDS = int(os.getenv("MAX_TOOL_ROUNDS", "5"))
|
|
|
|
|
|
|
| 59 |
LLM_THINKING = os.getenv("LLM_THINKING", "0") == "1" # Qwen3 系混合推理模型的思考模式,业务场景默认关
|
| 60 |
|
| 61 |
SERVER_HOST = os.getenv("SERVER_HOST", "127.0.0.1")
|
|
|
|
| 21 |
LLM_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0.1"))
|
| 22 |
LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "1024"))
|
| 23 |
|
| 24 |
+
BASE_MODEL = os.getenv("BASE_MODEL", "mlx-community/Qwen3-14B-4bit")
|
| 25 |
# mlx 后端:请求里显式带 adapters 路径(mlx_lm server 用 default_model 时不会应用 --adapter-path;
|
| 26 |
# 且 fuse 进 4bit 基座会让 LoRA 失效,所以不合并、直接挂 adapter)
|
| 27 |
_ADAPTER = ROOT / "adapters" / "logistics-lora"
|
|
|
|
| 56 |
RAG_TOP_K = int(os.getenv("RAG_TOP_K", "4"))
|
| 57 |
RAG_MIN_SCORE = float(os.getenv("RAG_MIN_SCORE", "0.35"))
|
| 58 |
MAX_TOOL_ROUNDS = int(os.getenv("MAX_TOOL_ROUNDS", "5"))
|
| 59 |
+
# 工具后端:mock(内置 SQLite 演示库)或 tms(对接本地零担通 TMS 真实接口)
|
| 60 |
+
TOOLS_BACKEND = os.getenv("TOOLS_BACKEND", "mock")
|
| 61 |
LLM_THINKING = os.getenv("LLM_THINKING", "0") == "1" # Qwen3 系混合推理模型的思考模式,业务场景默认关
|
| 62 |
|
| 63 |
SERVER_HOST = os.getenv("SERVER_HOST", "127.0.0.1")
|
logicllm/tools/__init__.py
CHANGED
|
@@ -48,3 +48,12 @@ def all_specs() -> list[dict]:
|
|
| 48 |
|
| 49 |
# 导入即注册
|
| 50 |
from logicllm.tools import waybill, pricing, eta, route, address # noqa: E402,F401
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
# 导入即注册
|
| 50 |
from logicllm.tools import waybill, pricing, eta, route, address # noqa: E402,F401
|
| 51 |
+
|
| 52 |
+
# TOOLS_BACKEND=tms 时用真实 TMS 接口覆盖同名工具(query/track/search/report/calc_freight),
|
| 53 |
+
# 并移除仅适用于演示库的工具。
|
| 54 |
+
from logicllm import config as _config # noqa: E402
|
| 55 |
+
|
| 56 |
+
if _config.TOOLS_BACKEND == "tms":
|
| 57 |
+
from logicllm.tools import tms # noqa: E402,F401
|
| 58 |
+
REGISTRY.pop("create_waybill", None)
|
| 59 |
+
REGISTRY.pop("estimate_eta", None) # 演示时效表按城市对,不适用网点体系
|
logicllm/tools/geo.py
CHANGED
|
@@ -13,6 +13,43 @@ CITY_PROVINCE = {
|
|
| 13 |
"呼和浩特": "内蒙古", "银川": "宁夏", "南宁": "广西", "海口": "海南", "太原": "山西",
|
| 14 |
}
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
REMOTE_PROVINCES = {"新疆", "西藏", "青海", "内蒙古", "甘肃", "宁夏"}
|
| 17 |
|
| 18 |
# 无向图:主要干线参考里程
|
|
|
|
| 13 |
"呼和浩特": "内蒙古", "银川": "宁夏", "南宁": "广西", "海口": "海南", "太原": "山西",
|
| 14 |
}
|
| 15 |
|
| 16 |
+
# 各城市真实市辖区(抽样用,每市取常见 2–3 个;县级市用街道)
|
| 17 |
+
CITY_DISTRICTS = {
|
| 18 |
+
"北京": ["朝阳区", "海淀区", "东城区"], "上海": ["浦东新区", "徐汇区", "静安区"],
|
| 19 |
+
"天津": ["和平区", "南开区", "河西区"], "重庆": ["渝中区", "江北区", "沙坪坝区"],
|
| 20 |
+
"广州": ["天河区", "越秀区", "白云区"], "深圳": ["南山区", "福田区", "宝安区"],
|
| 21 |
+
"东莞": ["南城街道", "东城街道"], "佛山": ["禅城区", "南海区", "顺德区"],
|
| 22 |
+
"杭州": ["西湖区", "拱墅区", "滨江区"], "宁波": ["鄞州区", "海曙区", "江北区"],
|
| 23 |
+
"义乌": ["稠城街道", "北苑街道"], "温州": ["鹿城区", "瓯海区", "龙湾区"],
|
| 24 |
+
"南京": ["鼓楼区", "玄武区", "秦淮区"], "苏州": ["姑苏区", "吴中区", "相城区"],
|
| 25 |
+
"无锡": ["梁溪区", "滨湖区", "锡山区"], "武汉": ["江汉区", "武昌区", "洪山区"],
|
| 26 |
+
"长沙": ["岳麓区", "芙蓉区", "天心区"], "郑州": ["金水区", "二七区", "中原区"],
|
| 27 |
+
"西安": ["雁塔区", "碑林区", "未央区"], "成都": ["武侯区", "锦江区", "金牛区"],
|
| 28 |
+
"昆明": ["五华区", "盘龙区", "官渡区"], "贵阳": ["云岩区", "南明区", "观山湖区"],
|
| 29 |
+
"济南": ["历下区", "市中区", "槐荫区"], "青岛": ["市南区", "崂山区", "城阳区"],
|
| 30 |
+
"石家庄": ["长安区", "桥西区", "裕华区"], "沈阳": ["和平区", "沈河区", "铁西区"],
|
| 31 |
+
"大连": ["中山区", "西岗区", "甘井子区"], "哈尔滨": ["道里区", "南岗区", "香坊区"],
|
| 32 |
+
"长春": ["朝阳区", "南关区", "宽城区"], "福州": ["鼓楼区", "台江区", "仓山区"],
|
| 33 |
+
"厦门": ["思明区", "湖里区", "集美区"], "南昌": ["东湖区", "西湖区", "红谷滩区"],
|
| 34 |
+
"合肥": ["蜀山区", "庐阳区", "包河区"], "乌鲁木齐": ["天山区", "沙依巴克区", "新市区"],
|
| 35 |
+
"拉萨": ["城关区", "堆龙德庆区"], "兰州": ["城关区", "七里河区", "安宁区"],
|
| 36 |
+
"西宁": ["城中区", "城东区", "城西区"], "呼和浩特": ["新城区", "回民区", "赛罕区"],
|
| 37 |
+
"银川": ["兴庆区", "金凤区", "西夏区"], "南宁": ["青秀区", "兴宁区", "江南区"],
|
| 38 |
+
"海口": ["龙华区", "美兰区", "秀英区"], "太原": ["迎泽区", "小店区", "杏花岭区"],
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
# 省级行政区全称后缀(避免生成「宁夏省」「内蒙古省」)
|
| 42 |
+
PROVINCE_FULL = {
|
| 43 |
+
"北京": "北京市", "上海": "上海市", "天津": "天津市", "重庆": "重庆市",
|
| 44 |
+
"内蒙古": "内蒙古自治区", "广西": "广西壮族自治区", "西藏": "西藏自治区",
|
| 45 |
+
"宁夏": "宁夏回族自治区", "新疆": "新疆维吾尔自治区",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def province_full(p: str) -> str:
|
| 50 |
+
return PROVINCE_FULL.get(p, p + "省")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
REMOTE_PROVINCES = {"新疆", "西藏", "青海", "内蒙古", "甘肃", "宁夏"}
|
| 54 |
|
| 55 |
# 无向图:主要干线参考里程
|
logicllm/tools/pricing.py
CHANGED
|
@@ -9,9 +9,7 @@ SERVICE_MULT = {"标准快递": 1.0, "特快": 1.6, "经济": 0.8, "零担": 0.5
|
|
| 9 |
|
| 10 |
|
| 11 |
def _zone(origin: str, dest: str) -> str:
|
| 12 |
-
po, pd = CITY_PROVINCE
|
| 13 |
-
if po is None or pd is None:
|
| 14 |
-
return "跨区"
|
| 15 |
if po == pd:
|
| 16 |
return "同省"
|
| 17 |
d = distance_km(origin, dest) or 1500
|
|
@@ -39,6 +37,9 @@ def calc_freight(origin: str, destination: str, weight_kg: float, length_cm: flo
|
|
| 39 |
declared_value: float = 0):
|
| 40 |
if service not in SERVICE_MULT:
|
| 41 |
return {"error": f"不支持的服务类型 {service}", "supported": list(SERVICE_MULT)}
|
|
|
|
|
|
|
|
|
|
| 42 |
vol_w = (length_cm * width_cm * height_cm) / VOLUME_DIVISOR if length_cm and width_cm and height_cm else 0
|
| 43 |
bill_w = max(weight_kg, vol_w)
|
| 44 |
import math
|
|
@@ -47,12 +48,16 @@ def calc_freight(origin: str, destination: str, weight_kg: float, length_cm: flo
|
|
| 47 |
base = FIRST_KG[zone] + max(0, bill_w - 1) * ADD_KG[zone]
|
| 48 |
base *= SERVICE_MULT[service]
|
| 49 |
remote = 10.0 if CITY_PROVINCE.get(destination) in REMOTE_PROVINCES else 0.0
|
|
|
|
| 50 |
insurance = max(1.0, declared_value * 0.005) if declared_value else 0.0
|
| 51 |
-
total = round(base + remote + insurance, 2)
|
| 52 |
return {
|
| 53 |
"billable_weight_kg": bill_w, "volume_weight_kg": round(vol_w, 2), "zone": zone,
|
| 54 |
"service": service, "base_fee": round(base, 2), "remote_surcharge": remote,
|
|
|
|
| 55 |
"insurance_fee": round(insurance, 2), "total_fee": total,
|
| 56 |
"breakdown": f"首重{FIRST_KG[zone]}元 + 续重{max(0, bill_w-1)}kg×{ADD_KG[zone]}元,×{SERVICE_MULT[service]}({service})"
|
| 57 |
-
+ (f" + 偏远附加{remote}元" if remote else "")
|
|
|
|
|
|
|
| 58 |
}
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
def _zone(origin: str, dest: str) -> str:
|
| 12 |
+
po, pd = CITY_PROVINCE[origin], CITY_PROVINCE[dest]
|
|
|
|
|
|
|
| 13 |
if po == pd:
|
| 14 |
return "同省"
|
| 15 |
d = distance_km(origin, dest) or 1500
|
|
|
|
| 37 |
declared_value: float = 0):
|
| 38 |
if service not in SERVICE_MULT:
|
| 39 |
return {"error": f"不支持的服务类型 {service}", "supported": list(SERVICE_MULT)}
|
| 40 |
+
unknown = [c for c in (origin, destination) if c not in CITY_PROVINCE]
|
| 41 |
+
if unknown:
|
| 42 |
+
return {"error": f"未收录城市: {'、'.join(unknown)}(仅支持国内已开通城市)", "supported": sorted(CITY_PROVINCE)}
|
| 43 |
vol_w = (length_cm * width_cm * height_cm) / VOLUME_DIVISOR if length_cm and width_cm and height_cm else 0
|
| 44 |
bill_w = max(weight_kg, vol_w)
|
| 45 |
import math
|
|
|
|
| 48 |
base = FIRST_KG[zone] + max(0, bill_w - 1) * ADD_KG[zone]
|
| 49 |
base *= SERVICE_MULT[service]
|
| 50 |
remote = 10.0 if CITY_PROVINCE.get(destination) in REMOTE_PROVINCES else 0.0
|
| 51 |
+
oversize = 30.0 if (weight_kg > 50 or max(length_cm, width_cm, height_cm) > 150) else 0.0
|
| 52 |
insurance = max(1.0, declared_value * 0.005) if declared_value else 0.0
|
| 53 |
+
total = round(base + remote + oversize + insurance, 2)
|
| 54 |
return {
|
| 55 |
"billable_weight_kg": bill_w, "volume_weight_kg": round(vol_w, 2), "zone": zone,
|
| 56 |
"service": service, "base_fee": round(base, 2), "remote_surcharge": remote,
|
| 57 |
+
"oversize_surcharge": oversize,
|
| 58 |
"insurance_fee": round(insurance, 2), "total_fee": total,
|
| 59 |
"breakdown": f"首重{FIRST_KG[zone]}元 + 续重{max(0, bill_w-1)}kg×{ADD_KG[zone]}元,×{SERVICE_MULT[service]}({service})"
|
| 60 |
+
+ (f" + 偏远附加{remote}元" if remote else "")
|
| 61 |
+
+ (f" + 超长超重操作费{oversize}元" if oversize else "")
|
| 62 |
+
+ (f" + 保价费{insurance:.2f}元" if insurance else ""),
|
| 63 |
}
|
logicllm/tools/tms.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""零担通 TMS 真实后端工具(TOOLS_BACKEND=tms 时启用,覆盖同名模拟工具)。
|
| 2 |
+
|
| 3 |
+
对接本地「零担通 LTL GO Backend」(FastAPI,默认 http://127.0.0.1:8000):
|
| 4 |
+
- query_waybill / track_waybill / search_waybills / report_exception / waybill_status_counts
|
| 5 |
+
- calc_freight → /pricing/trial-quote(真实报价引擎,按网点对计价)
|
| 6 |
+
|
| 7 |
+
鉴权:手机号+密码登录 → 选租户 → Bearer token;401 时自动重登。
|
| 8 |
+
环境变量:TMS_BASE_URL、TMS_PHONE、TMS_PASSWORD、TMS_TENANT(租户名)。
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import threading
|
| 14 |
+
import uuid
|
| 15 |
+
|
| 16 |
+
import httpx
|
| 17 |
+
|
| 18 |
+
from logicllm.tools import tool
|
| 19 |
+
|
| 20 |
+
BASE = os.getenv("TMS_BASE_URL", "http://127.0.0.1:8000/api/v1").rstrip("/")
|
| 21 |
+
PHONE = os.getenv("TMS_PHONE", "13800000001")
|
| 22 |
+
PASSWORD = os.getenv("TMS_PASSWORD", "Dev@12345")
|
| 23 |
+
TENANT = os.getenv("TMS_TENANT", "Dev Tenant")
|
| 24 |
+
|
| 25 |
+
_lock = threading.Lock()
|
| 26 |
+
_token: str | None = None
|
| 27 |
+
|
| 28 |
+
STATUS_ZH = {
|
| 29 |
+
"draft": "草稿", "pending_confirmation": "待确认", "routed": "已排路由", "in_progress": "运输中",
|
| 30 |
+
"delivered": "已送达", "completed": "已完成", "archived": "已归档", "canceled": "已取消",
|
| 31 |
+
"rerouting": "改道中", "split": "已拆单", "returning": "退回中", "returned": "已退回",
|
| 32 |
+
}
|
| 33 |
+
EXC_CODE = {
|
| 34 |
+
"地址错误": "ADDRESS_ISSUE", "派送失败": "DELIVERY_FAILED", "拒收": "REFUSED", "破损": "DAMAGED",
|
| 35 |
+
"丢失": "LOST", "扣货": "DETAINED", "拦截": "INTERCEPTED", "揽收被拒": "PICKUP_REJECTED",
|
| 36 |
+
"超尺寸": "OVERSIZE", "超重": "OVERWEIGHT", "回单驳回": "POD_REVIEW_REJECTED",
|
| 37 |
+
"代收失败": "COD_COLLECTION_FAILED", "代收金额不符": "COD_AMOUNT_MISMATCH", "退货申请": "RETURN_REQUESTED",
|
| 38 |
+
"延误": "DELIVERY_FAILED", "其他": "DELIVERY_FAILED",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _login() -> str:
|
| 43 |
+
r = httpx.post(f"{BASE}/auth/login", json={"phone": PHONE, "password": PASSWORD}, timeout=15)
|
| 44 |
+
r.raise_for_status()
|
| 45 |
+
d = r.json()
|
| 46 |
+
if d.get("access_token"):
|
| 47 |
+
return d["access_token"]
|
| 48 |
+
comps = d.get("companies") or []
|
| 49 |
+
comp = next((c for c in comps if c["tenant_name"] == TENANT), comps[0] if comps else None)
|
| 50 |
+
if not comp:
|
| 51 |
+
raise RuntimeError("TMS 登录成功但无可用租户")
|
| 52 |
+
r = httpx.post(f"{BASE}/auth/select-tenant", json={"ticket": d["ticket"], "tenant_id": comp["tenant_id"]}, timeout=15)
|
| 53 |
+
r.raise_for_status()
|
| 54 |
+
return r.json()["access_token"]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _req(method: str, path: str, **kw) -> httpx.Response:
|
| 58 |
+
global _token
|
| 59 |
+
with _lock:
|
| 60 |
+
if _token is None:
|
| 61 |
+
_token = _login()
|
| 62 |
+
tok = _token
|
| 63 |
+
def _headers(t: str) -> dict:
|
| 64 |
+
h = {"Authorization": f"Bearer {t}"}
|
| 65 |
+
if method.upper() in ("POST", "PUT", "PATCH", "DELETE"):
|
| 66 |
+
h["X-Idempotency-Key"] = str(uuid.uuid4())
|
| 67 |
+
return h
|
| 68 |
+
|
| 69 |
+
r = httpx.request(method, f"{BASE}{path}", headers=_headers(tok), timeout=30, **kw)
|
| 70 |
+
if r.status_code == 401:
|
| 71 |
+
with _lock:
|
| 72 |
+
_token = _login()
|
| 73 |
+
tok = _token
|
| 74 |
+
r = httpx.request(method, f"{BASE}{path}", headers=_headers(tok), timeout=30, **kw)
|
| 75 |
+
return r
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _api(method: str, path: str, **kw) -> dict | list:
|
| 79 |
+
r = _req(method, path, **kw)
|
| 80 |
+
if r.status_code >= 400:
|
| 81 |
+
try:
|
| 82 |
+
err = r.json()
|
| 83 |
+
except ValueError:
|
| 84 |
+
err = r.text[:200]
|
| 85 |
+
return {"error": f"TMS 接口 {path} 返回 {r.status_code}", "detail": err}
|
| 86 |
+
return r.json() if r.text else {}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _items(res) -> tuple[list, int]:
|
| 90 |
+
"""兼容 {data, pagination} 与 {items, total} 两种列表响应。"""
|
| 91 |
+
if isinstance(res, list):
|
| 92 |
+
return res, len(res)
|
| 93 |
+
items = res.get("data") or res.get("items") or []
|
| 94 |
+
pg = res.get("pagination") or {}
|
| 95 |
+
total = pg.get("total") or res.get("total") or len(items)
|
| 96 |
+
return items, total
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _brief(w: dict) -> dict:
|
| 100 |
+
return {
|
| 101 |
+
"waybill_no": w.get("waybill_no"), "status": STATUS_ZH.get(w.get("status"), w.get("status")),
|
| 102 |
+
"origin_site": w.get("origin_site_name") or w.get("origin_site_id"),
|
| 103 |
+
"destination_site": w.get("destination_site_name") or w.get("destination_site_id"),
|
| 104 |
+
"customer": w.get("customer_name_snapshot"),
|
| 105 |
+
"receiver": (w.get("destination_address") or {}).get("contact"),
|
| 106 |
+
"items": w.get("items_summary"), "payment_type": w.get("payment_type"),
|
| 107 |
+
"receivable_amount": w.get("receivable_amount"), "created_at": w.get("created_at"),
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _find_waybill(waybill_no: str) -> dict | None:
|
| 112 |
+
res = _api("GET", "/waybills", params={"keyword": waybill_no.strip(), "page_size": 5})
|
| 113 |
+
if isinstance(res, dict) and res.get("error"):
|
| 114 |
+
return res
|
| 115 |
+
items, _ = _items(res)
|
| 116 |
+
exact = [w for w in items if w.get("waybill_no") == waybill_no.strip()]
|
| 117 |
+
return (exact or items or [None])[0]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@tool(
|
| 121 |
+
"query_waybill",
|
| 122 |
+
"按运单号查询 TMS 系统中的运单详情(起止网点、客户、货物、费用、当前状态)。",
|
| 123 |
+
{"type": "object", "properties": {"waybill_no": {"type": "string", "description": "运单号"}},
|
| 124 |
+
"required": ["waybill_no"]},
|
| 125 |
+
)
|
| 126 |
+
def query_waybill(waybill_no: str):
|
| 127 |
+
w = _find_waybill(waybill_no)
|
| 128 |
+
if not w:
|
| 129 |
+
return {"error": f"未找到运单 {waybill_no}"}
|
| 130 |
+
if w.get("error"):
|
| 131 |
+
return w
|
| 132 |
+
detail = _api("GET", f"/waybills/{w['id']}")
|
| 133 |
+
if isinstance(detail, dict) and not detail.get("error"):
|
| 134 |
+
w = {**w, **{k: v for k, v in detail.items() if v is not None}}
|
| 135 |
+
out = _brief(w)
|
| 136 |
+
out["is_cod"] = w.get("is_cod")
|
| 137 |
+
out["fee_breakdown"] = w.get("fee_breakdown")
|
| 138 |
+
return out
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@tool(
|
| 142 |
+
"track_waybill",
|
| 143 |
+
"查询运单的全程时间线/物流轨迹(TMS 系统真实事件)。",
|
| 144 |
+
{"type": "object", "properties": {"waybill_no": {"type": "string"}}, "required": ["waybill_no"]},
|
| 145 |
+
)
|
| 146 |
+
def track_waybill(waybill_no: str):
|
| 147 |
+
w = _find_waybill(waybill_no)
|
| 148 |
+
if not w or w.get("error"):
|
| 149 |
+
return w or {"error": f"未找到运单 {waybill_no}"}
|
| 150 |
+
tl = _api("GET", f"/waybills/{w['id']}/timeline")
|
| 151 |
+
if isinstance(tl, dict) and tl.get("error"):
|
| 152 |
+
return tl
|
| 153 |
+
events = tl if isinstance(tl, list) else tl.get("items") or tl.get("events") or []
|
| 154 |
+
slim = []
|
| 155 |
+
for e in events[-20:]:
|
| 156 |
+
slim.append({k: e.get(k) for k in ("event_at", "occurred_at", "created_at", "ulsc_code", "event_type", "title", "site_name", "notes", "description") if e.get(k) is not None})
|
| 157 |
+
return {"waybill_no": w.get("waybill_no"), "status": STATUS_ZH.get(w.get("status"), w.get("status")), "events": slim}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@tool(
|
| 161 |
+
"search_waybills",
|
| 162 |
+
"按关键词(客户名/电话/运单号片段)或状态搜索运单列表。",
|
| 163 |
+
{"type": "object", "properties": {
|
| 164 |
+
"keyword": {"type": "string", "description": "关键词,可选"},
|
| 165 |
+
"phone": {"type": "string", "description": "寄/收件人手机号,可选"},
|
| 166 |
+
"name": {"type": "string", "description": "寄/收件人或客户姓名,可选"},
|
| 167 |
+
"status": {"type": "string", "enum": list(STATUS_ZH.values()), "description": "运单状态,可选"},
|
| 168 |
+
}},
|
| 169 |
+
)
|
| 170 |
+
def search_waybills(keyword: str = "", status: str = "", phone: str = "", name: str = ""):
|
| 171 |
+
keyword = keyword or phone or name # 兼容微调数据里的 phone/name 参数习惯
|
| 172 |
+
params: dict = {"page_size": 10}
|
| 173 |
+
if keyword:
|
| 174 |
+
params["keyword"] = keyword
|
| 175 |
+
if status:
|
| 176 |
+
rev = {v: k for k, v in STATUS_ZH.items()}
|
| 177 |
+
params["status"] = rev.get(status, status)
|
| 178 |
+
res = _api("GET", "/waybills", params=params)
|
| 179 |
+
if isinstance(res, dict) and res.get("error"):
|
| 180 |
+
return res
|
| 181 |
+
items, total = _items(res)
|
| 182 |
+
return {"total": total, "waybills": [_brief(w) for w in items]}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@tool(
|
| 186 |
+
"waybill_status_counts",
|
| 187 |
+
"统计当前各状态的运单数量(运营概览)。",
|
| 188 |
+
{"type": "object", "properties": {}},
|
| 189 |
+
)
|
| 190 |
+
def waybill_status_counts():
|
| 191 |
+
res = _api("GET", "/waybills/status-counts")
|
| 192 |
+
if isinstance(res, dict) and res.get("error"):
|
| 193 |
+
return res
|
| 194 |
+
counts = res.get("counts") or res
|
| 195 |
+
if isinstance(counts, dict):
|
| 196 |
+
return {STATUS_ZH.get(k, k): v for k, v in counts.items() if isinstance(v, int)}
|
| 197 |
+
return counts
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@tool(
|
| 201 |
+
"report_exception",
|
| 202 |
+
"为运单在 TMS 中登记异常工单。type 可选:破损/丢失/地址错误/拒收/派送失败/扣货/超重/超尺寸/延误/其他。",
|
| 203 |
+
{"type": "object", "properties": {
|
| 204 |
+
"waybill_no": {"type": "string"},
|
| 205 |
+
"type": {"type": "string", "enum": list(EXC_CODE)},
|
| 206 |
+
"description": {"type": "string"},
|
| 207 |
+
}, "required": ["waybill_no", "type"]},
|
| 208 |
+
)
|
| 209 |
+
def report_exception(waybill_no: str, type: str, description: str = ""):
|
| 210 |
+
w = _find_waybill(waybill_no)
|
| 211 |
+
if not w or w.get("error"):
|
| 212 |
+
return w or {"error": f"未找到运单 {waybill_no}"}
|
| 213 |
+
body = {"waybill_id": w["id"], "code": EXC_CODE.get(type, "DELIVERY_FAILED"),
|
| 214 |
+
"severity": "high" if type in ("丢失", "破损") else "medium", "note": description or type}
|
| 215 |
+
res = _api("POST", "/exceptions", json=body)
|
| 216 |
+
if isinstance(res, dict) and res.get("error"):
|
| 217 |
+
return res
|
| 218 |
+
return {"exception_id": res.get("id"), "waybill_no": w.get("waybill_no"), "code": body["code"],
|
| 219 |
+
"status": res.get("status", "open"), "note": "已登记异常工单,运营会在工作台跟进处理"}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _all_sites() -> list | dict:
|
| 223 |
+
res = _api("GET", "/sites", params={"page_size": 100})
|
| 224 |
+
if isinstance(res, dict) and res.get("error"):
|
| 225 |
+
return res
|
| 226 |
+
sites = [s for s in _items(res)[0] if s.get("status") in (None, "active")]
|
| 227 |
+
# 名称去重(环境残留的重复网点取最早创建的一个)
|
| 228 |
+
seen: dict = {}
|
| 229 |
+
for s2 in sorted(sites, key=lambda x: x.get("created_at") or ""):
|
| 230 |
+
seen.setdefault(s2.get("name"), s2)
|
| 231 |
+
return list(seen.values())
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _find_site(name: str) -> dict | None:
|
| 235 |
+
sites = _all_sites()
|
| 236 |
+
if isinstance(sites, dict):
|
| 237 |
+
return sites
|
| 238 |
+
q = name.strip()
|
| 239 |
+
exact = [s for s in sites if s.get("name") == q]
|
| 240 |
+
if exact:
|
| 241 |
+
return exact[0]
|
| 242 |
+
sub = [s for s in sites if q in s.get("name", "") or s.get("name", "") in q]
|
| 243 |
+
if len(sub) == 1:
|
| 244 |
+
return sub[0]
|
| 245 |
+
# 模糊:去掉「网点/站/仓」后缀,按城市/关键词包含匹配
|
| 246 |
+
core = q.replace("网点", "").replace("营业部", "").replace("站", "").replace("仓", "")
|
| 247 |
+
fuzzy = [s for s in sites if core and core in s.get("name", "")]
|
| 248 |
+
if len(fuzzy) == 1:
|
| 249 |
+
return fuzzy[0]
|
| 250 |
+
if len(fuzzy) > 1:
|
| 251 |
+
return {"error": f"网点「{name}」有多个匹配,请让用户确认", "candidates": [s["name"] for s in fuzzy]}
|
| 252 |
+
return None
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@tool(
|
| 256 |
+
"calc_freight",
|
| 257 |
+
"用 TMS 报价引擎试算两个网点之间的零担运费。origin/destination 传网点名称(如 杭州网点)。",
|
| 258 |
+
{"type": "object", "properties": {
|
| 259 |
+
"origin": {"type": "string", "description": "起运网点名称"},
|
| 260 |
+
"destination": {"type": "string", "description": "目的网点名称"},
|
| 261 |
+
"weight_kg": {"type": "number"},
|
| 262 |
+
"volume_m3": {"type": "number", "description": "体积方数,可选"},
|
| 263 |
+
"piece_count": {"type": "integer", "description": "件数,可选"},
|
| 264 |
+
"declared_value": {"type": "number", "description": "声明价值,可选"},
|
| 265 |
+
}, "required": ["origin", "destination", "weight_kg"]},
|
| 266 |
+
)
|
| 267 |
+
def calc_freight(origin: str, destination: str, weight_kg: float, volume_m3: float | None = None,
|
| 268 |
+
piece_count: int | None = None, declared_value: float | None = None):
|
| 269 |
+
fs, ts = _find_site(origin), _find_site(destination)
|
| 270 |
+
if not fs or fs.get("error") or not ts or ts.get("error"):
|
| 271 |
+
bad = fs if (not fs or fs.get("error")) else ts
|
| 272 |
+
if bad and bad.get("error"):
|
| 273 |
+
return bad
|
| 274 |
+
sites = _all_sites()
|
| 275 |
+
names = [s["name"] for s in sites] if isinstance(sites, list) else []
|
| 276 |
+
return {"error": f"未找到网点「{origin if not fs or fs.get('error') else destination}」",
|
| 277 |
+
"available_sites": names, "hint": "请用 available_sites 里的准确名称重新调用 calc_freight"}
|
| 278 |
+
body: dict = {"from_site_id": fs["id"], "to_site_id": ts["id"], "weight_kg": str(weight_kg)}
|
| 279 |
+
if volume_m3:
|
| 280 |
+
body["volume_m3"] = str(volume_m3)
|
| 281 |
+
if piece_count:
|
| 282 |
+
body["piece_count"] = piece_count
|
| 283 |
+
if declared_value:
|
| 284 |
+
body["declared_value"] = str(declared_value)
|
| 285 |
+
res = _api("POST", "/pricing/trial-quote", json=body)
|
| 286 |
+
if isinstance(res, dict) and res.get("error"):
|
| 287 |
+
return res
|
| 288 |
+
# 映射成模型熟悉的字段:total_fee 为最终应收,breakdown 为明细说明
|
| 289 |
+
parts = ";".join(f"{b.get('label')} {b.get('amount')} 元" for b in res.get("breakdown") or [])
|
| 290 |
+
return {
|
| 291 |
+
"from_site": fs["name"], "to_site": ts["name"],
|
| 292 |
+
"billable_weight_kg": weight_kg, "volume_m3": volume_m3,
|
| 293 |
+
"product": res.get("applied_product_name"),
|
| 294 |
+
"total_fee": float(res.get("total") or res.get("discounted_freight") or 0),
|
| 295 |
+
"breakdown": parts or f"运费 {res.get('total')} 元",
|
| 296 |
+
"note": "以上为 TMS 报价引擎试算结果,请直接引用 total_fee,不要自行按首重续重公式重算",
|
| 297 |
+
}
|
logicllm/tools/waybill.py
CHANGED
|
@@ -22,6 +22,7 @@ def _conn():
|
|
| 22 |
|
| 23 |
|
| 24 |
def init_db(seed: int = 42, n: int = 60):
|
|
|
|
| 25 |
config.DATA_DIR.mkdir(exist_ok=True)
|
| 26 |
c = _conn()
|
| 27 |
c.executescript("""
|
|
@@ -42,17 +43,22 @@ def init_db(seed: int = 42, n: int = 60):
|
|
| 42 |
names = ["张伟", "王芳", "李娜", "刘强", "陈静", "杨洋", "赵磊", "黄敏", "周杰", "吴丹"]
|
| 43 |
cities = list(CITY_PROVINCE)
|
| 44 |
for i in range(n):
|
|
|
|
| 45 |
no = f"LL{20260800 + i:08d}{rnd.randint(10, 99)}"
|
| 46 |
o, d = rnd.sample(cities, 2)
|
| 47 |
stage = rnd.randint(0, 4)
|
| 48 |
created = datetime(2026, 8, rnd.randint(10, 21), rnd.randint(8, 20))
|
| 49 |
w = round(rnd.uniform(0.3, 25), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
c.execute("INSERT INTO waybill VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (
|
| 51 |
-
no,
|
| 52 |
-
|
| 53 |
-
f"{d}市某某区某某路{rnd.randint(1, 500)}号", w, rnd.choice(["标准快递", "特快", "经济"]),
|
| 54 |
-
round(10 + w * 4, 1), STATUS_FLOW[stage], created.isoformat(timespec="minutes"),
|
| 55 |
-
rnd.choice([0, 0, 500, 2000]), "" if rnd.random() > 0.12 else rnd.choice(["地址不详", "电话无人接听", "包裹破损"]),
|
| 56 |
))
|
| 57 |
ts = created
|
| 58 |
locs = [o, o + "转运中心", "干线运输", d + "转运中心", d]
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def init_db(seed: int = 42, n: int = 60):
|
| 25 |
+
from logicllm.tools.pricing import calc_freight as _calc
|
| 26 |
config.DATA_DIR.mkdir(exist_ok=True)
|
| 27 |
c = _conn()
|
| 28 |
c.executescript("""
|
|
|
|
| 43 |
names = ["张伟", "王芳", "李娜", "刘强", "陈静", "杨洋", "赵磊", "黄敏", "周杰", "吴丹"]
|
| 44 |
cities = list(CITY_PROVINCE)
|
| 45 |
for i in range(n):
|
| 46 |
+
# 注意保持随机数取用顺序与最初版本一致,确保演示运单号(如 LL2026080258)稳定
|
| 47 |
no = f"LL{20260800 + i:08d}{rnd.randint(10, 99)}"
|
| 48 |
o, d = rnd.sample(cities, 2)
|
| 49 |
stage = rnd.randint(0, 4)
|
| 50 |
created = datetime(2026, 8, rnd.randint(10, 21), rnd.randint(8, 20))
|
| 51 |
w = round(rnd.uniform(0.3, 25), 1)
|
| 52 |
+
sender, sender_phone = rnd.choice(names), f"13{rnd.randint(100000000, 999999999)}"
|
| 53 |
+
receiver, receiver_phone = rnd.choice(names), f"15{rnd.randint(100000000, 999999999)}"
|
| 54 |
+
addr = f"{d}市某某区某某路{rnd.randint(1, 500)}号"
|
| 55 |
+
svc = rnd.choice(["标准快递", "特快", "经济"])
|
| 56 |
+
dv = rnd.choice([0, 0, 500, 2000])
|
| 57 |
+
exc = "" if rnd.random() > 0.12 else rnd.choice(["地址不详", "电话无人接听", "包裹破损"])
|
| 58 |
+
fee = _calc(o, d, w, service=svc, declared_value=dv)["total_fee"] # 统一计费引擎,与 calc_freight 一致
|
| 59 |
c.execute("INSERT INTO waybill VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (
|
| 60 |
+
no, sender, sender_phone, o, receiver, receiver_phone, d, addr, w, svc,
|
| 61 |
+
fee, STATUS_FLOW[stage], created.isoformat(timespec="minutes"), dv, exc,
|
|
|
|
|
|
|
|
|
|
| 62 |
))
|
| 63 |
ts = created
|
| 64 |
locs = [o, o + "转运中心", "干线运输", d + "转运中心", d]
|
scripts/check_adapter.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
"""离线对比:不带 / 带 LoRA 时,模型对同一个问题的输出。训练后先跑这个,确认 adapter 真的生效再上线。"""
|
|
|
|
|
|
|
| 2 |
from mlx_lm import load, generate
|
| 3 |
from logicllm.agent.prompts import build_system, openai_tools
|
| 4 |
from logicllm.tools import all_specs
|
|
@@ -6,6 +8,8 @@ from logicllm.tools import all_specs
|
|
| 6 |
msgs = [{"role": "system", "content": build_system()},
|
| 7 |
{"role": "user", "content": "提取收件信息:收件人王小明 13812345678 浙江省杭州市西湖区文三路123号5楼"}]
|
| 8 |
for adapter in [None, "adapters/logistics-lora"]:
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
print("便签本:", adapter, "→", generate(model, tok, prompt=prompt, max_tokens=120, verbose=False)[:150], "\n")
|
|
|
|
| 1 |
"""离线对比:不带 / 带 LoRA 时,模型对同一个问题的输出。训练后先跑这个,确认 adapter 真的生效再上线。"""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
from mlx_lm import load, generate
|
| 5 |
from logicllm.agent.prompts import build_system, openai_tools
|
| 6 |
from logicllm.tools import all_specs
|
|
|
|
| 8 |
msgs = [{"role": "system", "content": build_system()},
|
| 9 |
{"role": "user", "content": "提取收件信息:收件人王小明 13812345678 浙江省杭州市西湖区文三路123号5楼"}]
|
| 10 |
for adapter in [None, "adapters/logistics-lora"]:
|
| 11 |
+
base = os.getenv("BASE_MODEL", "mlx-community/Qwen3-14B-4bit")
|
| 12 |
+
model, tok = load(base, adapter_path=adapter)
|
| 13 |
+
kw = {"enable_thinking": False} if "enable_thinking" in str(getattr(tok, "chat_template", "")) else {}
|
| 14 |
+
prompt = tok.apply_chat_template(msgs, tools=openai_tools(all_specs()), add_generation_prompt=True, **kw)
|
| 15 |
print("便签本:", adapter, "→", generate(model, tok, prompt=prompt, max_tokens=120, verbose=False)[:150], "\n")
|
scripts/eval.py
CHANGED
|
@@ -22,13 +22,24 @@ def score(case: dict, answer: str, steps: list) -> tuple[float, str]:
|
|
| 22 |
if "expect_tool" in case:
|
| 23 |
if case["expect_tool"] not in tools_called:
|
| 24 |
ok = 0.0; notes.append(f"未调用 {case['expect_tool']} (实际 {tools_called})")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
if "expect_no_tool" in case and tools_called:
|
| 26 |
ok = 0.0; notes.append(f"不应调用工具,实际 {tools_called}")
|
|
|
|
| 27 |
if "keywords" in case:
|
| 28 |
-
norm = answer.replace(" ", "")
|
| 29 |
miss = [k for k in case["keywords"] if k.replace(" ", "") not in norm]
|
| 30 |
if miss:
|
| 31 |
ok = min(ok, 1 - len(miss) / len(case["keywords"])); notes.append(f"缺关键词 {miss}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
if "expect_json" in case:
|
| 33 |
m = re.search(r"\{.*\}", answer, re.S)
|
| 34 |
try:
|
|
@@ -47,7 +58,8 @@ async def main():
|
|
| 47 |
ap.add_argument("--no-rag", action="store_true")
|
| 48 |
ap.add_argument("--limit", type=int, default=0)
|
| 49 |
a = ap.parse_args()
|
| 50 |
-
cases = [json.loads(l) for l in CASES.read_text(encoding="utf-8").splitlines()
|
|
|
|
| 51 |
if a.limit:
|
| 52 |
cases = cases[:a.limit]
|
| 53 |
agent = LogisticsAgent(use_tools=not a.no_tools, use_rag=not a.no_rag)
|
|
@@ -56,14 +68,21 @@ async def main():
|
|
| 56 |
t0 = time.time()
|
| 57 |
for i, c in enumerate(cases, 1):
|
| 58 |
try:
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
except Exception as e: # noqa: BLE001
|
| 62 |
s, note, res = 0.0, f"异常 {e}", None
|
| 63 |
total += s
|
| 64 |
by_cat.setdefault(c["cat"], []).append(s)
|
| 65 |
flag = "✅" if s >= 0.99 else ("🟡" if s > 0 else "❌")
|
| 66 |
-
|
|
|
|
| 67 |
print(f"\n总分 {total / len(cases):.3f} 用时 {time.time() - t0:.0f}s")
|
| 68 |
for k, v in by_cat.items():
|
| 69 |
print(f" {k}: {sum(v) / len(v):.3f} ({len(v)})")
|
|
|
|
| 22 |
if "expect_tool" in case:
|
| 23 |
if case["expect_tool"] not in tools_called:
|
| 24 |
ok = 0.0; notes.append(f"未调用 {case['expect_tool']} (实际 {tools_called})")
|
| 25 |
+
if "expect_tools" in case: # 多个工具都必须被调用
|
| 26 |
+
miss_t = [t for t in case["expect_tools"] if t not in tools_called]
|
| 27 |
+
if miss_t:
|
| 28 |
+
ok = 0.0; notes.append(f"未调用 {miss_t} (实际 {tools_called})")
|
| 29 |
if "expect_no_tool" in case and tools_called:
|
| 30 |
ok = 0.0; notes.append(f"不应调用工具,实际 {tools_called}")
|
| 31 |
+
norm = answer.replace(" ", "")
|
| 32 |
if "keywords" in case:
|
|
|
|
| 33 |
miss = [k for k in case["keywords"] if k.replace(" ", "") not in norm]
|
| 34 |
if miss:
|
| 35 |
ok = min(ok, 1 - len(miss) / len(case["keywords"])); notes.append(f"缺关键词 {miss}")
|
| 36 |
+
if "keywords_any" in case: # 命中其一即可
|
| 37 |
+
if not any(k.replace(" ", "") in norm for k in case["keywords_any"]):
|
| 38 |
+
ok = 0.0; notes.append(f"任一关键词均未命中 {case['keywords_any']}")
|
| 39 |
+
if "forbid" in case: # 出现即零分(用于臆测/编造检查)
|
| 40 |
+
hit = [k for k in case["forbid"] if k.replace(" ", "") in norm]
|
| 41 |
+
if hit:
|
| 42 |
+
ok = 0.0; notes.append(f"出现禁用词 {hit}")
|
| 43 |
if "expect_json" in case:
|
| 44 |
m = re.search(r"\{.*\}", answer, re.S)
|
| 45 |
try:
|
|
|
|
| 58 |
ap.add_argument("--no-rag", action="store_true")
|
| 59 |
ap.add_argument("--limit", type=int, default=0)
|
| 60 |
a = ap.parse_args()
|
| 61 |
+
cases = [json.loads(l) for l in CASES.read_text(encoding="utf-8").splitlines()
|
| 62 |
+
if l.strip() and not l.strip().startswith("//")]
|
| 63 |
if a.limit:
|
| 64 |
cases = cases[:a.limit]
|
| 65 |
agent = LogisticsAgent(use_tools=not a.no_tools, use_rag=not a.no_rag)
|
|
|
|
| 68 |
t0 = time.time()
|
| 69 |
for i, c in enumerate(cases, 1):
|
| 70 |
try:
|
| 71 |
+
turns = c.get("turns") or [c["q"]]
|
| 72 |
+
history, steps_all = [], []
|
| 73 |
+
for t in turns:
|
| 74 |
+
history.append({"role": "user", "content": t})
|
| 75 |
+
res = await agent.run(history)
|
| 76 |
+
history.append({"role": "assistant", "content": res.answer})
|
| 77 |
+
steps_all.extend(res.steps)
|
| 78 |
+
s, note = score(c, res.answer, steps_all)
|
| 79 |
except Exception as e: # noqa: BLE001
|
| 80 |
s, note, res = 0.0, f"异常 {e}", None
|
| 81 |
total += s
|
| 82 |
by_cat.setdefault(c["cat"], []).append(s)
|
| 83 |
flag = "✅" if s >= 0.99 else ("🟡" if s > 0 else "❌")
|
| 84 |
+
qshow = c.get("q") or " ▸ ".join(c.get("turns", []))
|
| 85 |
+
print(f"{flag} [{c['cat']}] {qshow[:44]} {s:.2f} {note}")
|
| 86 |
print(f"\n总分 {total / len(cases):.3f} 用时 {time.time() - t0:.0f}s")
|
| 87 |
for k, v in by_cat.items():
|
| 88 |
print(f" {k}: {sum(v) / len(v):.3f} ({len(v)})")
|
scripts/fuse.sh
CHANGED
|
@@ -3,6 +3,6 @@
|
|
| 3 |
# ⚠️ 实测直接合并进 4bit 量化基座会让 LoRA 失效,必须 --de-quantize 得到 fp16 模型(约 15GB)。
|
| 4 |
set -euo pipefail
|
| 5 |
cd "$(dirname "$0")/.."
|
| 6 |
-
BASE=${BASE_MODEL:-mlx-community/
|
| 7 |
uv run python -m mlx_lm fuse --model "$BASE" --adapter-path adapters/logistics-lora --save-path models/logistics-qwen-7b-fp16 --de-quantize
|
| 8 |
echo "已合并 → models/logistics-qwen-7b-fp16(fp16)"
|
|
|
|
| 3 |
# ⚠️ 实测直接合并进 4bit 量化基座会让 LoRA 失效,必须 --de-quantize 得到 fp16 模型(约 15GB)。
|
| 4 |
set -euo pipefail
|
| 5 |
cd "$(dirname "$0")/.."
|
| 6 |
+
BASE=${BASE_MODEL:-mlx-community/Qwen3-14B-4bit}
|
| 7 |
uv run python -m mlx_lm fuse --model "$BASE" --adapter-path adapters/logistics-lora --save-path models/logistics-qwen-7b-fp16 --de-quantize
|
| 8 |
echo "已合并 → models/logistics-qwen-7b-fp16(fp16)"
|
scripts/gen_sft.py
CHANGED
|
@@ -22,7 +22,7 @@ from logicllm.agent.prompts import build_system, openai_tools
|
|
| 22 |
from logicllm.llm.client import ChatClient
|
| 23 |
from logicllm.rag.kb import split_markdown
|
| 24 |
from logicllm.tools import all_specs, call_tool
|
| 25 |
-
from logicllm.tools.geo import CITY_PROVINCE
|
| 26 |
from logicllm.tools.waybill import init_db, _conn
|
| 27 |
|
| 28 |
OUT = config.DATA_DIR / "sft"
|
|
@@ -109,6 +109,29 @@ async def gen_kb(n: int, client: ChatClient) -> list[dict]:
|
|
| 109 |
|
| 110 |
|
| 111 |
# ---------- 2. 客服 + 工具调用 ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
async def gen_tool(n: int) -> list[dict]:
|
| 113 |
init_db()
|
| 114 |
c = _conn()
|
|
@@ -116,17 +139,15 @@ async def gen_tool(n: int) -> list[dict]:
|
|
| 116 |
c.close()
|
| 117 |
out = []
|
| 118 |
for _ in range(n):
|
| 119 |
-
kind = rnd.choice(["query", "track", "freight", "eta", "search", "exception", "ask_no", "create"
|
|
|
|
| 120 |
if kind == "query":
|
| 121 |
w = rnd.choice(wbs)
|
| 122 |
u = rnd.choice([f"帮我查一下运单 {w['no']}", f"{w['no']} 这个单子现在什么情况", f"查询 {w['no']}", f"我的快递 {w['no']} 到哪了"])
|
| 123 |
call = {"name": "query_waybill", "arguments": {"waybill_no": w["no"]}}
|
| 124 |
res = await call_tool(**{"name": call["name"], "args": call["arguments"]})
|
| 125 |
-
exc = f",当前标记有异常「{res['exception']}」,我们会尽快处理" if res.get("exception") else ""
|
| 126 |
-
a = (f"运单 {w['no']} 的情况如下:\n- 路线:{res['origin']} → {res['destination']}\n- 服务:{res['service']},重量 {res['weight_kg']}kg,运费 {res['fee']} 元\n"
|
| 127 |
-
f"- 当前状态:**{res['status']}**{exc}\n- 下单时间:{res['created_at'].replace('T', ' ')}\n如需查看详细轨迹,我可以继续为您查询。")
|
| 128 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, tc(call),
|
| 129 |
-
tr(res), {"role": "assistant", "content":
|
| 130 |
elif kind == "track":
|
| 131 |
w = rnd.choice(wbs)
|
| 132 |
u = rnd.choice([f"{w['no']} 的物流轨迹", f"看看 {w['no']} 的运输记录", f"运单{w['no']}现在在哪个环节"])
|
|
@@ -157,18 +178,19 @@ async def gen_tool(n: int) -> list[dict]:
|
|
| 157 |
utxt += (f",{svc}" if svc_in_text else "") + rnd.choice(["多少钱?", "运费怎么算?", "要花多少运费"])
|
| 158 |
call = {"name": "calc_freight", "arguments": args}
|
| 159 |
res = await call_tool(call["name"], call["arguments"])
|
| 160 |
-
a = ("" if svc_in_text else
|
| 161 |
-
+ (f"(体积重 {res['volume_weight_kg']}kg 大于实重)" if res["volume_weight_kg"] > w else "")
|
| 162 |
-
+ f":\n- {res['breakdown']}\n- **合计 {res['total_fee']} 元**")
|
| 163 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": utxt}, tc(call),
|
| 164 |
tr(res), {"role": "assistant", "content": a}]))
|
| 165 |
elif kind == "eta":
|
| 166 |
o, d = rnd.sample(list(CITY_PROVINCE), 2)
|
| 167 |
svc = rnd.choice(["标准快递", "特快", "经济"])
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
| 169 |
call = {"name": "estimate_eta", "arguments": {"origin": o, "destination": d, "service": svc}}
|
| 170 |
res = await call_tool(call["name"], call["arguments"])
|
| 171 |
-
a = f"{o} → {d} 约 {res['distance_km']}km,{svc}预计 **{res['est_days']} 天**送达(约 {res['est_delivery_date']})。" + (f"{res['note']}。" if res["note"] else "") + "实际以揽收后轨迹为准,恶劣天气可能延误。"
|
| 172 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, tc(call),
|
| 173 |
tr(res), {"role": "assistant", "content": a}]))
|
| 174 |
elif kind == "search":
|
|
@@ -187,17 +209,70 @@ async def gen_tool(n: int) -> list[dict]:
|
|
| 187 |
u = rnd.choice([f"运单 {w['no']} 收到时外箱{typ}了,要投诉", f"{w['no']} {typ},怎么处理", f"我要给 {w['no']} 登记{typ}"])
|
| 188 |
call = {"name": "report_exception", "arguments": {"waybill_no": w["no"], "type": typ, "description": f"用户反馈{typ}"}}
|
| 189 |
res = await call_tool(call["name"], call["arguments"])
|
| 190 |
-
tip = {"破损": "请保留外包装并拍照(外包装+内件),如已保价按声明价值赔付,未保价按实际损失赔付,上限为运费 3 倍。",
|
| 191 |
-
"丢失": "轨迹停更超过 7 天可认定丢失;未保价按运费 3 倍赔偿(最高 300 元)并退还运费,已保价按声明价值赔付。",
|
| 192 |
-
"延误": "特快超承诺时效 1 天以上可退还特快与标准快递的差价。",
|
| 193 |
-
"地址错误": "派送前可联系修改,同城免费;跨城需补差价。"}[typ]
|
| 194 |
-
a = f"非常抱歉给您带来不便。已为运单 {w['no']} 登记「{typ}」异常,工单号 **{res['ticket_no']}**,客服将在 48 小时内回访处理。\n{tip}"
|
| 195 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, tc(call),
|
| 196 |
-
tr(res), {"role": "assistant", "content":
|
| 197 |
elif kind == "ask_no":
|
| 198 |
u = rnd.choice(["我的快递到哪了?", "帮我查下快递", "我的包裹怎么还没到", "查一下物流"])
|
| 199 |
a = "好的,请提供您的运单号(LL 开头的 12 位编号),或者告诉我寄/收件人手机号,我来帮您查询。"
|
| 200 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, {"role": "assistant", "content": a}]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
else: # create
|
| 202 |
o, d = rnd.sample(list(CITY_PROVINCE), 2)
|
| 203 |
s, r = rnd.sample(NAMES, 2)
|
|
@@ -222,13 +297,13 @@ def gen_extract(n: int) -> list[dict]:
|
|
| 222 |
from logicllm.tools.address import parse_address
|
| 223 |
out = []
|
| 224 |
for _ in range(n):
|
| 225 |
-
city = rnd.choice(list(
|
| 226 |
prov = CITY_PROVINCE[city]
|
| 227 |
name = rnd.choice(NAMES)
|
| 228 |
phone = f"1{rnd.choice('3589')}{rnd.randint(100000000, 999999999)}"
|
| 229 |
-
dist = rnd.choice([
|
| 230 |
detail = f"{rnd.choice(STREETS)}{rnd.randint(1, 500)}号{rnd.choice(['', '3栋2单元501', 'A座1201', '科技园T3栋'])}"
|
| 231 |
-
provtxt = "" if prov == city else rnd.choice([
|
| 232 |
citytxt = f"{city}市" if rnd.random() < 0.7 else city
|
| 233 |
item, w = rnd.choice(ITEMS)
|
| 234 |
layout = rnd.choice([
|
|
@@ -281,12 +356,68 @@ async def gen_route(n: int) -> list[dict]:
|
|
| 281 |
return out
|
| 282 |
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
async def main():
|
| 285 |
ap = argparse.ArgumentParser()
|
| 286 |
ap.add_argument("--n-kb", type=int, default=200)
|
| 287 |
ap.add_argument("--n-tool", type=int, default=300)
|
| 288 |
ap.add_argument("--n-extract", type=int, default=150)
|
| 289 |
ap.add_argument("--n-route", type=int, default=100)
|
|
|
|
| 290 |
ap.add_argument("--valid-ratio", type=float, default=0.08)
|
| 291 |
ap.add_argument("--skip-kb", action="store_true", help="不用教师模型生成知识问答")
|
| 292 |
ap.add_argument("--kb-cache", default=str(config.DATA_DIR / "seed" / "teacher_kb_qa.json"), help="已生成的知识问答缓存,存在则直接复用")
|
|
@@ -297,6 +428,7 @@ async def main():
|
|
| 297 |
print("生成工具调用样本…"); data += await gen_tool(a.n_tool)
|
| 298 |
print("生成抽取样本…"); data += gen_extract(a.n_extract)
|
| 299 |
print("生成路径样本…"); data += await gen_route(a.n_route)
|
|
|
|
| 300 |
cache = Path(a.kb_cache)
|
| 301 |
if not a.skip_kb and a.n_kb > 0 and cache.exists():
|
| 302 |
pairs = json.load(open(cache, encoding="utf-8"))
|
|
@@ -320,26 +452,45 @@ async def main():
|
|
| 320 |
d = json.loads(l)
|
| 321 |
data.append(sample(SYSTEM_PLAIN, d["messages"][1:])) # 手写种子也注入检索上下文
|
| 322 |
|
| 323 |
-
#
|
| 324 |
-
|
| 325 |
-
expanded = []
|
| 326 |
for d in data:
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
data
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
with open(OUT / "valid.jsonl", "w", encoding="utf-8") as f:
|
| 337 |
-
for x in
|
| 338 |
f.write(json.dumps(x, ensure_ascii=False) + "\n")
|
| 339 |
with open(OUT / "train.jsonl", "w", encoding="utf-8") as f:
|
| 340 |
-
for x in
|
| 341 |
f.write(json.dumps(x, ensure_ascii=False) + "\n")
|
| 342 |
-
print(f"完成:train {len(
|
| 343 |
|
| 344 |
|
| 345 |
if __name__ == "__main__":
|
|
|
|
| 22 |
from logicllm.llm.client import ChatClient
|
| 23 |
from logicllm.rag.kb import split_markdown
|
| 24 |
from logicllm.tools import all_specs, call_tool
|
| 25 |
+
from logicllm.tools.geo import CITY_PROVINCE, CITY_DISTRICTS, province_full
|
| 26 |
from logicllm.tools.waybill import init_db, _conn
|
| 27 |
|
| 28 |
OUT = config.DATA_DIR / "sft"
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
# ---------- 2. 客服 + 工具调用 ----------
|
| 112 |
+
NO_SVC_NOTE = "您未指明服务类型,按默认的标准快递计算(如需特快/经济请告诉我)。\n"
|
| 113 |
+
EXC_TIPS = {"破损": "请保留外包装并拍照(外包装+内件),如已保价按声明价值赔付,未保价按实际损失赔付,上限为运费 3 倍。",
|
| 114 |
+
"丢失": "轨迹停更超过 7 天可认定丢失;未保价按运费 3 倍赔偿(最高 300 元)并退还运费,已保价按声明价值赔付。",
|
| 115 |
+
"延误": "特快超承诺时效 1 天以上可退还特快与标准快递的差价。",
|
| 116 |
+
"地址错误": "派送前可联系修改,同城免费;跨城需补差价。"}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _query_answer(no: str, res: dict) -> str:
|
| 120 |
+
exc = f",当前标记有异常「{res['exception']}」,我们会尽快处理" if res.get("exception") else ""
|
| 121 |
+
return (f"运单 {no} 的情况如下:\n- 路线:{res['origin']} → {res['destination']}\n- 服务:{res['service']},重量 {res['weight_kg']}kg,运费 {res['fee']} 元\n"
|
| 122 |
+
f"- 当前状态:**{res['status']}**{exc}\n- 下单时间:{res['created_at'].replace('T', ' ')}\n如需查看详细轨迹,我可以继续为您查询。")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _freight_answer(o: str, d: str, w: float, res: dict) -> str:
|
| 126 |
+
return (f"{o} → {d}({res['zone']}),计费重量 {res['billable_weight_kg']}kg"
|
| 127 |
+
+ (f"(体积重 {res['volume_weight_kg']}kg 大于实重)" if res["volume_weight_kg"] > w else "")
|
| 128 |
+
+ f":\n- {res['breakdown']}\n- **合计 {res['total_fee']} 元**")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _exception_answer(no: str, typ: str, res: dict) -> str:
|
| 132 |
+
return f"非常抱歉给您带来不便。已为运单 {no} 登记「{typ}」异常,工单号 **{res['ticket_no']}**,客服将在 48 小时内回访处理。\n{EXC_TIPS[typ]}"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
async def gen_tool(n: int) -> list[dict]:
|
| 136 |
init_db()
|
| 137 |
c = _conn()
|
|
|
|
| 139 |
c.close()
|
| 140 |
out = []
|
| 141 |
for _ in range(n):
|
| 142 |
+
kind = rnd.choice(["query", "track", "freight", "eta", "search", "exception", "ask_no", "create",
|
| 143 |
+
"combo", "followup_svc", "ask_then_no", "complain_then_no"])
|
| 144 |
if kind == "query":
|
| 145 |
w = rnd.choice(wbs)
|
| 146 |
u = rnd.choice([f"帮我查一下运单 {w['no']}", f"{w['no']} 这个单子现在什么情况", f"查询 {w['no']}", f"我的快递 {w['no']} 到哪了"])
|
| 147 |
call = {"name": "query_waybill", "arguments": {"waybill_no": w["no"]}}
|
| 148 |
res = await call_tool(**{"name": call["name"], "args": call["arguments"]})
|
|
|
|
|
|
|
|
|
|
| 149 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, tc(call),
|
| 150 |
+
tr(res), {"role": "assistant", "content": _query_answer(w["no"], res)}]))
|
| 151 |
elif kind == "track":
|
| 152 |
w = rnd.choice(wbs)
|
| 153 |
u = rnd.choice([f"{w['no']} 的物流轨迹", f"看看 {w['no']} 的运输记录", f"运单{w['no']}现在在哪个环节"])
|
|
|
|
| 178 |
utxt += (f",{svc}" if svc_in_text else "") + rnd.choice(["多少钱?", "运费怎么算?", "要花多少运费"])
|
| 179 |
call = {"name": "calc_freight", "arguments": args}
|
| 180 |
res = await call_tool(call["name"], call["arguments"])
|
| 181 |
+
a = ("" if svc_in_text else NO_SVC_NOTE) + _freight_answer(o, d, w, res)
|
|
|
|
|
|
|
| 182 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": utxt}, tc(call),
|
| 183 |
tr(res), {"role": "assistant", "content": a}]))
|
| 184 |
elif kind == "eta":
|
| 185 |
o, d = rnd.sample(list(CITY_PROVINCE), 2)
|
| 186 |
svc = rnd.choice(["标准快递", "特快", "经济"])
|
| 187 |
+
svc_in_text = rnd.random() > 0.35
|
| 188 |
+
if not svc_in_text:
|
| 189 |
+
svc = "标准快递" # 用户未指明时不得臆测,按默认标准快递并说明
|
| 190 |
+
u = rnd.choice([f"{o}到{d}{svc}几天能到?", f"今天从{o}发{svc}到{d},什么时候到"]) if svc_in_text else rnd.choice([f"{o}寄{d}要多久", f"{o}到{d}几天能到"])
|
| 191 |
call = {"name": "estimate_eta", "arguments": {"origin": o, "destination": d, "service": svc}}
|
| 192 |
res = await call_tool(call["name"], call["arguments"])
|
| 193 |
+
a = ("" if svc_in_text else "按默认的标准快递估算(特快可再快 1 天):") + f"{o} → {d} 约 {res['distance_km']}km,{svc}预计 **{res['est_days']} 天**送达(约 {res['est_delivery_date']})。" + (f"{res['note']}。" if res["note"] else "") + "实际以揽收后轨迹为准,恶劣天气可能延误。"
|
| 194 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, tc(call),
|
| 195 |
tr(res), {"role": "assistant", "content": a}]))
|
| 196 |
elif kind == "search":
|
|
|
|
| 209 |
u = rnd.choice([f"运单 {w['no']} 收到时外箱{typ}了,要投诉", f"{w['no']} {typ},怎么处理", f"我要给 {w['no']} 登记{typ}"])
|
| 210 |
call = {"name": "report_exception", "arguments": {"waybill_no": w["no"], "type": typ, "description": f"用户反馈{typ}"}}
|
| 211 |
res = await call_tool(call["name"], call["arguments"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, tc(call),
|
| 213 |
+
tr(res), {"role": "assistant", "content": _exception_answer(w["no"], typ, res)}]))
|
| 214 |
elif kind == "ask_no":
|
| 215 |
u = rnd.choice(["我的快递到哪了?", "帮我查下快递", "我的包裹怎么还没到", "查一下物流"])
|
| 216 |
a = "好的,请提供您的运单号(LL 开头的 12 位编号),或者告诉我寄/收件人手机号,我来帮您查询。"
|
| 217 |
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, {"role": "assistant", "content": a}]))
|
| 218 |
+
elif kind == "combo": # 一句话同时问运费和时效:两个工具都要调,先算钱再算时效
|
| 219 |
+
o, d = rnd.sample(list(CITY_PROVINCE), 2)
|
| 220 |
+
item, w = rnd.choice(ITEMS)
|
| 221 |
+
w = round(w * rnd.uniform(0.6, 3.0), 1)
|
| 222 |
+
svc = rnd.choice(["标准快递", "特快", "经济"])
|
| 223 |
+
svc_in_text = rnd.random() > 0.4
|
| 224 |
+
if not svc_in_text:
|
| 225 |
+
svc = "标准快递"
|
| 226 |
+
svc_txt = f"{svc}" if svc_in_text else ""
|
| 227 |
+
u = rnd.choice([f"{o}寄{w}公斤{item}到{d},{svc_txt}多少钱几天能到", f"从{o}发{item}到{d},{w}kg,{svc_txt}运费和时效分别是多少",
|
| 228 |
+
f"{o}到{d} {w}kg {item},{svc_txt}要多少钱、什么时候能到"])
|
| 229 |
+
c1 = {"name": "calc_freight", "arguments": {"origin": o, "destination": d, "weight_kg": w, "service": svc}}
|
| 230 |
+
r1 = await call_tool(c1["name"], c1["arguments"])
|
| 231 |
+
c2 = {"name": "estimate_eta", "arguments": {"origin": o, "destination": d, "service": svc}}
|
| 232 |
+
r2 = await call_tool(c2["name"], c2["arguments"])
|
| 233 |
+
a = (("" if svc_in_text else NO_SVC_NOTE) + f"{o} → {d}({r1['zone']}),计费重量 {r1['billable_weight_kg']}kg:\n"
|
| 234 |
+
f"- 运费:{r1['breakdown']},**合计 {r1['total_fee']} 元**\n"
|
| 235 |
+
f"- 时效:约 {r2['distance_km']}km,{svc}预计 **{r2['est_days']} 天**送达(约 {r2['est_delivery_date']})" + (f",{r2['note']}" if r2["note"] else "")
|
| 236 |
+
+ "\n实际以揽收后轨迹为准。")
|
| 237 |
+
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, tc(c1), tr(r1), tc(c2), tr(r2), {"role": "assistant", "content": a}]))
|
| 238 |
+
elif kind == "followup_svc": # 多轮:先按默认标准快递报价,追问换服务类型要重新调工具
|
| 239 |
+
o, d = rnd.sample(list(CITY_PROVINCE), 2)
|
| 240 |
+
item, w = rnd.choice(ITEMS)
|
| 241 |
+
w = round(w * rnd.uniform(0.6, 2.5), 1)
|
| 242 |
+
u1 = rnd.choice([f"从{o}寄{w}公斤{item}到{d}多少钱?", f"{o}到{d},{item} {w}kg,运费多少"])
|
| 243 |
+
r1 = await call_tool("calc_freight", {"origin": o, "destination": d, "weight_kg": w, "service": "标准快递"})
|
| 244 |
+
a1 = NO_SVC_NOTE + _freight_answer(o, d, w, r1)
|
| 245 |
+
new_svc = rnd.choice(["特快", "经济"])
|
| 246 |
+
u2 = rnd.choice([f"改成{new_svc}呢?", f"{new_svc}多少钱", f"换{new_svc}的话", f"那{new_svc}要多少"])
|
| 247 |
+
c2 = {"name": "calc_freight", "arguments": {"origin": o, "destination": d, "weight_kg": w, "service": new_svc}}
|
| 248 |
+
r2 = await call_tool(c2["name"], c2["arguments"])
|
| 249 |
+
diff = round(r2["total_fee"] - r1["total_fee"], 2)
|
| 250 |
+
a2 = (f"换成{new_svc}的话,{o} → {d},计费重量 {r2['billable_weight_kg']}kg:\n- {r2['breakdown']}\n- **合计 {r2['total_fee']} 元**"
|
| 251 |
+
+ (f",比标准快递贵 {diff} 元" if diff > 0 else f",比标准快递便宜 {-diff} 元") + "。")
|
| 252 |
+
# 历史轮次只保留最终答复文本(与线上会话历史一致,不含工具轨迹)
|
| 253 |
+
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u1}, {"role": "assistant", "content": a1},
|
| 254 |
+
{"role": "user", "content": u2}, tc(c2), tr(r2), {"role": "assistant", "content": a2}]))
|
| 255 |
+
elif kind == "ask_then_no": # 多轮:先没给单号,追问后给了单号再查
|
| 256 |
+
w = rnd.choice(wbs)
|
| 257 |
+
u1 = rnd.choice(["帮我查个运单", "查一下我的快递", "我想查物流", "帮我看看包裹到哪了"])
|
| 258 |
+
a1 = "好的,请提供您的运单号(LL 开头的 12 位编号),或者告诉我寄/收件人手机号,我来帮您查询。"
|
| 259 |
+
u2 = rnd.choice([w["no"], f"运单号是 {w['no']}", f"{w['no']},麻烦了", f"单号 {w['no']}"])
|
| 260 |
+
call = {"name": "query_waybill", "arguments": {"waybill_no": w["no"]}}
|
| 261 |
+
res = await call_tool(call["name"], call["arguments"])
|
| 262 |
+
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u1}, {"role": "assistant", "content": a1},
|
| 263 |
+
{"role": "user", "content": u2}, tc(call), tr(res), {"role": "assistant", "content": _query_answer(w["no"], res)}]))
|
| 264 |
+
elif kind == "complain_then_no": # 多轮:先描述问题,再给单号登记异常
|
| 265 |
+
w = rnd.choice(wbs)
|
| 266 |
+
typ = rnd.choice(["破损", "丢失", "延误"])
|
| 267 |
+
u1 = {"破损": rnd.choice(["我的快递收到的时候摔坏了,怎么办", "包裹外箱破了,里面东西也坏了"]),
|
| 268 |
+
"丢失": rnd.choice(["我的快递好多天没动静了,是不是丢了", "包裹一直不更新物流,怎么办"]),
|
| 269 |
+
"延误": rnd.choice(["我寄的特快已经超时了还没到", "快递比承诺时间晚了两天了"])}[typ]
|
| 270 |
+
a1 = f"{EXC_TIPS[typ]}\n请提供运单号,我帮您登记「{typ}」异常工单,客服会在 48 小时内回访。"
|
| 271 |
+
u2 = rnd.choice([f"运单号是 {w['no']},帮我登记投诉", f"{w['no']},麻烦登记一下", f"单号 {w['no']},要投诉"])
|
| 272 |
+
call = {"name": "report_exception", "arguments": {"waybill_no": w["no"], "type": typ, "description": f"用户反馈{typ}"}}
|
| 273 |
+
res = await call_tool(call["name"], call["arguments"])
|
| 274 |
+
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u1}, {"role": "assistant", "content": a1},
|
| 275 |
+
{"role": "user", "content": u2}, tc(call), tr(res), {"role": "assistant", "content": _exception_answer(w["no"], typ, res)}]))
|
| 276 |
else: # create
|
| 277 |
o, d = rnd.sample(list(CITY_PROVINCE), 2)
|
| 278 |
s, r = rnd.sample(NAMES, 2)
|
|
|
|
| 297 |
from logicllm.tools.address import parse_address
|
| 298 |
out = []
|
| 299 |
for _ in range(n):
|
| 300 |
+
city = rnd.choice(list(CITY_DISTRICTS))
|
| 301 |
prov = CITY_PROVINCE[city]
|
| 302 |
name = rnd.choice(NAMES)
|
| 303 |
phone = f"1{rnd.choice('3589')}{rnd.randint(100000000, 999999999)}"
|
| 304 |
+
dist = rnd.choice(CITY_DISTRICTS[city])
|
| 305 |
detail = f"{rnd.choice(STREETS)}{rnd.randint(1, 500)}号{rnd.choice(['', '3栋2单元501', 'A座1201', '科技园T3栋'])}"
|
| 306 |
+
provtxt = "" if prov == city else rnd.choice([province_full(prov), prov, ""])
|
| 307 |
citytxt = f"{city}市" if rnd.random() < 0.7 else city
|
| 308 |
item, w = rnd.choice(ITEMS)
|
| 309 |
layout = rnd.choice([
|
|
|
|
| 356 |
return out
|
| 357 |
|
| 358 |
|
| 359 |
+
# ---------- 5. 失败与边界轨迹 ----------
|
| 360 |
+
async def gen_failures(n: int) -> list[dict]:
|
| 361 |
+
init_db()
|
| 362 |
+
out = []
|
| 363 |
+
unknown_cities = ["洛阳", "三亚", "桂林", "唐山", "襄阳", "赣州", "遵义", "绵阳"]
|
| 364 |
+
foreign_cities = ["纽约", "伦敦", "东京", "洛杉矶", "新加坡", "巴黎", "首尔", "悉尼"]
|
| 365 |
+
for _ in range(n):
|
| 366 |
+
kind = rnd.choice(["bad_no", "bad_no_track", "bad_city_eta", "bad_city_freight", "bad_city_route", "no_result_phone", "intl_freight"])
|
| 367 |
+
if kind in ("bad_no", "bad_no_track"):
|
| 368 |
+
no = f"LL{rnd.randint(2027000000, 2029999999)}{rnd.randint(10, 99)}"
|
| 369 |
+
tool_name = "query_waybill" if kind == "bad_no" else "track_waybill"
|
| 370 |
+
u = rnd.choice([f"帮我查下 {no}", f"{no} 到哪了", f"查一下运单 {no} 的物流"])
|
| 371 |
+
call = {"name": tool_name, "arguments": {"waybill_no": no}}
|
| 372 |
+
res = await call_tool(call["name"], call["arguments"])
|
| 373 |
+
a = f"抱歉,系统里没有找到运单 {no}。请核对运单号是否正确(本系统运单号为 LL 开头加 10 位数字),也可以告诉我寄/收件人手机号,我用手机号帮您查。"
|
| 374 |
+
elif kind == "bad_city_eta":
|
| 375 |
+
c1 = rnd.choice(unknown_cities); c2 = rnd.choice(list(CITY_PROVINCE))
|
| 376 |
+
u = f"{c1}到{c2}几天能到?"
|
| 377 |
+
call = {"name": "estimate_eta", "arguments": {"origin": c1, "destination": c2, "service": "标准快递"}}
|
| 378 |
+
res = await call_tool(call["name"], call["arguments"])
|
| 379 |
+
near = rnd.sample(list(CITY_PROVINCE), 2)
|
| 380 |
+
a = f"抱歉,{c1} 暂不在系统收录的城市范围内,无法直接估算时效。您可以确认一下城市名,或者选择邻近的已开通城市(如 {near[0]}、{near[1]})再帮您估算。"
|
| 381 |
+
elif kind == "bad_city_freight":
|
| 382 |
+
c1 = rnd.choice(list(CITY_PROVINCE)); c2 = rnd.choice(unknown_cities)
|
| 383 |
+
item, w = rnd.choice(ITEMS)
|
| 384 |
+
u = f"从{c1}寄{item}到{c2},{w}kg 多少钱?"
|
| 385 |
+
call = {"name": "calc_freight", "arguments": {"origin": c1, "destination": c2, "weight_kg": w}}
|
| 386 |
+
res = await call_tool(call["name"], call["arguments"])
|
| 387 |
+
a = f"抱歉,{c2} 暂不在已开通的计费城市范围内,算不了这条线路的运费。请确认城市名是否有误,或告诉我具体想寄到哪个已开通城市。"
|
| 388 |
+
elif kind == "intl_freight": # 境外目的地:工具返回未收录,不得编造总价
|
| 389 |
+
c1 = rnd.choice(list(CITY_PROVINCE)); c2 = rnd.choice(foreign_cities)
|
| 390 |
+
item, w = rnd.choice(ITEMS)
|
| 391 |
+
u = rnd.choice([f"从{c1}寄{item}到{c2}多少钱?", f"{c1}发{w}kg{item}去{c2},运费怎么算", f"寄东西到{c2}要多少运费"])
|
| 392 |
+
args = {"origin": c1, "destination": c2, "weight_kg": w} if c1 in u else {"origin": "上海", "destination": c2, "weight_kg": w}
|
| 393 |
+
call = {"name": "calc_freight", "arguments": args}
|
| 394 |
+
res = await call_tool(call["name"], call["arguments"])
|
| 395 |
+
a = (f"抱歉,{c2} 是境外城市,本系统的运费计算只覆盖国内已开通城市,无法直接报价。"
|
| 396 |
+
"国际件请走国际业务:国际快递(DHL/FedEx/UPS)约 3–7 天,适合文件和样品;空运 5–10 天适合批量货;海运最便宜但需 20–45 天。"
|
| 397 |
+
"告诉我货物类型、重量和时效要求,我可以帮您选合适的方式。")
|
| 398 |
+
elif kind == "bad_city_route":
|
| 399 |
+
c1 = rnd.choice(unknown_cities); c2 = rnd.choice(list(CITY_PROVINCE))
|
| 400 |
+
u = f"{c1}到{c2}的干线怎么走?"
|
| 401 |
+
call = {"name": "plan_route", "arguments": {"origin": c1, "destination": c2}}
|
| 402 |
+
res = await call_tool(call["name"], call["arguments"])
|
| 403 |
+
a = f"抱歉,路网数据里没有收录 {c1},无法规划这条干线。如果您指的是其他城市请更正一下;也可以从收录的邻近枢纽城市出发规划。"
|
| 404 |
+
else: # no_result_phone
|
| 405 |
+
ph = f"13{rnd.randint(100000000, 999999999)}"
|
| 406 |
+
u = rnd.choice([f"手机号 {ph} 名下有哪些运单?", f"用 {ph} 帮我查下快递"])
|
| 407 |
+
call = {"name": "search_waybills", "arguments": {"phone": ph}}
|
| 408 |
+
res = await call_tool(call["name"], call["arguments"])
|
| 409 |
+
a = f"手机号 {ph} 名下没有查到运单。请确认号码是否为下单时预留的手机号,或直接提供运单号(LL 开头 12 位)我来查询。"
|
| 410 |
+
out.append(sample(SYSTEM_TOOLS, [{"role": "user", "content": u}, tc(call), tr(res), {"role": "assistant", "content": a}]))
|
| 411 |
+
return out
|
| 412 |
+
|
| 413 |
+
|
| 414 |
async def main():
|
| 415 |
ap = argparse.ArgumentParser()
|
| 416 |
ap.add_argument("--n-kb", type=int, default=200)
|
| 417 |
ap.add_argument("--n-tool", type=int, default=300)
|
| 418 |
ap.add_argument("--n-extract", type=int, default=150)
|
| 419 |
ap.add_argument("--n-route", type=int, default=100)
|
| 420 |
+
ap.add_argument("--n-fail", type=int, default=60)
|
| 421 |
ap.add_argument("--valid-ratio", type=float, default=0.08)
|
| 422 |
ap.add_argument("--skip-kb", action="store_true", help="不用教师模型生成知识问答")
|
| 423 |
ap.add_argument("--kb-cache", default=str(config.DATA_DIR / "seed" / "teacher_kb_qa.json"), help="已生成的知识问答缓存,存在则直接复用")
|
|
|
|
| 428 |
print("生成工具调用样本…"); data += await gen_tool(a.n_tool)
|
| 429 |
print("生成抽取样本…"); data += gen_extract(a.n_extract)
|
| 430 |
print("生成路径样本…"); data += await gen_route(a.n_route)
|
| 431 |
+
print("生成失败/边界样本…"); data += await gen_failures(a.n_fail)
|
| 432 |
cache = Path(a.kb_cache)
|
| 433 |
if not a.skip_kb and a.n_kb > 0 and cache.exists():
|
| 434 |
pairs = json.load(open(cache, encoding="utf-8"))
|
|
|
|
| 452 |
d = json.loads(l)
|
| 453 |
data.append(sample(SYSTEM_PLAIN, d["messages"][1:])) # 手写种子也注入检索上下文
|
| 454 |
|
| 455 |
+
# 会话级去重(避免同一轨迹既进训练又进验证)
|
| 456 |
+
seen, uniq = set(), []
|
|
|
|
| 457 |
for d in data:
|
| 458 |
+
key = json.dumps(d["messages"], ensure_ascii=False, sort_keys=True)
|
| 459 |
+
if key not in seen:
|
| 460 |
+
seen.add(key)
|
| 461 |
+
uniq.append(d)
|
| 462 |
+
print(f"会话去重:{len(data)} → {len(uniq)}")
|
| 463 |
+
|
| 464 |
+
# 按用户问题分组后切分训练/验证,再在各自分区内展开工具调用前缀
|
| 465 |
+
# (mlx_lm --mask-prompt 只训最后一条 assistant,前缀样本让模型学会发出调用;
|
| 466 |
+
# 同一问题的会话/前缀必须落在同一分区,否则验证损失虚低)
|
| 467 |
+
groups: dict = {}
|
| 468 |
+
for d in uniq:
|
| 469 |
+
groups.setdefault(d["messages"][1]["content"], []).append(d)
|
| 470 |
+
gkeys = list(groups)
|
| 471 |
+
rnd.shuffle(gkeys)
|
| 472 |
+
valid_sessions, train_sessions, nv = [], [], max(10, int(len(uniq) * a.valid_ratio))
|
| 473 |
+
for k in gkeys:
|
| 474 |
+
(valid_sessions if len(valid_sessions) < nv else train_sessions).extend(groups[k])
|
| 475 |
+
|
| 476 |
+
def expand(sessions):
|
| 477 |
+
out_rows = list(sessions)
|
| 478 |
+
for d in sessions:
|
| 479 |
+
msgs = d["messages"]
|
| 480 |
+
for i, m in enumerate(msgs):
|
| 481 |
+
if m["role"] == "assistant" and m.get("tool_calls") and i < len(msgs) - 1:
|
| 482 |
+
out_rows.append({**d, "messages": msgs[:i + 1]})
|
| 483 |
+
return out_rows
|
| 484 |
+
|
| 485 |
+
train_rows, valid_rows = expand(train_sessions), expand(valid_sessions)
|
| 486 |
+
rnd.shuffle(train_rows)
|
| 487 |
with open(OUT / "valid.jsonl", "w", encoding="utf-8") as f:
|
| 488 |
+
for x in valid_rows:
|
| 489 |
f.write(json.dumps(x, ensure_ascii=False) + "\n")
|
| 490 |
with open(OUT / "train.jsonl", "w", encoding="utf-8") as f:
|
| 491 |
+
for x in train_rows:
|
| 492 |
f.write(json.dumps(x, ensure_ascii=False) + "\n")
|
| 493 |
+
print(f"完成:train {len(train_rows)} 条(会话 {len(train_sessions)}),valid {len(valid_rows)} 条(会话 {len(valid_sessions)})→ {OUT}")
|
| 494 |
|
| 495 |
|
| 496 |
if __name__ == "__main__":
|
scripts/publish_hf.py
CHANGED
|
@@ -22,7 +22,7 @@ if not a.skip_space:
|
|
| 22 |
for d in ["logicllm", "data/kb", "data/seed", "eval", "scripts"]:
|
| 23 |
shutil.copytree(root / d, tmp / d, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
| 24 |
front = ("---\ntitle: 物流通 · 物流行业大模型\nemoji: 🚚\ncolorFrom: yellow\ncolorTo: gray\nsdk: gradio\napp_file: app.py\n"
|
| 25 |
-
"pinned: false\nlicense: apache-2.0\nshort_description:
|
| 26 |
f"models:\n - Qwen/Qwen3-14B\n - {a.owner}/{a.model}\n---\n\n")
|
| 27 |
(tmp / "README.md").write_text(front + (root / "README.md").read_text())
|
| 28 |
api.upload_folder(repo_id=space, repo_type="space", folder_path=str(tmp), commit_message="update space")
|
|
|
|
| 22 |
for d in ["logicllm", "data/kb", "data/seed", "eval", "scripts"]:
|
| 23 |
shutil.copytree(root / d, tmp / d, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
| 24 |
front = ("---\ntitle: 物流通 · 物流行业大模型\nemoji: 🚚\ncolorFrom: yellow\ncolorTo: gray\nsdk: gradio\napp_file: app.py\n"
|
| 25 |
+
"pinned: false\nlicense: apache-2.0\nshort_description: Qwen3-14B + 物流 LoRA + RAG + 工具调用\n"
|
| 26 |
f"models:\n - Qwen/Qwen3-14B\n - {a.owner}/{a.model}\n---\n\n")
|
| 27 |
(tmp / "README.md").write_text(front + (root / "README.md").read_text())
|
| 28 |
api.upload_folder(repo_id=space, repo_type="space", folder_path=str(tmp), commit_message="update space")
|
scripts/seed_tms_demo.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""往本地零担通 TMS(Dev Tenant)种一批演示数据:3 个网点、1 个客户、路由规则、3 张运单、若干轨迹事件。
|
| 2 |
+
幂等:按名称/单号存在即跳过。用法:uv run python scripts/seed_tms_demo.py
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import datetime
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
from logicllm.tools.tms import BASE, _api, _items # 复用登录与请求封装
|
| 10 |
+
|
| 11 |
+
SITES = [
|
| 12 |
+
{"name": "杭州西湖网点", "roles": ["origin", "destination"], "addr": ("浙江", "杭州", "西湖")},
|
| 13 |
+
{"name": "深圳南山网点", "roles": ["origin", "destination"], "addr": ("广东", "深圳", "南山")},
|
| 14 |
+
{"name": "成都金牛网点", "roles": ["origin", "destination"], "addr": ("四川", "成都", "金牛")},
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def ensure_site(spec) -> dict:
|
| 19 |
+
res = _api("GET", "/sites", params={"keyword": spec["name"], "page_size": 5})
|
| 20 |
+
for s in _items(res)[0]:
|
| 21 |
+
if s["name"] == spec["name"]:
|
| 22 |
+
return s
|
| 23 |
+
p, c, dst = spec["addr"]
|
| 24 |
+
body = {
|
| 25 |
+
"name": spec["name"], "node_type": "site", "cooperation_type": "direct", "roles": spec["roles"],
|
| 26 |
+
"address": {"province": p, "city": c, "district": dst, "detail": f"{c}市{dst}区物流园1号"},
|
| 27 |
+
"contact_admin": {"name": "站长", "phone": "13800000001"},
|
| 28 |
+
}
|
| 29 |
+
r = _api("POST", "/sites", json=body)
|
| 30 |
+
if r.get("error"):
|
| 31 |
+
sys.exit(f"创建网点失败: {r}")
|
| 32 |
+
print("创建网点:", spec["name"])
|
| 33 |
+
return r
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def ensure_customer() -> dict:
|
| 37 |
+
res = _api("GET", "/customers", params={"page_size": 20})
|
| 38 |
+
for cu in _items(res)[0]:
|
| 39 |
+
if cu["name"] == "杭州云货科技有限公司":
|
| 40 |
+
return cu
|
| 41 |
+
body = {
|
| 42 |
+
"code": "CUST-DEMO-001", "name": "杭州云货科技有限公司", "short_name": "云货科技",
|
| 43 |
+
"contacts": [{"name": "王小明", "phone": "13812345678", "role": "logistics"}],
|
| 44 |
+
"settlement": {"cycle": "cash_on_delivery"}, "credit": {"limit": "0"},
|
| 45 |
+
}
|
| 46 |
+
r = _api("POST", "/customers", json=body)
|
| 47 |
+
if r.get("error"):
|
| 48 |
+
sys.exit(f"创建客户失败: {r}")
|
| 49 |
+
print("创建客户:", body["name"])
|
| 50 |
+
return r
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def ensure_route(frm: dict, to: dict):
|
| 54 |
+
res = _api("GET", "/route-rules", params={"page_size": 50})
|
| 55 |
+
for rr in _items(res)[0]:
|
| 56 |
+
if rr.get("from_site_id") == frm["id"] and rr.get("to_site_id") == to["id"]:
|
| 57 |
+
return rr
|
| 58 |
+
body = {
|
| 59 |
+
"from_site_id": frm["id"], "to_site_id": to["id"], "priority": 0,
|
| 60 |
+
"valid_from": datetime.date.today().isoformat(),
|
| 61 |
+
"legs": [{"from_node_id": frm["id"], "to_node_id": to["id"], "leg_type": "linehaul", "carrier_type": "hq_fleet"}],
|
| 62 |
+
}
|
| 63 |
+
r = _api("POST", "/route-rules", json=body)
|
| 64 |
+
if r.get("error"):
|
| 65 |
+
print(f"路由规则 {frm['name']}→{to['name']} 失败: {r}")
|
| 66 |
+
return None
|
| 67 |
+
print(f"创建路由: {frm['name']} → {to['name']}")
|
| 68 |
+
return r
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def ensure_waybill(cust: dict, frm: dict, to: dict, weight: str, fee: str, receiver: tuple[str, str]):
|
| 72 |
+
body = {
|
| 73 |
+
"customer_id": cust["id"], "customer_name_snapshot": cust["name"],
|
| 74 |
+
"origin_site_id": frm["id"], "destination_site_id": to["id"],
|
| 75 |
+
"origin_address": None,
|
| 76 |
+
"destination_address": {"contact": receiver[0], "phone": receiver[1],
|
| 77 |
+
"province": to["address"]["province"] if isinstance(to.get("address"), dict) else "四川",
|
| 78 |
+
"city": to["name"][:2], "district": "城区"},
|
| 79 |
+
"items_summary": {"count": 3, "weight_kg": weight, "volume_m3": "0.5"},
|
| 80 |
+
"payment_type": "cash_on_delivery", "settlement_snapshot": {"cycle": "cash_on_delivery"},
|
| 81 |
+
"is_cod": False, "cod_amount": "0", "receivable_amount": fee, "cod_service_fee_amount": "0",
|
| 82 |
+
"surcharges": [], "fee_breakdown": {"base": fee, "surcharges": []},
|
| 83 |
+
"pricing_snapshot": {"product_id": None, "discount_rate": "1.0"},
|
| 84 |
+
"business_mode": "ltl_network",
|
| 85 |
+
}
|
| 86 |
+
r = _api("POST", "/waybills", json=body)
|
| 87 |
+
if r.get("error"):
|
| 88 |
+
print(f"创建运单失败 ({frm['name']}→{to['name']}): {r}")
|
| 89 |
+
return None
|
| 90 |
+
print("创建运单:", r.get("waybill_no"), f"{frm['name']} → {to['name']}")
|
| 91 |
+
return r
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def main():
|
| 95 |
+
print("TMS:", BASE)
|
| 96 |
+
sites = {s["name"]: ensure_site(s) for s in SITES}
|
| 97 |
+
cust = ensure_customer()
|
| 98 |
+
hz, sz, cd = sites["杭州西湖网点"], sites["深圳南山网点"], sites["成都金牛网点"]
|
| 99 |
+
for a, b in [(hz, sz), (hz, cd), (sz, cd)]:
|
| 100 |
+
ensure_route(a, b)
|
| 101 |
+
existing, total = _items(_api("GET", "/waybills", params={"page_size": 5}))
|
| 102 |
+
if total >= 3:
|
| 103 |
+
print("已有运单,跳过创建:", [w["waybill_no"] for w in existing[:5]])
|
| 104 |
+
else:
|
| 105 |
+
ensure_waybill(cust, hz, sz, "120", "260.00", ("陈静", "15901112222"))
|
| 106 |
+
ensure_waybill(cust, hz, cd, "50", "200.00", ("王经理", "13800000001"))
|
| 107 |
+
ensure_waybill(cust, sz, cd, "800", "980.00", ("刘强", "15633334444"))
|
| 108 |
+
wb, _t = _items(_api("GET", "/waybills", params={"page_size": 10}))
|
| 109 |
+
print("\n当前运单��")
|
| 110 |
+
for w in wb:
|
| 111 |
+
print(" ", w["waybill_no"], w["status"], w.get("origin_site_name"), "→", w.get("destination_site_name"))
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
main()
|
scripts/serve_mlx.sh
CHANGED
|
@@ -6,12 +6,19 @@
|
|
| 6 |
# 所以应用侧在每个请求里显式带 "adapters": <绝对路径>(见 logicllm/config.py LLM_ADAPTER)。
|
| 7 |
set -euo pipefail
|
| 8 |
cd "$(dirname "$0")/.."
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
ADAPTER="$(pwd)/adapters/logistics-lora"
|
| 11 |
if [ -f "$ADAPTER/adapters.safetensors" ]; then
|
| 12 |
echo "基座 $BASE + LoRA $ADAPTER"
|
| 13 |
-
exec uv run python -m mlx_lm server --model "$BASE" --adapter-path "$ADAPTER" --port
|
| 14 |
else
|
| 15 |
echo "⚠️ 未找到 LoRA,使用原始基座"
|
| 16 |
-
exec uv run python -m mlx_lm server --model "$BASE" --port
|
| 17 |
fi
|
|
|
|
| 6 |
# 所以应用侧在每个请求里显式带 "adapters": <绝对路径>(见 logicllm/config.py LLM_ADAPTER)。
|
| 7 |
set -euo pipefail
|
| 8 |
cd "$(dirname "$0")/.."
|
| 9 |
+
PORT=${MLX_PORT:-8080}
|
| 10 |
+
if lsof -tnP -iTCP:$PORT -sTCP:LISTEN >/dev/null 2>&1; then
|
| 11 |
+
echo "⚠️ 端口 $PORT 已有服务在运行:"
|
| 12 |
+
ps -o command= -p "$(lsof -tnP -iTCP:$PORT -sTCP:LISTEN | head -1)" | cut -c1-100
|
| 13 |
+
echo "如需重启:pkill -f 'mlx_lm server' 后再运行本脚本;直接使用现有服务则无需任何操作。"
|
| 14 |
+
exit 0
|
| 15 |
+
fi
|
| 16 |
+
BASE=${BASE_MODEL:-mlx-community/Qwen3-14B-4bit}
|
| 17 |
ADAPTER="$(pwd)/adapters/logistics-lora"
|
| 18 |
if [ -f "$ADAPTER/adapters.safetensors" ]; then
|
| 19 |
echo "基座 $BASE + LoRA $ADAPTER"
|
| 20 |
+
exec uv run python -m mlx_lm server --model "$BASE" --adapter-path "$ADAPTER" --port $PORT --host 127.0.0.1
|
| 21 |
else
|
| 22 |
echo "⚠️ 未找到 LoRA,使用原始基座"
|
| 23 |
+
exec uv run python -m mlx_lm server --model "$BASE" --port $PORT --host 127.0.0.1
|
| 24 |
fi
|
scripts/train.sh
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
# LoRA 微调(MLX)。用法:bash scripts/train.sh [iters]
|
|
|
|
| 3 |
set -euo pipefail
|
| 4 |
cd "$(dirname "$0")/.."
|
| 5 |
-
ITERS=${1:-
|
| 6 |
-
BASE=${BASE_MODEL:-mlx-community/
|
| 7 |
-
ADAPTER=adapters/logistics-lora
|
| 8 |
mkdir -p "$ADAPTER"
|
| 9 |
uv run python -m mlx_lm lora \
|
| 10 |
--model "$BASE" \
|
|
@@ -18,6 +19,7 @@ uv run python -m mlx_lm lora \
|
|
| 18 |
--steps-per-eval 100 \
|
| 19 |
--steps-per-report 20 \
|
| 20 |
--max-seq-length 4096 \
|
|
|
|
| 21 |
--mask-prompt \
|
| 22 |
--seed 42
|
| 23 |
echo "训练完成 → $ADAPTER"
|
|
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
# LoRA 微调(MLX)。用法:bash scripts/train.sh [iters]
|
| 3 |
+
# 环境变量:BASE_MODEL 基座;ADAPTER_PATH 输出目录(默认 adapters/logistics-lora,实验版可另设以免覆盖已发布权重)
|
| 4 |
set -euo pipefail
|
| 5 |
cd "$(dirname "$0")/.."
|
| 6 |
+
ITERS=${1:-800}
|
| 7 |
+
BASE=${BASE_MODEL:-mlx-community/Qwen3-14B-4bit}
|
| 8 |
+
ADAPTER=${ADAPTER_PATH:-adapters/logistics-lora}
|
| 9 |
mkdir -p "$ADAPTER"
|
| 10 |
uv run python -m mlx_lm lora \
|
| 11 |
--model "$BASE" \
|
|
|
|
| 19 |
--steps-per-eval 100 \
|
| 20 |
--steps-per-report 20 \
|
| 21 |
--max-seq-length 4096 \
|
| 22 |
+
--grad-checkpoint \
|
| 23 |
--mask-prompt \
|
| 24 |
--seed 42
|
| 25 |
echo "训练完成 → $ADAPTER"
|