zhatrix commited on
Commit
df2d400
·
verified ·
1 Parent(s): fdf0d22

update space

Browse files
.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/Qwen2.5-7B-Instruct-4bit
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 数据(需要 LLM 后端在线用于知识问答;否则加 --skip-kb)
8
- uv run python scripts/gen_sft.py
9
- train: ## LoRA 微调(默认 600 iters)
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: Qwen2.5-7B + 物流 LoRA + RAG + 工具调用
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,20 例,规则打分)
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
- | **Qwen3-14B + LoRA(600 步 ≈1 epoch,Val loss 0.031)** | **0.990** | 1.000 | 1.000 | 0.900 | 1.000 |
 
 
 
 
 
 
 
 
137
 
138
  当前默认基座:`mlx-community/Qwen3-14B-4bit`(训练时 `BASE_MODEL` 可换回 7B)。教师问答由人工精编(`data/seed/teacher_kb_qa.json`,127 条,含数值比较靶向题)。
139
 
140
  微调解决了:抽取按约定输出 JSON、运费不漏尺寸、未指明服务类型时默认标准快递并说明、"53 度 vs 70%"类数值比较。
141
- 剩余:省份偶尔输出全称(评测严格);训练分布外的数值套档仍可能出错(如 1600km 套错档),可继续加变体样本。
 
 
 
 
 
 
 
 
 
 
 
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,需防碎包装,只能陆运;> 70% 禁寄。
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": "70 度(含 70% 以上酒精饮品)禁寄;24–70 度限每件 5L 且陆运;24 度以下不限。判断时把酒精度和 70 比:低于 70 就能寄。"
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
- return SYSTEM_BASE + (RAG_BLOCK.format(context=context) if context else "")
 
 
 
 
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/Qwen2.5-7B-Instruct-4bit")
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.get(origin), CITY_PROVINCE.get(dest)
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 "") + (f" + 保价费{insurance:.2f}元" if insurance 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, rnd.choice(names), f"13{rnd.randint(100000000, 999999999)}", o,
52
- rnd.choice(names), f"15{rnd.randint(100000000, 999999999)}", d,
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
- model, tok = load("mlx-community/Qwen2.5-7B-Instruct-4bit", adapter_path=adapter)
10
- prompt = tok.apply_chat_template(msgs, tools=openai_tools(all_specs()), add_generation_prompt=True)
 
 
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() if l.strip()]
 
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
- res = await agent.run([{"role": "user", "content": c["q"]}])
60
- s, note = score(c, res.answer, res.steps)
 
 
 
 
 
 
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
- print(f"{flag} [{c['cat']}] {c['q'][:40]} {s:.2f} {note}")
 
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/Qwen2.5-7B-Instruct-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)"
 
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": a}]))
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 "您未指明服务类型,按默认的标准快递计算(如需特快/经济请告诉我)。\n") + (f"{o} → {d}({res['zone']}),计费重量 {res['billable_weight_kg']}kg"
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
- u = rnd.choice([f"{o}到{d}{svc}几天能到?", f"今天从{o}发{svc}到{d},什么时候到", f"{o}寄{d}要多久"])
 
 
 
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": a}]))
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(CITY_PROVINCE))
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([f"{prov}省", prov, ""])
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
- # mlx_lm --mask-prompt 只对最后一条 assistant 消息计算 loss,
324
- # 所以把每个工具调用轨迹额外拆出"到该次工具调用为止"的前缀样本,让模型学会发出调用。
325
- expanded = []
326
  for d in data:
327
- msgs = d["messages"]
328
- for i, m in enumerate(msgs):
329
- if m["role"] == "assistant" and m.get("tool_calls") and i < len(msgs) - 1:
330
- expanded.append({**d, "messages": msgs[:i + 1]})
331
- data += expanded
332
- print(f"拆分出工具调用前缀样本 {len(expanded)} 条")
333
-
334
- rnd.shuffle(data)
335
- nv = max(10, int(len(data) * a.valid_ratio))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  with open(OUT / "valid.jsonl", "w", encoding="utf-8") as f:
337
- for x in data[:nv]:
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 data[nv:]:
341
  f.write(json.dumps(x, ensure_ascii=False) + "\n")
342
- print(f"完成:train {len(data) - nv} 条,valid {nv} 条 → {OUT}")
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: Qwen2.5-7B + 物流 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")
 
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
- BASE=${BASE_MODEL:-mlx-community/Qwen2.5-7B-Instruct-4bit}
 
 
 
 
 
 
 
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 8080 --host 127.0.0.1
14
  else
15
  echo "⚠️ 未找到 LoRA,使用原始基座"
16
- exec uv run python -m mlx_lm server --model "$BASE" --port 8080 --host 127.0.0.1
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:-600}
6
- BASE=${BASE_MODEL:-mlx-community/Qwen2.5-7B-Instruct-4bit}
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"