✨ feat: add order-attachment LLM classifier
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Logs & runtime output
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Test & type caches
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Editor / OS (kept in case this folder is used standalone)
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Project-local agent data (uncomment if you do NOT want to track it)
|
||||||
|
.workbuddy/
|
||||||
200
README.md
Normal file
200
README.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# 布莱迪压力表 - 订单附件识别工具
|
||||||
|
|
||||||
|
根据"总排号"从 SQL Server 查询"新参数"字段,调用大语言模型判断该订单是否携带
|
||||||
|
附件,并以 JSON 输出结果。支持粗分类(资料/配件/耗材)和精分类("大类:细分
|
||||||
|
类目",具体到针型阀、说明书等)两种粒度。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
`pyodbc` 需要系统已安装对应的 ODBC 驱动(如 "ODBC Driver 17 for SQL Server")。
|
||||||
|
若服务器上已有 SQL Server 管理工具/客户端环境,通常已包含该驱动;否则需自行
|
||||||
|
安装 Microsoft 官方 ODBC Driver。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
编辑 `config.yaml`,填入以下三部分(模板中的 `CHANGE_ME` 必须替换):
|
||||||
|
|
||||||
|
- `database`:SQL Server 连接信息(含 `schema`)、表名、字段名
|
||||||
|
- `llm`:OpenAI 兼容接口的 `base_url`、`api_key`、`model`
|
||||||
|
- `business`:并发数、日志级别、默认分类模式(`default_mode`)、日志目录(`log_dir`)、
|
||||||
|
是否启用"其他"兜底类目(`enable_other_category`,默认关闭)
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 单个总排号,默认粗分类
|
||||||
|
python main.py --id 26B742
|
||||||
|
|
||||||
|
# 批量,逗号分隔
|
||||||
|
python main.py --id 26B742,26B743,26B744
|
||||||
|
|
||||||
|
# 批量,文件输入(每行一个总排号)
|
||||||
|
python main.py --ids-file ids.txt
|
||||||
|
|
||||||
|
# 精分类:输出"大类:细分类目"组合
|
||||||
|
python main.py --id 26B742 --mode fine
|
||||||
|
|
||||||
|
# 指定其他配置文件
|
||||||
|
python main.py --id 26B742 --config other_config.yaml
|
||||||
|
|
||||||
|
# 临时覆盖日志目录
|
||||||
|
python main.py --id 26B742 --log-dir /tmp/debug_logs
|
||||||
|
|
||||||
|
# 格式化输出(默认是紧凑的 JSON Lines,每行一条)
|
||||||
|
python main.py --id 26B742,26B743 --pretty
|
||||||
|
|
||||||
|
# 允许模型使用"其他"兜底类目
|
||||||
|
python main.py --id 26B742 --mode fine --enable-other
|
||||||
|
```
|
||||||
|
|
||||||
|
## 分类粒度:coarse / fine
|
||||||
|
|
||||||
|
由 `--mode` 参数或配置文件 `business.default_mode` 指定,`--mode` 优先级更高。
|
||||||
|
|
||||||
|
- **coarse(默认)**:只判断三个大类——资料 / 配件 / 耗材
|
||||||
|
- **fine**:在同一个 `types` 字段里直接给出"大类:细分类目",例如
|
||||||
|
"配件:针型阀""资料:说明书"
|
||||||
|
|
||||||
|
两种模式对应的提示词都定义在 `src/prompts.py` 中,修改分类边界或细分类目
|
||||||
|
枚举,只需要改这一个文件。
|
||||||
|
|
||||||
|
### 分类体系(已与业务方确认边界)
|
||||||
|
|
||||||
|
**资料类**(12 个细分):出厂检测检验报告、材质证明类、说明书、
|
||||||
|
检验记录过程性、图纸类、标定校验证书、质量证明书、原产地证明、检验合格证书
|
||||||
|
综合、其他交工文件、营业执照、型式检验报告
|
||||||
|
|
||||||
|
**配件类**(25 个细分):针型阀、球阀、截止阀、旋塞阀、角阀、阀组、冷凝圈、
|
||||||
|
冷凝管、冷凝弯、虹吸管、表弯管、缓冲管缓冲弯、法兰隔膜、隔离器、过压保护器、
|
||||||
|
散热器散热片、铅封、转换接头、焊接接头短节短管、卡箍抱箍、紧固件、活接头、
|
||||||
|
接线盒、电缆插头、变送器
|
||||||
|
|
||||||
|
**耗材类**(1 个细分):垫片(四氟/紫铜/缠绕/密封圈等各种材质,单列为
|
||||||
|
第三大类,不算在配件里,避免大量小垫片稀释"配件"标签的信息量)
|
||||||
|
|
||||||
|
**其他类(可选,默认关闭)**:由 `--enable-other` 或配置文件
|
||||||
|
`business.enable_other_category` 开启。开启后新增第四大类"其他",只有
|
||||||
|
唯一细分值"其他",作为兜底——遇到确实是随货附件、但不属于以上任何一个
|
||||||
|
具体细分类目的情况才使用。关闭时模型和格式校验都不知道这个选项的存在,
|
||||||
|
行为与不支持"其他"之前完全一致。
|
||||||
|
|
||||||
|
**明确不算配件**:位号牌/铭牌/标牌(标识件);缓冲钉/阻尼钉/阻尼帽(均为
|
||||||
|
工艺处理,不算随货实物配件)
|
||||||
|
|
||||||
|
**明确不算资料**:装箱单/送货单/贴箱等物流包装指令;合格证(无论参数文本
|
||||||
|
是否提到,一律忽略——既不影响"是否携带附件"的判断,也不会出现在细分
|
||||||
|
类目里)
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
默认逐行输出 JSON(JSON Lines),便于管道处理和逐条消费:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"zong_pai_hao": "26B742", "status": "ok", "has_attachment": true, "types": ["资料"]}
|
||||||
|
{"zong_pai_hao": "26B744", "status": "ok", "has_attachment": true, "types": ["配件:针型阀", "配件:表弯管"]}
|
||||||
|
{"zong_pai_hao": "26B999", "status": "not_found", "has_attachment": null, "types": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
`types` 是唯一的类型字段,不再有单独的 `fine_types`:
|
||||||
|
- `coarse` 模式下,`types` 是大类列表,如 `["资料", "配件"]`。
|
||||||
|
- `fine` 模式下,`types` 里每一项都是"大类:细分类目",如
|
||||||
|
`["资料:说明书", "配件:针型阀"]`,同一个订单可能同时出现多个大类、多个细分。
|
||||||
|
- 若启用了"其他"兜底类目,对应项会体现为 `"其他:其他"`,格式与其余项保持一致。
|
||||||
|
|
||||||
|
### status 字段说明
|
||||||
|
|
||||||
|
| status | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `ok` | 识别成功 |
|
||||||
|
| `not_found` | 数据库中查不到该总排号 |
|
||||||
|
| `empty_param` | 总排号存在,但"新参数"字段为空,未调用 LLM |
|
||||||
|
| `llm_call_error` | LLM 调用失败(网络/接口错误),重试耗尽 |
|
||||||
|
| `llm_format_error` | LLM 输出内容不符合约定格式,重试耗尽 |
|
||||||
|
| `db_error` | 数据库连接/查询失败,影响整批请求 |
|
||||||
|
|
||||||
|
`status` 非 `ok` 时,`has_attachment` 为 `null`,`types` 为空数组,
|
||||||
|
不会有猜测性的默认值混入结果。
|
||||||
|
|
||||||
|
## 对话日志
|
||||||
|
|
||||||
|
每个实际发起 LLM 调用的总排号,都会在日志目录(默认 `logs/`,可用
|
||||||
|
`--log-dir` 或配置文件 `business.log_dir` 指定)下生成一个独立的日志文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
logs/26B742_20260723_153012_123456.log
|
||||||
|
```
|
||||||
|
|
||||||
|
日志内容为纯文本,完整记录:
|
||||||
|
- 发给模型的完整对话(system prompt + few-shot 示例 + 实际用户消息)
|
||||||
|
- 模型的每一次原始回复——**包括被格式校验判定无效、触发重试的那些**
|
||||||
|
- 每次尝试的格式校验结果(通过/失败及原因)
|
||||||
|
- 最终的解析结果和 status
|
||||||
|
|
||||||
|
同一总排号被重复处理不会覆盖旧日志(文件名带精确到微秒的时间戳),方便对比
|
||||||
|
"调整提示词前后,同一条记录的判断有没有变化"。
|
||||||
|
|
||||||
|
`not_found`(查不到)和 `empty_param`(参数为空)的总排号不会生成日志文件,
|
||||||
|
因为它们本就没有与 LLM 的对话内容可记。
|
||||||
|
|
||||||
|
## 故障排查:模型回复为空(推理型模型特有)
|
||||||
|
|
||||||
|
如果日志里出现"[调用结果] 已收到模型回复"但"[模型原始回复]"下面是空的(或
|
||||||
|
`status` 为 `llm_call_error`,错误信息提到"推理耗尽了max_tokens预算"),这是
|
||||||
|
带思维链(thinking/reasoning)能力的模型的已知行为——像 DeepSeek-V4 系列,
|
||||||
|
默认会先打一段思考草稿再给正文,而 `max_tokens` 限制的是"思考+正文"的总量。
|
||||||
|
如果思考阶段把预算用完,正文就会被截断成空字符串。
|
||||||
|
|
||||||
|
本工具通过两处配置应对:
|
||||||
|
- `llm.max_tokens`:调大到能覆盖"思考+正文"的总量(默认 1024),而不是只按
|
||||||
|
正文那两三行的长度来设。
|
||||||
|
- `llm.disable_thinking`:设为 `true` 时会通过 `extra_body={"thinking":
|
||||||
|
{"type": "disabled"}}` 关闭推理链——分类任务规则清晰、不需要模型思考,关闭
|
||||||
|
后响应更快、更省 token,也从根源上避免"思考耗尽预算"的问题。仅 DeepSeek-V4
|
||||||
|
系列等支持该参数的模型有效;换用其他不支持该参数的模型时改回 `false`
|
||||||
|
(多数接口对不认识的字段会直接忽略,但不保证所有厂商都是如此)。
|
||||||
|
|
||||||
|
## 设计说明
|
||||||
|
|
||||||
|
**为什么 LLM 不直接输出 JSON?**
|
||||||
|
JSON 的括号、引号、转义更容易被模型写错。约定 LLM 只输出"标签: 值"格式的
|
||||||
|
纯文本(coarse 模式 2 行,fine 模式 3 行),程序侧用严格的正则做格式校验——
|
||||||
|
校验通过才提取字段、组装成本工具自己定义的 JSON 结构;校验失败会触发重试
|
||||||
|
(次数由配置文件 `business.format_retry` 控制,与 LLM 网络层重试 `llm.max_retry`
|
||||||
|
分开计数),重试仍失败则标记 `llm_format_error`,绝不把不确定的内容硬凑进
|
||||||
|
最终结果。
|
||||||
|
|
||||||
|
**fine 模式的交叉自洽校验:**
|
||||||
|
第三行(细分类目)和第二行(大类)必须自洽——比如模型选了"针型阀",第二行
|
||||||
|
就必须包含"配件",否则视为模型输出自相矛盾,同样触发格式校验失败重试。
|
||||||
|
校验通过后,`parser.py` 会把每个细分类目和它所属的大类拼接成"大类:细分类目"
|
||||||
|
(如"配件:针型阀"),作为最终对外的 `types`;程序内部不再单独保留"大类列表"
|
||||||
|
和"细分类目列表"两份数据。
|
||||||
|
|
||||||
|
**"其他"兜底类目是怎么接入这套校验体系的:**
|
||||||
|
"其他"被当成第四个大类,且细分类目固定只有"其他"自身一个值(`其他:其他`),
|
||||||
|
不允许模型编造具体名称——这样既给了"枚举之外的附件"一个去处,又不破坏
|
||||||
|
"细分类目必须严格匹配枚举"这条不信任 LLM 输出的核心原则。是否启用由
|
||||||
|
`enable_other` 参数控制(对应 `--enable-other` 或配置文件
|
||||||
|
`business.enable_other_category`),并且同一次调用里,提示词(`prompts.py`)
|
||||||
|
和格式校验(`parser.py`)用的 `enable_other` 必须一致,否则会出现"提示词
|
||||||
|
允许但校验拒绝"的不一致——这层一致性由 `classifier.py` 统一负责传递。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── config.yaml # 配置文件
|
||||||
|
├── main.py # 命令行入口
|
||||||
|
├── requirements.txt
|
||||||
|
└── src/
|
||||||
|
├── config_loader.py # YAML 配置读取与校验
|
||||||
|
├── db.py # SQL Server 查询 (pyodbc)
|
||||||
|
├── prompts.py # 提示词与细分类目枚举(唯一需要改分类边界时编辑的文件)
|
||||||
|
├── llm_client.py # LLM 调用 (OpenAI 兼容接口)
|
||||||
|
├── parser.py # LLM 输出格式校验与清洗 → 结构化数据
|
||||||
|
├── order_logger.py # 每个总排号一份的完整对话日志
|
||||||
|
└── classifier.py # 编排:查库 -> 调LLM -> 校验解析 -> 记日志 -> 组装结果
|
||||||
|
```
|
||||||
168
attachment_classifier.py
Normal file
168
attachment_classifier.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
布莱迪压力表 - 订单附件识别批处理脚本
|
||||||
|
读取 Excel 中"新参数"字段,调用大模型判断每条订单是否携带附件,
|
||||||
|
并对附件类型(资料/配件)做分类,结果写回 Excel。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
pip install openpyxl anthropic
|
||||||
|
export ANTHROPIC_API_KEY=sk-...
|
||||||
|
python attachment_classifier.py 输入.xlsx 输出.xlsx
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import concurrent.futures as cf
|
||||||
|
import openpyxl
|
||||||
|
from anthropic import Anthropic
|
||||||
|
|
||||||
|
# ============ 配置 ============
|
||||||
|
MODEL = "claude-sonnet-5" # 分类任务用 Sonnet 足够,性价比高
|
||||||
|
MAX_WORKERS = 8 # 并发线程数,按你的 API 速率上限调整
|
||||||
|
MAX_RETRY = 3 # 单条失败重试次数
|
||||||
|
PARAM_COL_NAME = "新参数" # 参数所在列名(合同工作表)
|
||||||
|
CONTRACT_SHEET = None # None=自动取第一个含"新参数"列的表;也可写死表名
|
||||||
|
|
||||||
|
client = Anthropic() # 自动读取 ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
# ============ 提示词 ============
|
||||||
|
SYSTEM_PROMPT = """你是布莱迪压力表公司的订单参数审核助手。你的任务是判断一条订单的"技术参数"文本中,除了压力表产品本体之外,是否还要求交付"附件"。
|
||||||
|
|
||||||
|
【背景】公司生产压力表(用于化工行业)。参数文本里大部分内容是描述产品本体的规格,这些都不是附件。
|
||||||
|
|
||||||
|
【什么是产品本体(不算附件)】
|
||||||
|
表壳直径/材质、精度等级、防护等级(IP)、过压保护、弹性元件材质、接头材质、接头螺纹、安装方式、表盘材质/刻度/颜色、量程、充油、玻璃、指针、调零装置、缓冲钉/阻尼钉、结构代号(如513/531)、卡盘尺寸、以及各种打标/示值/红线标识要求。
|
||||||
|
|
||||||
|
【附件 = 产品本体之外,随货交付的东西,分两类】
|
||||||
|
|
||||||
|
一、资料类(交付给客户的技术文件):
|
||||||
|
合格证、检测报告、检验报告、出厂报告、材质证明/材质单/材质报告、说明书(使用/安装/维护)、标定证书/校验证书、制造记录/检验记录、图纸、含技术信息的二维码/标签。
|
||||||
|
※ 注意:装箱单、送货单、贴箱单、发货章、包装/箱体标注等,属于物流包装指令,不算资料。
|
||||||
|
|
||||||
|
二、配件类(随货的独立实物零件):
|
||||||
|
针型阀、二/三阀组、冷凝管/冷凝圈/冷凝弯、表弯管/缓冲弯、虹吸管/存水弯、过压保护器/保护罐、散热器、焊接接头/连接接头、法兰隔膜/隔膜、活接、卡箍、支架、单独提供的垫片、转接件等,凡是随表一起出货的独立零件都算。
|
||||||
|
※ 注意:位号牌、铭牌、标牌、号牌属于标识件,不算配件。缓冲钉/阻尼钉是打进接头的工艺,不算配件。
|
||||||
|
|
||||||
|
【判断步骤】
|
||||||
|
1. 逐项扫描文本,剔除所有"产品本体"描述。
|
||||||
|
2. 看剩下的内容里是否出现资料类或配件类的东西。
|
||||||
|
3. 只要出现任一类,has_attachment 就是"是";两类都没有就是"否"。
|
||||||
|
|
||||||
|
【输出要求】
|
||||||
|
只输出一个 JSON 对象,不要任何解释、不要 markdown 代码块:
|
||||||
|
{"has_attachment":"是或否","types":["资料"或"配件"或两者],"items":["具体识别到的附件名称"],"reason":"一句话依据"}
|
||||||
|
- 若 has_attachment 为"否",types 和 items 都为空数组 []。
|
||||||
|
- types 只能包含 "资料" 和/或 "配件"。"""
|
||||||
|
|
||||||
|
# few-shot:正例 + 陷阱负例,显著提升边界准确率
|
||||||
|
FEWSHOT = [
|
||||||
|
("表壳直径:100mm 表壳材质:304SS 精度等级:1.6级 接头螺纹:M20×1.5 带缓冲钉 513结构",
|
||||||
|
'{"has_attachment":"否","types":[],"items":[],"reason":"全部为产品本体规格,缓冲钉是工艺不算配件"}'),
|
||||||
|
("表壳直径:63mm 精度等级:2.5级 带不锈钢位号牌",
|
||||||
|
'{"has_attachment":"否","types":[],"items":[],"reason":"位号牌为标识件,不算配件"}'),
|
||||||
|
("表壳直径:100mm 张贴好装箱单并注明订单号 送货单盖发货章",
|
||||||
|
'{"has_attachment":"否","types":[],"items":[],"reason":"装箱单送货单属物流指令,不算资料"}'),
|
||||||
|
("表壳直径:100mm 提供出厂检测报告 合格证随货",
|
||||||
|
'{"has_attachment":"是","types":["资料"],"items":["出厂检测报告","合格证"],"reason":"要求交付检测报告和合格证"}'),
|
||||||
|
("表壳直径:100mm 针型阀材质:304SS 配316L表弯管",
|
||||||
|
'{"has_attachment":"是","types":["配件"],"items":["针型阀","表弯管"],"reason":"随货提供针型阀和表弯管独立零件"}'),
|
||||||
|
("出厂检验报告 仪表使用说明书 配冷凝圈 过压保护器",
|
||||||
|
'{"has_attachment":"是","types":["资料","配件"],"items":["检验报告","使用说明书","冷凝圈","过压保护器"],"reason":"既有技术文件又有独立零件"}'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_messages(text: str):
|
||||||
|
msgs = []
|
||||||
|
for u, a in FEWSHOT:
|
||||||
|
msgs.append({"role": "user", "content": f'请判断以下订单参数是否携带附件:\n"""\n{u}\n"""'})
|
||||||
|
msgs.append({"role": "assistant", "content": a})
|
||||||
|
msgs.append({"role": "user", "content": f'请判断以下订单参数是否携带附件:\n"""\n{text}\n"""'})
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json(raw: str) -> dict:
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = raw.strip("`")
|
||||||
|
if raw.startswith("json"):
|
||||||
|
raw = raw[4:]
|
||||||
|
s, e = raw.find("{"), raw.rfind("}")
|
||||||
|
if s != -1 and e != -1:
|
||||||
|
raw = raw[s:e + 1]
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(text: str) -> dict:
|
||||||
|
if not text or not str(text).strip():
|
||||||
|
return {"has_attachment": "否", "types": [], "items": [], "reason": "空参数"}
|
||||||
|
last_err = None
|
||||||
|
for _ in range(MAX_RETRY):
|
||||||
|
try:
|
||||||
|
resp = client.messages.create(
|
||||||
|
model=MODEL,
|
||||||
|
max_tokens=400,
|
||||||
|
system=SYSTEM_PROMPT,
|
||||||
|
messages=build_messages(str(text)),
|
||||||
|
)
|
||||||
|
return parse_json(resp.content[0].text)
|
||||||
|
except Exception as ex: # noqa
|
||||||
|
last_err = ex
|
||||||
|
time.sleep(1.5)
|
||||||
|
return {"has_attachment": "错误", "types": [], "items": [],
|
||||||
|
"reason": f"调用失败: {last_err}"}
|
||||||
|
|
||||||
|
|
||||||
|
def find_sheet_and_col(wb):
|
||||||
|
for ws in wb.worksheets:
|
||||||
|
if CONTRACT_SHEET and ws.title != CONTRACT_SHEET:
|
||||||
|
continue
|
||||||
|
headers = [c.value for c in ws[1]]
|
||||||
|
if PARAM_COL_NAME in headers:
|
||||||
|
return ws, headers.index(PARAM_COL_NAME) + 1
|
||||||
|
raise RuntimeError(f'未找到含"{PARAM_COL_NAME}"列的工作表')
|
||||||
|
|
||||||
|
|
||||||
|
def main(in_path: str, out_path: str):
|
||||||
|
wb = openpyxl.load_workbook(in_path)
|
||||||
|
ws, col = find_sheet_and_col(wb)
|
||||||
|
max_row = ws.max_row
|
||||||
|
print(f"工作表: {ws.title} 参数列: 第{col}列 数据行: {max_row - 1}")
|
||||||
|
|
||||||
|
# 追加结果列
|
||||||
|
base = ws.max_column
|
||||||
|
ws.cell(row=1, column=base + 1, value="是否携带附件")
|
||||||
|
ws.cell(row=1, column=base + 2, value="附件类型")
|
||||||
|
ws.cell(row=1, column=base + 3, value="识别明细")
|
||||||
|
ws.cell(row=1, column=base + 4, value="判断依据")
|
||||||
|
|
||||||
|
rows = list(range(2, max_row + 1))
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
with cf.ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
||||||
|
fut = {pool.submit(classify, ws.cell(row=r, column=col).value): r for r in rows}
|
||||||
|
done = 0
|
||||||
|
for f in cf.as_completed(fut):
|
||||||
|
r = fut[f]
|
||||||
|
results[r] = f.result()
|
||||||
|
done += 1
|
||||||
|
if done % 50 == 0:
|
||||||
|
print(f" 进度 {done}/{len(rows)}")
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
d = results[r]
|
||||||
|
ws.cell(row=r, column=base + 1, value=d.get("has_attachment", ""))
|
||||||
|
ws.cell(row=r, column=base + 2, value="、".join(d.get("types", [])))
|
||||||
|
ws.cell(row=r, column=base + 3, value="、".join(d.get("items", [])))
|
||||||
|
ws.cell(row=r, column=base + 4, value=d.get("reason", ""))
|
||||||
|
|
||||||
|
wb.save(out_path)
|
||||||
|
n_yes = sum(1 for d in results.values() if d.get("has_attachment") == "是")
|
||||||
|
print(f"完成 -> {out_path} 携带附件 {n_yes}/{len(rows)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("用法: python attachment_classifier.py 输入.xlsx 输出.xlsx")
|
||||||
|
sys.exit(1)
|
||||||
|
main(sys.argv[1], sys.argv[2])
|
||||||
199
classifier.py
Normal file
199
classifier.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""编排层:串联 数据库查询 -> LLM调用 -> 格式校验解析 -> 组装最终JSON。
|
||||||
|
|
||||||
|
单条记录的处理逻辑(classify_param_text):
|
||||||
|
1. 若新参数为空/None -> 直接判定无附件,不调用LLM(省成本,也没有可判断内容)。
|
||||||
|
2. 调用 LLM 拿到调用结果(LLMCallResult)。
|
||||||
|
- 若调用本身失败(网络/接口错误,重试耗尽) -> 记日志,标记 llm_call_error。
|
||||||
|
3. 用 parser.parse_llm_output(mode=..., enable_other=...) 做严格格式校验。
|
||||||
|
- 校验通过 -> 组装结果,记日志,结束。
|
||||||
|
- 校验失败(FormatError) -> 记日志,重新调用LLM重试。
|
||||||
|
4. 格式校验重试全部耗尽 -> 标记 status="llm_format_error",不让脏数据进入最终结果。
|
||||||
|
|
||||||
|
无论上述哪一步,只要 OrderLogger 存在,每一次尝试都会被完整记录——包括发给
|
||||||
|
模型的完整对话、模型的原始回复(如果有)、格式校验是否通过。这是本模块与
|
||||||
|
日志模块的核心约定:不因为"这次调用失败了"就跳过记录,失败的调用恰恰最
|
||||||
|
需要被记下来供排查。
|
||||||
|
|
||||||
|
批量处理(classify_batch)在此基础上先查数据库拿到 {总排号: 新参数} 映射,
|
||||||
|
对查不到的总排号直接标记 status="not_found",不发起LLM调用(也不生成日志,
|
||||||
|
因为根本没有"新参数"可供判断,没有对话可记)。
|
||||||
|
|
||||||
|
enable_other 控制是否允许模型使用"其他"兜底类目(默认 False),从
|
||||||
|
classify_batch/classify_single 一路透传到 llm_client.classify_raw(决定
|
||||||
|
提示词要不要包含"其他")和 parser.parse_llm_output(决定校验要不要放行
|
||||||
|
"其他")。本模块是唯一同时持有这两个调用点的地方,因此由本模块负责把
|
||||||
|
同一个值传给两边,不暴露给更上层去分别设置从而导致不一致。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from db import DatabaseError, fetch_params_by_ids
|
||||||
|
from llm_client import LLMClient
|
||||||
|
from order_logger import OrderLogger
|
||||||
|
from parser import FormatError, ParsedResult, parse_llm_output
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_result(zong_pai_hao: str, status: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"zong_pai_hao": zong_pai_hao,
|
||||||
|
"status": status,
|
||||||
|
"has_attachment": None,
|
||||||
|
"types": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_param_text(
|
||||||
|
llm_client: LLMClient,
|
||||||
|
param_text: str | None,
|
||||||
|
mode: str = "coarse",
|
||||||
|
format_retry: int = 2,
|
||||||
|
order_logger: OrderLogger | None = None,
|
||||||
|
enable_other: bool = False,
|
||||||
|
) -> tuple[str, bool | None, list[str]]:
|
||||||
|
"""对单条"新参数"文本做分类,返回 (status, has_attachment, types)。
|
||||||
|
|
||||||
|
mode 决定分类粒度:"coarse"只输出大类(资料/配件/耗材,启用 enable_other
|
||||||
|
时还有"其他");"fine"输出"大类:细分类目"(如"配件:针型阀")的列表。
|
||||||
|
不再有单独的 fine_types 返回值——fine 模式下细分类目已经内嵌在 types
|
||||||
|
的每个元素里。
|
||||||
|
|
||||||
|
enable_other 控制是否允许模型使用"其他"兜底那些不在既有枚举范围内的
|
||||||
|
附件,默认关闭;关闭时的行为与不支持"其他"之前完全一致。
|
||||||
|
|
||||||
|
status 取值:
|
||||||
|
"ok" - 正常识别成功
|
||||||
|
"empty_param" - 新参数字段为空,直接判无附件,未调用LLM
|
||||||
|
"llm_call_error" - LLM 网络/接口调用失败(重试耗尽)
|
||||||
|
"llm_format_error" - LLM 返回内容格式校验多次失败(重试耗尽)
|
||||||
|
|
||||||
|
format_retry 控制"格式校验失败后重新请求LLM"的次数,与 LLMClient 内部的
|
||||||
|
网络层重试是两回事:网络重试解决的是"请求没成功",这里解决的是
|
||||||
|
"请求成功了但模型没按格式输出"。两层重试互不影响,各自独立计数。
|
||||||
|
|
||||||
|
order_logger 若提供,会记录本次分类过程中的每一次LLM往返(无论成败)。
|
||||||
|
"""
|
||||||
|
if param_text is None or not str(param_text).strip():
|
||||||
|
if order_logger is not None:
|
||||||
|
order_logger.log_param_text(param_text)
|
||||||
|
order_logger.log_final_result("empty_param", None)
|
||||||
|
return "empty_param", False, []
|
||||||
|
|
||||||
|
if order_logger is not None:
|
||||||
|
order_logger.log_param_text(param_text)
|
||||||
|
|
||||||
|
last_format_err: Exception | None = None
|
||||||
|
for attempt in range(1, format_retry + 1):
|
||||||
|
call_result = llm_client.classify_raw(param_text, mode=mode, enable_other=enable_other)
|
||||||
|
|
||||||
|
if call_result.error is not None:
|
||||||
|
logger.error("LLM 调用失败: %s", call_result.error)
|
||||||
|
if order_logger is not None:
|
||||||
|
order_logger.log_llm_attempt(attempt, call_result, None, None)
|
||||||
|
order_logger.log_final_result("llm_call_error", None)
|
||||||
|
return "llm_call_error", None, []
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = parse_llm_output(call_result.raw_response, mode=mode, enable_other=enable_other)
|
||||||
|
if order_logger is not None:
|
||||||
|
order_logger.log_llm_attempt(attempt, call_result, parsed, None)
|
||||||
|
order_logger.log_final_result("ok", parsed)
|
||||||
|
return "ok", parsed.has_attachment, parsed.types
|
||||||
|
except FormatError as e:
|
||||||
|
last_format_err = e
|
||||||
|
logger.warning(
|
||||||
|
"LLM 输出格式校验失败 (第 %d/%d 次): %s | 原始输出: %r",
|
||||||
|
attempt, format_retry, e, call_result.raw_response,
|
||||||
|
)
|
||||||
|
if order_logger is not None:
|
||||||
|
order_logger.log_llm_attempt(attempt, call_result, None, str(e))
|
||||||
|
|
||||||
|
logger.error("格式校验重试 %d 次后仍失败,放弃: %s", format_retry, last_format_err)
|
||||||
|
if order_logger is not None:
|
||||||
|
order_logger.log_final_result("llm_format_error", None)
|
||||||
|
return "llm_format_error", None, []
|
||||||
|
|
||||||
|
|
||||||
|
def classify_batch(
|
||||||
|
db_cfg: dict[str, Any],
|
||||||
|
llm_cfg: dict[str, Any],
|
||||||
|
zong_pai_hao_list: list[str],
|
||||||
|
max_workers: int = 8,
|
||||||
|
mode: str = "coarse",
|
||||||
|
log_dir: str | Path | None = None,
|
||||||
|
enable_other: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""批量分类入口:查库 -> 并发调用LLM -> 组装结果列表。
|
||||||
|
|
||||||
|
log_dir 若提供,会为每个实际发起LLM调用的总排号在该目录下生成一个日志
|
||||||
|
文件;查库失败(not_found)或参数为空(empty_param)的总排号不生成日志文件,
|
||||||
|
因为它们本就没有与LLM的对话内容可记。
|
||||||
|
|
||||||
|
返回结果顺序与输入 zong_pai_hao_list 一致,即使某些查询失败或格式错误也会
|
||||||
|
补全为对应 status 的占位结果,保证"输入N个总排号,输出N条结果"。
|
||||||
|
"""
|
||||||
|
# 去重但保留顺序,避免用户传入重复总排号导致重复查询/调用
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique_ids: list[str] = []
|
||||||
|
for zph in zong_pai_hao_list:
|
||||||
|
if zph not in seen:
|
||||||
|
seen.add(zph)
|
||||||
|
unique_ids.append(zph)
|
||||||
|
|
||||||
|
try:
|
||||||
|
param_map = fetch_params_by_ids(db_cfg, unique_ids)
|
||||||
|
except DatabaseError as e:
|
||||||
|
logger.error("数据库查询失败,本批次全部标记为 db_error: %s", e)
|
||||||
|
return [_empty_result(zph, "db_error") for zph in zong_pai_hao_list]
|
||||||
|
|
||||||
|
llm_client = LLMClient(llm_cfg)
|
||||||
|
results_by_id: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
ids_to_classify = [zph for zph in unique_ids if param_map.get(zph) is not None]
|
||||||
|
for zph in unique_ids:
|
||||||
|
if param_map.get(zph) is None:
|
||||||
|
results_by_id[zph] = _empty_result(zph, "not_found")
|
||||||
|
|
||||||
|
def _worker(zph: str) -> tuple[str, dict[str, Any]]:
|
||||||
|
order_logger = OrderLogger(log_dir, zph, mode) if log_dir is not None else None
|
||||||
|
status, has_attachment, types = classify_param_text(
|
||||||
|
llm_client, param_map[zph], mode=mode, order_logger=order_logger,
|
||||||
|
enable_other=enable_other,
|
||||||
|
)
|
||||||
|
return zph, {
|
||||||
|
"zong_pai_hao": zph,
|
||||||
|
"status": status,
|
||||||
|
"has_attachment": has_attachment,
|
||||||
|
"types": types,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ids_to_classify:
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||||
|
futures = [pool.submit(_worker, zph) for zph in ids_to_classify]
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
zph, result = fut.result()
|
||||||
|
results_by_id[zph] = result
|
||||||
|
|
||||||
|
# 按原始输入顺序(含重复项)展开最终结果
|
||||||
|
return [results_by_id[zph] for zph in zong_pai_hao_list]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_single(
|
||||||
|
db_cfg: dict[str, Any],
|
||||||
|
llm_cfg: dict[str, Any],
|
||||||
|
zong_pai_hao: str,
|
||||||
|
mode: str = "coarse",
|
||||||
|
log_dir: str | Path | None = None,
|
||||||
|
enable_other: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""单个总排号分类的便捷封装。"""
|
||||||
|
return classify_batch(
|
||||||
|
db_cfg, llm_cfg, [zong_pai_hao], max_workers=1, mode=mode, log_dir=log_dir,
|
||||||
|
enable_other=enable_other,
|
||||||
|
)[0]
|
||||||
88
config_loader.py
Normal file
88
config_loader.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""配置文件加载与校验。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
_REQUIRED_KEYS = {
|
||||||
|
"database": [
|
||||||
|
"driver", "server", "port", "database", "schema", "username", "password",
|
||||||
|
"table", "id_column", "param_column", "connect_timeout", "query_timeout",
|
||||||
|
],
|
||||||
|
"llm": [
|
||||||
|
"base_url", "api_key", "model", "temperature", "max_tokens",
|
||||||
|
"disable_thinking", "timeout", "max_retry", "retry_backoff_seconds",
|
||||||
|
],
|
||||||
|
"business": ["max_workers", "log_level", "default_mode", "log_dir", "format_retry"],
|
||||||
|
}
|
||||||
|
_VALID_MODES = {"coarse", "fine"}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""配置文件缺失或格式错误。"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str | Path = "config.yaml") -> dict[str, Any]:
|
||||||
|
"""读取 YAML 配置文件并做基础字段校验。
|
||||||
|
|
||||||
|
校验失败会抛出 ConfigError 并说明缺哪个字段,避免程序运行到一半才因为
|
||||||
|
配置缺失而报出难以理解的异常。
|
||||||
|
"""
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
raise ConfigError(f"配置文件不存在: {p.resolve()}")
|
||||||
|
|
||||||
|
with p.open("r", encoding="utf-8") as f:
|
||||||
|
try:
|
||||||
|
cfg = yaml.safe_load(f)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ConfigError(f"配置文件 YAML 解析失败: {e}") from e
|
||||||
|
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
raise ConfigError("配置文件顶层必须是一个字典 (database/llm/business)")
|
||||||
|
|
||||||
|
missing_sections = [s for s in _REQUIRED_KEYS if s not in cfg]
|
||||||
|
if missing_sections:
|
||||||
|
raise ConfigError(f"配置文件缺少顶层配置块: {missing_sections}")
|
||||||
|
|
||||||
|
for section, keys in _REQUIRED_KEYS.items():
|
||||||
|
section_cfg = cfg[section]
|
||||||
|
if not isinstance(section_cfg, dict):
|
||||||
|
raise ConfigError(f"配置块 '{section}' 必须是字典")
|
||||||
|
missing = [k for k in keys if k not in section_cfg]
|
||||||
|
if missing:
|
||||||
|
raise ConfigError(f"配置块 '{section}' 缺少字段: {missing}")
|
||||||
|
|
||||||
|
default_mode = cfg["business"]["default_mode"]
|
||||||
|
if default_mode not in _VALID_MODES:
|
||||||
|
raise ConfigError(
|
||||||
|
f"business.default_mode 必须是 {_VALID_MODES} 之一,实际为: {default_mode!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# enable_other_category 是后加的可选字段,不放进 _REQUIRED_KEYS——避免
|
||||||
|
# 旧配置文件因为没有这个字段就直接报"缺字段"。不写就按 False(关闭)处理;
|
||||||
|
# 一旦写了就必须是布尔值,防止 "false"(字符串) 这类误写被当成真值静默生效。
|
||||||
|
if "enable_other_category" in cfg["business"]:
|
||||||
|
other_flag = cfg["business"]["enable_other_category"]
|
||||||
|
if not isinstance(other_flag, bool):
|
||||||
|
raise ConfigError(
|
||||||
|
f"business.enable_other_category 必须是布尔值 true/false,实际为: {other_flag!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 简单的占位符提醒,防止用户忘记改配置直接运行
|
||||||
|
placeholders = []
|
||||||
|
if cfg["database"]["password"] == "CHANGE_ME":
|
||||||
|
placeholders.append("database.password")
|
||||||
|
if cfg["llm"]["api_key"] == "CHANGE_ME":
|
||||||
|
placeholders.append("llm.api_key")
|
||||||
|
if placeholders:
|
||||||
|
print(
|
||||||
|
f"[警告] 以下配置项仍是占位符,请修改 config.yaml: {placeholders}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return cfg
|
||||||
88
db.py
Normal file
88
db.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""SQL Server 数据访问层,通过 pyodbc 按总排号批量查询新参数字段。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pyodbc
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseError(Exception):
|
||||||
|
"""数据库连接或查询失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_conn_str(db_cfg: dict[str, Any]) -> str:
|
||||||
|
parts = [
|
||||||
|
f"DRIVER={{{db_cfg['driver']}}};",
|
||||||
|
f"SERVER={db_cfg['server']},{db_cfg['port']};",
|
||||||
|
f"DATABASE={db_cfg['database']};",
|
||||||
|
f"UID={db_cfg['username']};",
|
||||||
|
f"PWD={db_cfg['password']};",
|
||||||
|
f"Connection Timeout={db_cfg['connect_timeout']};",
|
||||||
|
]
|
||||||
|
# 数据库服务器使用自签名/不受信任证书时,跳过证书链校验(连接仍保持加密)。
|
||||||
|
# ODBC Driver 17/18 默认 Encrypt=Yes,遇到自签证书会报"不受信任的颁发机构",
|
||||||
|
# 加 TrustServerCertificate=Yes 即可信任该证书。设为 false 时不影响原有行为。
|
||||||
|
if db_cfg.get("trust_server_certificate", False):
|
||||||
|
parts.append("TrustServerCertificate=Yes;")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_params_by_ids(
|
||||||
|
db_cfg: dict[str, Any], zong_pai_hao_list: list[str]
|
||||||
|
) -> dict[str, str | None]:
|
||||||
|
"""按总排号批量查询新参数字段。
|
||||||
|
|
||||||
|
返回 dict:{总排号: 新参数文本}。数据库中不存在的总排号,其值为 None
|
||||||
|
(而不是直接从结果里省略该 key),方便调用方区分"没查到"和"查到但内容为空"。
|
||||||
|
|
||||||
|
一个总排号只对应一条记录(业务已确认为一对一关系);如果实际数据出现
|
||||||
|
重复总排号,取查询结果的第一条并记录一条 WARNING 日志,不中断整体流程。
|
||||||
|
"""
|
||||||
|
if not zong_pai_hao_list:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result: dict[str, str | None] = {zph: None for zph in zong_pai_hao_list}
|
||||||
|
|
||||||
|
schema = db_cfg["schema"]
|
||||||
|
table = db_cfg["table"]
|
||||||
|
id_col = db_cfg["id_column"]
|
||||||
|
param_col = db_cfg["param_column"]
|
||||||
|
|
||||||
|
# 表名必须带 schema 前缀(如 [dbo].[表名]),只写表名在 schema 不是默认dbo时
|
||||||
|
# 会查到错误的表,甚至直接报"找不到对象"。schema 和表名分别加中括号转义,
|
||||||
|
# 不能写成 [schema.table],那样会被当成一个整体标识符解析。
|
||||||
|
qualified_table = f"[{schema}].[{table}]"
|
||||||
|
|
||||||
|
# 用参数化查询防止总排号里混入特殊字符导致 SQL 注入或语法错误
|
||||||
|
placeholders = ",".join("?" for _ in zong_pai_hao_list)
|
||||||
|
sql = f"SELECT [{id_col}], [{param_col}] FROM {qualified_table} WHERE [{id_col}] IN ({placeholders})"
|
||||||
|
|
||||||
|
conn_str = _build_conn_str(db_cfg)
|
||||||
|
try:
|
||||||
|
with pyodbc.connect(conn_str, timeout=db_cfg["connect_timeout"]) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# 查询超时设在 Connection 上(pyodbc 的 timeout 是 Connection 属性,
|
||||||
|
# Cursor 没有该属性,设 cursor.timeout 会报 AttributeError)。
|
||||||
|
conn.timeout = db_cfg["query_timeout"]
|
||||||
|
cursor.execute(sql, zong_pai_hao_list)
|
||||||
|
seen = set()
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
zph, param = row[0], row[1]
|
||||||
|
if zph in seen:
|
||||||
|
logger.warning("总排号 %s 存在重复记录,已取第一条", zph)
|
||||||
|
continue
|
||||||
|
seen.add(zph)
|
||||||
|
result[zph] = param
|
||||||
|
except pyodbc.Error as e:
|
||||||
|
raise DatabaseError(f"数据库查询失败: {e}") from e
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_param_by_id(db_cfg: dict[str, Any], zong_pai_hao: str) -> str | None:
|
||||||
|
"""单个总排号查询的便捷封装。"""
|
||||||
|
return fetch_params_by_ids(db_cfg, [zong_pai_hao]).get(zong_pai_hao)
|
||||||
137
llm_client.py
Normal file
137
llm_client.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""LLM 调用层:OpenAI 兼容接口,要求模型输出固定的纯文本格式。
|
||||||
|
|
||||||
|
之所以不让模型直接输出 JSON,是因为 JSON 的括号/引号/转义更容易被模型写错;
|
||||||
|
"标签: 值"这种极简格式模型几乎不会出错,且比 json.loads 更容易做宽松解析。
|
||||||
|
本模块只负责"调用模型、拿到原始文本",格式校验和清洗交给 parser.py。
|
||||||
|
|
||||||
|
提示词内容(system prompt + few-shot)全部来自 prompts.py,本文件不内嵌任何
|
||||||
|
提示词文字——修改分类边界/细分类目,只需要改 prompts.py。
|
||||||
|
|
||||||
|
为配合日志记录,classify_raw 返回时会连带一份完整的"本次调用消息列表",
|
||||||
|
调用方(classifier.py)据此写日志,即使调用失败也能拿到"发出去的消息是什么"。
|
||||||
|
|
||||||
|
enable_other 原样透传给 prompts.get_prompt,决定这次调用的提示词里要不要
|
||||||
|
包含"其他"兜底类目;调用方(classifier.py)必须把同一个值也传给
|
||||||
|
parser.parse_llm_output,两边保持一致。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openai import APIError, APITimeoutError, OpenAI
|
||||||
|
|
||||||
|
from prompts import get_prompt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClientError(Exception):
|
||||||
|
"""LLM 调用失败(网络/超时/接口错误),重试耗尽后抛出。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMCallResult:
|
||||||
|
"""一次 LLM 调用的完整记录,无论成功与否都会产出,供日志模块使用。"""
|
||||||
|
|
||||||
|
messages: list[dict[str, str]] # 实际发送给模型的完整消息列表(含system+few-shot+用户)
|
||||||
|
raw_response: str | None # 模型原始回复;调用失败时为 None
|
||||||
|
error: str | None # 调用失败时的错误描述;成功时为 None
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
"""封装 OpenAI 兼容接口的调用,内置 few-shot 和重试。"""
|
||||||
|
|
||||||
|
def __init__(self, llm_cfg: dict[str, Any]):
|
||||||
|
self._cfg = llm_cfg
|
||||||
|
self._client = OpenAI(
|
||||||
|
base_url=llm_cfg["base_url"],
|
||||||
|
api_key=llm_cfg["api_key"],
|
||||||
|
timeout=llm_cfg["timeout"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_messages(
|
||||||
|
self, param_text: str, mode: str, enable_other: bool = False
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
system_prompt, fewshot = get_prompt(mode, enable_other=enable_other)
|
||||||
|
messages: list[dict[str, str]] = [{"role": "system", "content": system_prompt}]
|
||||||
|
for user_text, assistant_text in fewshot:
|
||||||
|
messages.append(
|
||||||
|
{"role": "user", "content": f'请判断以下订单参数是否携带附件:\n"""\n{user_text}\n"""'}
|
||||||
|
)
|
||||||
|
messages.append({"role": "assistant", "content": assistant_text})
|
||||||
|
messages.append(
|
||||||
|
{"role": "user", "content": f'请判断以下订单参数是否携带附件:\n"""\n{param_text}\n"""'}
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def classify_raw(
|
||||||
|
self, param_text: str, mode: str = "coarse", enable_other: bool = False
|
||||||
|
) -> LLMCallResult:
|
||||||
|
"""调用模型,返回完整调用记录(消息列表 + 原始回复/错误)。
|
||||||
|
|
||||||
|
与旧版不同:即使调用最终失败(重试耗尽),也不再抛异常中断调用方,
|
||||||
|
而是把失败信息装进 LLMCallResult 返回——这样 classifier.py 才能在
|
||||||
|
"调用彻底失败"的情况下依然拿到"发出去的消息是什么",写进日志方便排查
|
||||||
|
(比如看是不是消息本身有问题导致接口一直拒绝)。
|
||||||
|
|
||||||
|
调用方如果需要区分"成功"还是"失败",检查 result.error is None 即可。
|
||||||
|
"""
|
||||||
|
messages = self._build_messages(param_text, mode, enable_other=enable_other)
|
||||||
|
max_retry = self._cfg["max_retry"]
|
||||||
|
backoff = self._cfg["retry_backoff_seconds"]
|
||||||
|
last_err: Exception | None = None
|
||||||
|
|
||||||
|
# extra_body 用于透传标准 OpenAI SDK 不识别的厂商专属参数。
|
||||||
|
# disable_thinking=true 时关闭推理链——像 DeepSeek-V4 系列这类默认开启
|
||||||
|
# thinking 的模型,max_tokens 限制的是"推理token+正文token"的总量;
|
||||||
|
# 分类任务规则清晰、不需要推理,关闭后能避免推理阶段耗尽token预算导致
|
||||||
|
# 正文被截断为空(表现为调用"成功"但content是空字符串)。
|
||||||
|
# 若接口不支持该字段,通常会被直接忽略而非报错,但仍建议按需关闭本配置。
|
||||||
|
extra_body: dict[str, Any] = {}
|
||||||
|
if self._cfg.get("disable_thinking", False):
|
||||||
|
extra_body["thinking"] = {"type": "disabled"}
|
||||||
|
|
||||||
|
for attempt in range(1, max_retry + 1):
|
||||||
|
try:
|
||||||
|
resp = self._client.chat.completions.create(
|
||||||
|
model=self._cfg["model"],
|
||||||
|
temperature=self._cfg["temperature"],
|
||||||
|
max_tokens=self._cfg["max_tokens"],
|
||||||
|
messages=messages,
|
||||||
|
extra_body=extra_body or None,
|
||||||
|
)
|
||||||
|
content = resp.choices[0].message.content
|
||||||
|
# 空字符串和 None 同样视为"没拿到有效正文"——推理型模型在
|
||||||
|
# max_tokens 预算被推理阶段耗尽时,常表现为 content="" 而非
|
||||||
|
# None(HTTP层面调用是成功的),必须一并捕获,否则会把这种
|
||||||
|
# 情况误判为"调用成功、只是格式不对",掩盖了真正的原因。
|
||||||
|
if not content:
|
||||||
|
raise LLMClientError(
|
||||||
|
f"模型返回内容为空 (content={content!r}),"
|
||||||
|
f"若模型支持思维链,可能是推理耗尽了max_tokens预算"
|
||||||
|
)
|
||||||
|
return LLMCallResult(messages=messages, raw_response=content, error=None)
|
||||||
|
except (APIError, APITimeoutError) as e:
|
||||||
|
last_err = e
|
||||||
|
logger.warning(
|
||||||
|
"LLM 调用失败 (第 %d/%d 次): %s", attempt, max_retry, e
|
||||||
|
)
|
||||||
|
if attempt < max_retry:
|
||||||
|
time.sleep(backoff * attempt)
|
||||||
|
except Exception as e: # noqa: BLE001 - 捕获SDK未明确分类的异常,统一包装
|
||||||
|
last_err = e
|
||||||
|
logger.warning(
|
||||||
|
"LLM 调用出现未预期异常 (第 %d/%d 次): %s", attempt, max_retry, e
|
||||||
|
)
|
||||||
|
if attempt < max_retry:
|
||||||
|
time.sleep(backoff * attempt)
|
||||||
|
|
||||||
|
return LLMCallResult(
|
||||||
|
messages=messages,
|
||||||
|
raw_response=None,
|
||||||
|
error=f"LLM 调用重试 {max_retry} 次后仍失败: {last_err}",
|
||||||
|
)
|
||||||
160
main.py
Normal file
160
main.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""布莱迪压力表 - 订单附件识别 命令行入口。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python main.py --id 26B742 # 单个总排号(粗分类,默认)
|
||||||
|
python main.py --id 26B742,26B743,26B744 # 批量,逗号分隔
|
||||||
|
python main.py --ids-file ids.txt # 批量,文件每行一个总排号
|
||||||
|
python main.py --id 26B742 --mode fine # 精分类(输出"大类:细分"格式)
|
||||||
|
python main.py --id 26B742 --config other.yaml # 指定其他配置文件
|
||||||
|
python main.py --id 26B742 --pretty # 格式化输出JSON(默认单行紧凑)
|
||||||
|
python main.py --id 26B742 --log-dir /tmp/logs # 覆盖配置文件里的日志目录
|
||||||
|
python main.py --id 26B742 --enable-other # 允许模型使用"其他"兜底类目
|
||||||
|
|
||||||
|
分类模式(--mode):
|
||||||
|
coarse (默认) - 只判断大类: 资料/配件/耗材(启用 --enable-other 时还有"其他")
|
||||||
|
fine - 输出"大类:细分类目",如"配件:针型阀""资料:说明书"
|
||||||
|
|
||||||
|
"其他"兜底类目(--enable-other 或配置文件 business.enable_other_category):
|
||||||
|
默认关闭。关闭时,模型和格式校验都不知道"其他"这个选项存在,行为与
|
||||||
|
未支持"其他"之前完全一致。开启后,遇到确实是随货附件、但不属于任何
|
||||||
|
已列出细分类目的情况,模型可以归为"其他",避免这类附件被漏判、或被
|
||||||
|
模型勉强套进不准确的类目里。命令行只能把它从"关"打开成"开",无法
|
||||||
|
反过来用命令行强制关闭配置文件里已经打开的设置。
|
||||||
|
|
||||||
|
输出:
|
||||||
|
每个总排号一行 JSON(JSON Lines 格式),例如:
|
||||||
|
{"zong_pai_hao": "26B742", "status": "ok", "has_attachment": true, "types": ["资料"]}
|
||||||
|
{"zong_pai_hao": "26B744", "status": "ok", "has_attachment": true, "types": ["配件:针型阀", "配件:表弯管"]}
|
||||||
|
{"zong_pai_hao": "XXXXXX", "status": "not_found", "has_attachment": null, "types": []}
|
||||||
|
|
||||||
|
types 是唯一的类型字段,不再有单独的 fine_types:
|
||||||
|
coarse模式下 types 是大类列表;fine模式下每一项都是"大类:细分类目"。
|
||||||
|
|
||||||
|
status 字段含义:
|
||||||
|
ok - 识别成功
|
||||||
|
not_found - 数据库中查不到该总排号
|
||||||
|
empty_param - 该总排号存在,但新参数字段为空
|
||||||
|
llm_call_error - LLM 调用失败(网络/接口错误,重试耗尽)
|
||||||
|
llm_format_error - LLM 返回内容格式不符合约定(重试耗尽)
|
||||||
|
db_error - 数据库连接/查询失败(影响整批)
|
||||||
|
|
||||||
|
日志:
|
||||||
|
每次实际发起LLM调用的总排号,都会在日志目录下生成一个独立的日志文件
|
||||||
|
(文件名含总排号和时间戳),完整记录发给模型的对话内容、模型的每一次
|
||||||
|
原始回复(含被判定格式错误、触发重试的)、以及最终解析结果。查不到
|
||||||
|
(not_found)或参数为空(empty_param)的总排号不产生日志文件。
|
||||||
|
日志目录默认读取配置文件 business.log_dir,可用 --log-dir 临时覆盖。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
||||||
|
|
||||||
|
from classifier import classify_batch # noqa: E402
|
||||||
|
from config_loader import ConfigError, load_config # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ids(args: argparse.Namespace) -> list[str]:
|
||||||
|
"""从 --id 或 --ids-file 解析出总排号列表,去除空白项。"""
|
||||||
|
ids: list[str] = []
|
||||||
|
if args.id:
|
||||||
|
ids.extend(s.strip() for s in args.id.split(",") if s.strip())
|
||||||
|
if args.ids_file:
|
||||||
|
p = Path(args.ids_file)
|
||||||
|
if not p.exists():
|
||||||
|
print(f"[错误] 总排号文件不存在: {p.resolve()}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
with p.open("r", encoding="utf-8") as f:
|
||||||
|
ids.extend(line.strip() for line in f if line.strip())
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="布莱迪压力表订单附件识别工具",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--id", type=str, default=None,
|
||||||
|
help="总排号,单个或逗号分隔的多个,例如: 26B742 或 26B742,26B743",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ids-file", type=str, default=None,
|
||||||
|
help="包含总排号的文本文件路径,每行一个总排号",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config", type=str, default="config.yaml",
|
||||||
|
help="配置文件路径 (默认: config.yaml)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode", type=str, default=None, choices=["coarse", "fine"],
|
||||||
|
help="分类粒度: coarse(粗分类,默认) 或 fine(精分类)。不指定则使用配置文件 business.default_mode",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-dir", type=str, default=None,
|
||||||
|
help="LLM对话日志存放目录。不指定则使用配置文件 business.log_dir",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pretty", action="store_true",
|
||||||
|
help="以缩进格式输出JSON数组,而非默认的紧凑JSON Lines格式",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--enable-other", action="store_true",
|
||||||
|
help="允许模型使用'其他'兜底类目。不指定则使用配置文件 business.enable_other_category"
|
||||||
|
"(缺省为关闭);本开关只能从命令行打开,无法用命令行强制关闭配置文件里已打开的设置",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.id and not args.ids_file:
|
||||||
|
parser.error("必须提供 --id 或 --ids-file 其中之一")
|
||||||
|
|
||||||
|
ids = _parse_ids(args)
|
||||||
|
if not ids:
|
||||||
|
print("[错误] 未解析到任何有效的总排号", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = load_config(args.config)
|
||||||
|
except ConfigError as e:
|
||||||
|
print(f"[配置错误] {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, cfg["business"]["log_level"], logging.INFO),
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
stream=sys.stderr, # 日志走stderr,保证stdout只有干净的JSON结果,便于管道/重定向
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = args.mode or cfg["business"]["default_mode"]
|
||||||
|
log_dir = args.log_dir or cfg["business"]["log_dir"]
|
||||||
|
# enable_other_category 是后加的可选配置项,缺省按"关闭"处理;--enable-other
|
||||||
|
# 只能从"关"打开成"开",不支持反过来用命令行强制关闭配置文件里已打开的设置。
|
||||||
|
enable_other = bool(args.enable_other) or bool(cfg["business"].get("enable_other_category", False))
|
||||||
|
|
||||||
|
results = classify_batch(
|
||||||
|
db_cfg=cfg["database"],
|
||||||
|
llm_cfg=cfg["llm"],
|
||||||
|
zong_pai_hao_list=ids,
|
||||||
|
max_workers=cfg["business"]["max_workers"],
|
||||||
|
mode=mode,
|
||||||
|
log_dir=log_dir,
|
||||||
|
enable_other=enable_other,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.pretty:
|
||||||
|
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
for r in results:
|
||||||
|
print(json.dumps(r, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
136
order_logger.py
Normal file
136
order_logger.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""对话日志模块。
|
||||||
|
|
||||||
|
设计目标:每个总排号一个日志文件,纯文本格式,完整记录"这次调用到底发生了
|
||||||
|
什么"——system prompt、few-shot、实际发给模型的用户消息、模型每一次原始
|
||||||
|
回复(含被格式校验拒绝、触发重试的那些),以及最终的解析结果。成功失败都写,
|
||||||
|
不因为调用失败就少记东西;恰恰是失败的调用最需要完整日志去排查。
|
||||||
|
|
||||||
|
文件命名: {log_dir}/{总排号}_{时间戳}.log
|
||||||
|
同一个总排号如果被反复处理(比如手动重跑),不会覆盖旧日志,每次生成新文件,
|
||||||
|
方便对比"改了提示词前后,同一条记录的判断有没有变化"。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from llm_client import LLMCallResult
|
||||||
|
from parser import ParsedResult
|
||||||
|
|
||||||
|
# 总排号可能含有对文件名不友好的字符(如斜杠),做一次保守清理
|
||||||
|
_UNSAFE_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename_part(zong_pai_hao: str) -> str:
|
||||||
|
return _UNSAFE_FILENAME_CHARS.sub("_", zong_pai_hao)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderLogger:
|
||||||
|
"""负责单个总排号在一次分类过程中的完整日志记录。
|
||||||
|
|
||||||
|
典型用法(在 classifier.py 里):
|
||||||
|
logger = OrderLogger(log_dir, zong_pai_hao, mode)
|
||||||
|
logger.log_param_text(param_text)
|
||||||
|
# ... 每次调用LLM后:
|
||||||
|
logger.log_llm_attempt(attempt_no, call_result, parsed_or_none, format_error_or_none)
|
||||||
|
# ... 最终:
|
||||||
|
logger.log_final_result(status, parsed_result_or_none)
|
||||||
|
logger.close()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, log_dir: str | Path, zong_pai_hao: str, mode: str):
|
||||||
|
self._log_dir = Path(log_dir)
|
||||||
|
self._log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||||
|
filename = f"{_safe_filename_part(zong_pai_hao)}_{timestamp}.log"
|
||||||
|
self._path = self._log_dir / filename
|
||||||
|
self._lines: list[str] = []
|
||||||
|
|
||||||
|
self._write_header(zong_pai_hao, mode)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> Path:
|
||||||
|
return self._path
|
||||||
|
|
||||||
|
def _write_header(self, zong_pai_hao: str, mode: str) -> None:
|
||||||
|
self._lines.append("=" * 70)
|
||||||
|
self._lines.append(f"总排号: {zong_pai_hao}")
|
||||||
|
self._lines.append(f"分类模式: {mode}")
|
||||||
|
self._lines.append(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
self._lines.append("=" * 70)
|
||||||
|
self._flush()
|
||||||
|
|
||||||
|
def log_param_text(self, param_text: str | None) -> None:
|
||||||
|
self._lines.append("")
|
||||||
|
self._lines.append("--- 新参数原文 ---")
|
||||||
|
self._lines.append(param_text if param_text else "(空)")
|
||||||
|
self._flush()
|
||||||
|
|
||||||
|
def log_llm_attempt(
|
||||||
|
self,
|
||||||
|
attempt_no: int,
|
||||||
|
call_result: LLMCallResult,
|
||||||
|
parsed: ParsedResult | None,
|
||||||
|
format_error: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""记录一次 LLM 调用的完整往返,无论成功还是失败都调用本方法。
|
||||||
|
|
||||||
|
call_result.error 非 None 表示网络/接口层面调用失败(对话都没发起成功
|
||||||
|
或没拿到回复);call_result.error 为 None 但 format_error 非 None 表示
|
||||||
|
调用成功但模型输出格式不合规;两者都为 None 表示本次调用完全成功。
|
||||||
|
"""
|
||||||
|
self._lines.append("")
|
||||||
|
self._lines.append(f"--- 第 {attempt_no} 次尝试 ---")
|
||||||
|
|
||||||
|
self._lines.append("")
|
||||||
|
self._lines.append("[发送给模型的完整对话]")
|
||||||
|
for msg in call_result.messages:
|
||||||
|
role = msg["role"]
|
||||||
|
content = msg["content"]
|
||||||
|
self._lines.append(f" [{role}]")
|
||||||
|
for line in content.splitlines():
|
||||||
|
self._lines.append(f" {line}")
|
||||||
|
|
||||||
|
self._lines.append("")
|
||||||
|
if call_result.error is not None:
|
||||||
|
self._lines.append("[调用结果] 网络/接口调用失败")
|
||||||
|
self._lines.append(f" 错误信息: {call_result.error}")
|
||||||
|
else:
|
||||||
|
self._lines.append("[调用结果] 已收到模型回复")
|
||||||
|
self._lines.append("[模型原始回复]")
|
||||||
|
for line in (call_result.raw_response or "").splitlines():
|
||||||
|
self._lines.append(f" {line}")
|
||||||
|
|
||||||
|
self._lines.append("")
|
||||||
|
if format_error is not None:
|
||||||
|
self._lines.append("[格式校验] 失败")
|
||||||
|
self._lines.append(f" 原因: {format_error}")
|
||||||
|
else:
|
||||||
|
self._lines.append("[格式校验] 通过")
|
||||||
|
if parsed is not None:
|
||||||
|
self._lines.append(f" has_attachment = {parsed.has_attachment}")
|
||||||
|
self._lines.append(f" types = {parsed.types}")
|
||||||
|
self._flush()
|
||||||
|
|
||||||
|
def log_final_result(self, status: str, parsed: ParsedResult | None) -> None:
|
||||||
|
self._lines.append("")
|
||||||
|
self._lines.append("=" * 70)
|
||||||
|
self._lines.append(f"最终状态: {status}")
|
||||||
|
if parsed is not None:
|
||||||
|
self._lines.append(f"最终结果: has_attachment={parsed.has_attachment}, types={parsed.types}")
|
||||||
|
self._lines.append(f"结束时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
self._lines.append("=" * 70)
|
||||||
|
self._flush()
|
||||||
|
|
||||||
|
def _flush(self) -> None:
|
||||||
|
"""每次追加内容后立即落盘,避免进程中途异常退出导致日志丢失。"""
|
||||||
|
with self._path.open("a", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(self._lines) + "\n")
|
||||||
|
self._lines = []
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""预留的显式收尾方法;当前实现下 _flush 已即时写盘,close 是空操作,
|
||||||
|
但保留该接口方便未来切换为缓冲写入而不改动调用方代码。"""
|
||||||
|
pass
|
||||||
186
parser.py
Normal file
186
parser.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""解析与清洗层。
|
||||||
|
|
||||||
|
职责边界很明确:
|
||||||
|
- LLM 只负责"识别",输出约定好的纯文本(coarse模式2行,fine模式3行)。
|
||||||
|
- 本模块只负责"验证格式是否正确 + 提取字段 + 组装成程序自己定义的结构"。
|
||||||
|
|
||||||
|
不信任 LLM 输出的具体体现:
|
||||||
|
1. 逐行校验,任何一行不匹配预期正则就判定为格式错误,绝不"猜测式"兜底解析。
|
||||||
|
2. 枚举值必须完全匹配 prompts.py 里定义的枚举,出现模型编造的第三种值也判格式错误。
|
||||||
|
3. fine模式下,细分类目与大类的归属关系必须自洽(如"针型阀"必须伴随"配件")。
|
||||||
|
4. 格式错误交给上层去做重试,而不是在这里勉强凑出一个结果。
|
||||||
|
|
||||||
|
enable_other 参数贯穿本模块的校验函数:是否把"其他"当作合法值,取决于这次
|
||||||
|
分类调用传入的 enable_other,必须和生成该输出时 prompts.get_prompt 用的
|
||||||
|
取值保持一致——由 classifier.py 负责把同一个值同时传给两边。
|
||||||
|
|
||||||
|
types 字段说明(对外唯一的类型字段,不再单独暴露 fine_types):
|
||||||
|
- coarse 模式:大类列表,如 ["资料", "配件"]。
|
||||||
|
- fine 模式:每一项都是"大类:细分类目",如 ["资料:说明书", "配件:针型阀"],
|
||||||
|
由第二行(大类)和第三行(细分类目)校验通过后拼接而成;启用"其他"时,
|
||||||
|
兜底项会体现为 "其他:其他",格式与其余项保持一致。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from prompts import all_fine_types, fine_type_to_category, valid_categories
|
||||||
|
|
||||||
|
_LINE1_RE = re.compile(r"^是否携带附件[::]\s*(是|否)\s*$")
|
||||||
|
_LINE2_RE = re.compile(r"^附件类型[::]\s*(.*)$")
|
||||||
|
_LINE3_RE = re.compile(r"^具体分类[::]\s*(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
class FormatError(Exception):
|
||||||
|
"""LLM 原始输出不符合约定格式,调用方应据此触发重试。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedResult:
|
||||||
|
"""分类结果。
|
||||||
|
|
||||||
|
types 是对外唯一的类型字段:coarse 模式下是大类列表;fine 模式下每一项
|
||||||
|
都已经是"大类:细分类目"的组合字符串。不再有单独的 fine_types 字段——
|
||||||
|
fine 模式的"大类"信息已经内嵌在每个元素里,无需再查第二份数据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
has_attachment: bool
|
||||||
|
types: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_markdown_fence(raw_text: str) -> str:
|
||||||
|
cleaned = raw_text.strip()
|
||||||
|
return re.sub(r"^```[a-zA-Z]*\n?|```$", "", cleaned).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_line1(line: str) -> bool:
|
||||||
|
m = _LINE1_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
raise FormatError(f"第一行格式不匹配 '是否携带附件: 是/否': {line!r}")
|
||||||
|
return m.group(1) == "是"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_line2(line: str, has_attachment: bool, categories: set[str]) -> list[str]:
|
||||||
|
m = _LINE2_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
raise FormatError(f"第二行格式不匹配 '附件类型: ...': {line!r}")
|
||||||
|
types_raw = m.group(1).strip()
|
||||||
|
|
||||||
|
if not has_attachment:
|
||||||
|
if types_raw:
|
||||||
|
raise FormatError(
|
||||||
|
f"第一行为'否',但第二行附件类型非空: {types_raw!r}(模型输出自相矛盾)"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not types_raw:
|
||||||
|
raise FormatError("第一行为'是',但第二行附件类型为空(模型输出自相矛盾)")
|
||||||
|
|
||||||
|
types = [t.strip() for t in types_raw.split(",") if t.strip()]
|
||||||
|
invalid = [t for t in types if t not in categories]
|
||||||
|
if invalid:
|
||||||
|
raise FormatError(f"附件类型出现非法值 {invalid},合法值仅为 {categories}")
|
||||||
|
if len(types) != len(set(types)):
|
||||||
|
raise FormatError(f"附件类型出现重复: {types}")
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
|
def parse_llm_output_coarse(raw_text: str, enable_other: bool = False) -> ParsedResult:
|
||||||
|
"""校验并解析 coarse 模式的两行输出。
|
||||||
|
|
||||||
|
enable_other 必须和生成该输出时 prompts.get_prompt 使用的取值一致,
|
||||||
|
否则"其他"要么被误判为非法值拒绝,要么被误放行。
|
||||||
|
"""
|
||||||
|
if raw_text is None:
|
||||||
|
raise FormatError("LLM 返回内容为 None")
|
||||||
|
|
||||||
|
categories = valid_categories(enable_other)
|
||||||
|
cleaned = _strip_markdown_fence(raw_text)
|
||||||
|
lines = [ln.strip() for ln in cleaned.splitlines() if ln.strip()]
|
||||||
|
|
||||||
|
if len(lines) != 2:
|
||||||
|
raise FormatError(f"coarse模式预期恰好2行有效内容,实际得到{len(lines)}行: {lines!r}")
|
||||||
|
|
||||||
|
has_attachment = _parse_line1(lines[0])
|
||||||
|
types = _parse_line2(lines[1], has_attachment, categories)
|
||||||
|
return ParsedResult(has_attachment=has_attachment, types=types)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_llm_output_fine(raw_text: str, enable_other: bool = False) -> ParsedResult:
|
||||||
|
"""校验并解析 fine 模式的三行输出,并把"大类+细分"合并进最终的 types。
|
||||||
|
|
||||||
|
在 coarse 校验规则的基础上,额外校验:
|
||||||
|
- 第三行每个细分类目都必须在生效的细分类目全集内(是否含"其他"取决于
|
||||||
|
enable_other)。
|
||||||
|
- 若第一行为"否",第三行必须为空;若为"是",第三行至少要有一项。
|
||||||
|
- 第三行不能有重复项。
|
||||||
|
- 交叉自洽:第三行每个细分类目所属的大类,必须出现在第二行的大类列表里
|
||||||
|
(例如选了"针型阀"但第二行没有"配件",视为模型输出自相矛盾)。
|
||||||
|
|
||||||
|
全部校验通过后,把第三行的每个细分类目和它所属的大类拼成
|
||||||
|
"大类:细分类目",作为最终 types 返回——不再单独暴露"大类列表"和
|
||||||
|
"细分类目列表"两份数据。
|
||||||
|
"""
|
||||||
|
if raw_text is None:
|
||||||
|
raise FormatError("LLM 返回内容为 None")
|
||||||
|
|
||||||
|
categories = valid_categories(enable_other)
|
||||||
|
fine_types_enum = all_fine_types(enable_other)
|
||||||
|
fine_to_category = fine_type_to_category(enable_other)
|
||||||
|
|
||||||
|
cleaned = _strip_markdown_fence(raw_text)
|
||||||
|
lines = [ln.strip() for ln in cleaned.splitlines() if ln.strip()]
|
||||||
|
|
||||||
|
if len(lines) != 3:
|
||||||
|
raise FormatError(f"fine模式预期恰好3行有效内容,实际得到{len(lines)}行: {lines!r}")
|
||||||
|
|
||||||
|
has_attachment = _parse_line1(lines[0])
|
||||||
|
line2_types = _parse_line2(lines[1], has_attachment, categories)
|
||||||
|
|
||||||
|
m3 = _LINE3_RE.match(lines[2])
|
||||||
|
if not m3:
|
||||||
|
raise FormatError(f"第三行格式不匹配 '具体分类: ...': {lines[2]!r}")
|
||||||
|
fine_raw = m3.group(1).strip()
|
||||||
|
|
||||||
|
if not has_attachment:
|
||||||
|
if fine_raw:
|
||||||
|
raise FormatError(
|
||||||
|
f"第一行为'否',但第三行具体分类非空: {fine_raw!r}(模型输出自相矛盾)"
|
||||||
|
)
|
||||||
|
return ParsedResult(has_attachment=False, types=[])
|
||||||
|
|
||||||
|
if not fine_raw:
|
||||||
|
raise FormatError("第一行为'是',但第三行具体分类为空(模型输出自相矛盾)")
|
||||||
|
|
||||||
|
fine_types = [t.strip() for t in fine_raw.split(",") if t.strip()]
|
||||||
|
invalid_fine = [t for t in fine_types if t not in fine_types_enum]
|
||||||
|
if invalid_fine:
|
||||||
|
raise FormatError(
|
||||||
|
f"具体分类出现非法值 {invalid_fine},不在枚举内(enable_other={enable_other})"
|
||||||
|
)
|
||||||
|
if len(fine_types) != len(set(fine_types)):
|
||||||
|
raise FormatError(f"具体分类出现重复: {fine_types}")
|
||||||
|
|
||||||
|
# 交叉自洽校验:每个细分类目对应的大类,必须出现在第二行里
|
||||||
|
implied_categories = {fine_to_category[t] for t in fine_types}
|
||||||
|
missing_categories = implied_categories - set(line2_types)
|
||||||
|
if missing_categories:
|
||||||
|
raise FormatError(
|
||||||
|
f"具体分类 {fine_types} 隐含大类 {implied_categories},"
|
||||||
|
f"但第二行附件类型只有 {line2_types},缺少 {missing_categories}(模型输出自相矛盾)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 合并为最终的 "大类:细分类目" 列表,替代原来分开暴露的 types/fine_types
|
||||||
|
combined = [f"{fine_to_category[t]}:{t}" for t in fine_types]
|
||||||
|
return ParsedResult(has_attachment=True, types=combined)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_llm_output(raw_text: str, mode: str = "coarse", enable_other: bool = False) -> ParsedResult:
|
||||||
|
"""按模式分派到对应的解析函数。mode 只接受 'coarse' 或 'fine'。"""
|
||||||
|
if mode == "coarse":
|
||||||
|
return parse_llm_output_coarse(raw_text, enable_other=enable_other)
|
||||||
|
if mode == "fine":
|
||||||
|
return parse_llm_output_fine(raw_text, enable_other=enable_other)
|
||||||
|
raise ValueError(f"不支持的分类模式: {mode!r},只能是 'coarse' 或 'fine'")
|
||||||
339
prompts.py
Normal file
339
prompts.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""提示词集中管理模块。
|
||||||
|
|
||||||
|
设计目的:把"模型该怎么判断"和"怎么调用模型"分开——本文件只关心提示词内容,
|
||||||
|
llm_client.py 只负责拿着提示词去调接口。以后要调整分类边界、增删细分类目,
|
||||||
|
只需要改这一个文件,不涉及调用逻辑。
|
||||||
|
|
||||||
|
两种模式共用同一套背景知识(什么是产品本体、资料/配件/耗材的定义),只是
|
||||||
|
模式为"fine"时,额外要求模型在第三行给出细分类目,且 few-shot 的回复也带
|
||||||
|
上第三行。
|
||||||
|
|
||||||
|
细分类目枚举以布莱迪真实历史数据(新参数字段,约2.4万条)为基础人工归纳,
|
||||||
|
并与业务方逐项确认边界后定稿:
|
||||||
|
- 螺栓/螺母单列"紧固件"一类(不并入法兰隔膜等附属部件)。
|
||||||
|
- 垫片单列第三大类"耗材/密封件",不算在"配件"里,避免大量小垫片稀释"配件"
|
||||||
|
标签的信息量(一张订单如果只要了一片垫片,和要了一整套阀组,不该被算作
|
||||||
|
同等权重的"携带配件")。
|
||||||
|
- 阻尼帽比照阻尼钉,视为工艺处理,不算配件、不进枚举。
|
||||||
|
- 合格证明确不计入判断:既不算资料,也不算任何其他附件类型。无论参数文本
|
||||||
|
里是否提到合格证,一律忽略——不影响"是否携带附件"的判断结果,也不会
|
||||||
|
出现在细分类目里。这是与此前版本的主要差异之一,few-shot 里同步给出了
|
||||||
|
"仅有合格证"的反例(否则模型容易在缺少监督信号时把合格证当成资料的一种)。
|
||||||
|
|
||||||
|
"其他"兜底类目(可选,默认关闭):
|
||||||
|
遇到确实是随货交付的附件、但不属于以上任何一个具体细分类目的情况,
|
||||||
|
如果调用方传入 enable_other=True,模型可以把这类附件归为第四大类"其他",
|
||||||
|
细分类目也固定填"其他"(不允许模型自己编造具体名称,保持枚举严格校验的
|
||||||
|
风格不变)。enable_other=False(默认)时,"其他"完全不出现在提示词和
|
||||||
|
校验枚举里,行为与不支持"其他"之前完全一致——这个参数必须和
|
||||||
|
parser.parse_llm_output 使用的 enable_other 保持一致,否则会出现
|
||||||
|
"提示词允许但校验拒绝"或反过来的不一致;classifier.py 负责保证两边一致。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 细分类目枚举(fine 模式专用,供 parser.py 做严格校验)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
DOC_FINE_TYPES: list[str] = [
|
||||||
|
"出厂检测检验报告",
|
||||||
|
"材质证明类",
|
||||||
|
"说明书",
|
||||||
|
"检验记录过程性",
|
||||||
|
"图纸类",
|
||||||
|
"标定校验证书",
|
||||||
|
"质量证明书",
|
||||||
|
"原产地证明",
|
||||||
|
"检验合格证书综合",
|
||||||
|
"其他交工文件",
|
||||||
|
"营业执照",
|
||||||
|
"型式检验报告",
|
||||||
|
]
|
||||||
|
|
||||||
|
PART_FINE_TYPES: list[str] = [
|
||||||
|
"针型阀",
|
||||||
|
"球阀",
|
||||||
|
"截止阀",
|
||||||
|
"旋塞阀",
|
||||||
|
"角阀",
|
||||||
|
"阀组",
|
||||||
|
"冷凝圈",
|
||||||
|
"冷凝管",
|
||||||
|
"冷凝弯",
|
||||||
|
"虹吸管",
|
||||||
|
"表弯管",
|
||||||
|
"缓冲管缓冲弯",
|
||||||
|
"法兰隔膜",
|
||||||
|
"隔离器",
|
||||||
|
"过压保护器",
|
||||||
|
"散热器散热片",
|
||||||
|
"铅封",
|
||||||
|
"转换接头",
|
||||||
|
"焊接接头短节短管",
|
||||||
|
"卡箍抱箍",
|
||||||
|
"紧固件",
|
||||||
|
"活接头",
|
||||||
|
"接线盒",
|
||||||
|
"电缆插头",
|
||||||
|
"变送器",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONSUMABLE_FINE_TYPES: list[str] = [
|
||||||
|
"垫片",
|
||||||
|
]
|
||||||
|
|
||||||
|
# "其他"大类专用:整个大类只有这一个细分值,且值本身就是"其他"——不允许
|
||||||
|
# 模型编造具体名称,兜底之外仍保持和三大类同样严格的枚举校验风格。
|
||||||
|
OTHER_CATEGORY = "其他"
|
||||||
|
OTHER_FINE_TYPE = "其他"
|
||||||
|
|
||||||
|
# 三大类固定存在;"其他"是否作为第四类生效,完全由调用方传入的 enable_other
|
||||||
|
# 决定,默认不生效。
|
||||||
|
CATEGORY_NAMES = ("资料", "配件", "耗材")
|
||||||
|
|
||||||
|
# 三大类下所有细分类目的全集(不含"其他")
|
||||||
|
ALL_FINE_TYPES: set[str] = set(DOC_FINE_TYPES) | set(PART_FINE_TYPES) | set(CONSUMABLE_FINE_TYPES)
|
||||||
|
|
||||||
|
# 细分类目 -> 所属大类(不含"其他")
|
||||||
|
FINE_TYPE_TO_CATEGORY: dict[str, str] = {
|
||||||
|
**{t: "资料" for t in DOC_FINE_TYPES},
|
||||||
|
**{t: "配件" for t in PART_FINE_TYPES},
|
||||||
|
**{t: "耗材" for t in CONSUMABLE_FINE_TYPES},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def valid_categories(enable_other: bool = False) -> set[str]:
|
||||||
|
"""返回当前生效的大类合法值集合,enable_other=True 时多一个"其他"。"""
|
||||||
|
return set(CATEGORY_NAMES) | ({OTHER_CATEGORY} if enable_other else set())
|
||||||
|
|
||||||
|
|
||||||
|
def all_fine_types(enable_other: bool = False) -> set[str]:
|
||||||
|
"""返回当前生效的细分类目全集,enable_other=True 时多一个"其他"。"""
|
||||||
|
return ALL_FINE_TYPES | ({OTHER_FINE_TYPE} if enable_other else set())
|
||||||
|
|
||||||
|
|
||||||
|
def fine_type_to_category(enable_other: bool = False) -> dict[str, str]:
|
||||||
|
"""返回当前生效的"细分类目 -> 大类"映射,enable_other=True 时多"其他"->"其他"。"""
|
||||||
|
if not enable_other:
|
||||||
|
return dict(FINE_TYPE_TO_CATEGORY)
|
||||||
|
return {**FINE_TYPE_TO_CATEGORY, OTHER_FINE_TYPE: OTHER_CATEGORY}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 背景知识(两种模式共用)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
_BACKGROUND = """你是布莱迪压力表公司的订单参数审核助手。你的任务是判断一条订单的"技术参数"文本中,除了压力表产品本体之外,是否还要求交付"附件"。
|
||||||
|
|
||||||
|
【背景】公司生产压力表(用于化工行业)。参数文本里大部分内容是描述产品本体的规格,这些都不是附件。
|
||||||
|
|
||||||
|
【什么是产品本体(不算附件)】
|
||||||
|
表壳直径/材质、精度等级、防护等级(IP)、过压保护、弹性元件材质、接头材质、接头螺纹、安装方式、表盘材质/刻度/颜色、量程、充油、玻璃、指针、调零装置、缓冲钉/阻尼钉/阻尼帽(均属工艺处理,不算配件)、结构代号(如513/531)、卡盘尺寸、以及各种打标/示值/红线标识要求。
|
||||||
|
|
||||||
|
【附件 = 产品本体之外,随货交付的东西,分三大类】
|
||||||
|
|
||||||
|
一、资料类(交付给客户的技术文件):
|
||||||
|
检测报告、检验报告、出厂报告、材质证明/材质单/材质报告、说明书(使用/安装/维护)、标定证书/校验证书、制造记录/检验记录、图纸、含技术信息的二维码/标签、营业执照、原产地证明、型式检验报告。
|
||||||
|
※ 注意:装箱单、送货单、贴箱单、发货章、包装/箱体标注等,属于物流包装指令,不算资料。合格证也不算资料——无论参数文本里是否提到合格证,一律忽略,既不影响"是否携带附件"的判断,也绝不能出现在第三行的具体分类里。
|
||||||
|
|
||||||
|
二、配件类(随货的独立实物零件,不含垫片):
|
||||||
|
针型阀、球阀、截止阀、旋塞阀、角阀、阀组(二/三/五阀组等)、冷凝管/冷凝圈/冷凝弯、表弯管/缓冲弯、虹吸管、过压保护器、散热器/散热片、焊接接头/短节/短管、法兰隔膜、隔离器、铅封、转换接头、卡箍/抱箍、紧固件(螺栓/螺母)、活接头、接线盒、电缆/插头、变送器等,凡是随表一起出货的独立零件都算。
|
||||||
|
※ 注意:位号牌、铭牌、标牌、号牌属于标识件,不算配件。缓冲钉/阻尼钉/阻尼帽都是工艺处理,不算配件。
|
||||||
|
|
||||||
|
三、耗材/密封件类:
|
||||||
|
垫片(四氟垫片、紫铜垫片、缠绕垫、密封圈、O型圈等各种材质的垫片),单独列为耗材类,不算在配件里。
|
||||||
|
|
||||||
|
【判断步骤】
|
||||||
|
1. 逐项扫描文本,剔除所有"产品本体"描述,并剔除合格证(合格证不计入判断,见上方资料类说明)。
|
||||||
|
2. 看剩下的内容里是否出现资料类、配件类或耗材类的东西。
|
||||||
|
3. 只要出现任一类,第一行就是"是";三类都没有就是"否"。"""
|
||||||
|
|
||||||
|
_OTHER_BACKGROUND_SUFFIX = """
|
||||||
|
|
||||||
|
四、其他类(兜底类目,仅在本次调用启用该选项时可用):
|
||||||
|
如果确认订单确实随货交付了某个附件,但它明显不属于以上资料/配件/耗材已列出的任何一个具体细分类目(包括各大类里已有的"其他XX"兜底项,如"其他交工文件""检验合格证书综合"),也无法合理归入其中任何一类,才可以归为"其他"。"其他"是最后的兜底选项——凡是能归到前三类具体细分类目的,都必须归到那里,不要图省事就往"其他"里放。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _background(enable_other: bool) -> str:
|
||||||
|
return _BACKGROUND + (_OTHER_BACKGROUND_SUFFIX if enable_other else "")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# coarse 模式:只输出大类
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _coarse_system_prompt(enable_other: bool) -> str:
|
||||||
|
ordered_categories = list(CATEGORY_NAMES) + ([OTHER_CATEGORY] if enable_other else [])
|
||||||
|
categories_str = "、".join(f'"{c}"' for c in ordered_categories)
|
||||||
|
return (
|
||||||
|
_background(enable_other)
|
||||||
|
+ f"""
|
||||||
|
|
||||||
|
【输出格式 —— 必须严格按此格式,只输出这两行,不要多余文字、不要标点符号、不要解释】
|
||||||
|
是否携带附件: 是
|
||||||
|
附件类型: 资料,配件
|
||||||
|
|
||||||
|
【格式规则】
|
||||||
|
- 第一行只能是"是否携带附件: 是"或"是否携带附件: 否"。
|
||||||
|
- 第二行"附件类型: "后面,如果第一行是"否",留空;如果是"是",写{categories_str}中的一个或多个,用英文逗号分隔(不要空格)。
|
||||||
|
- 不要输出 JSON、不要输出 markdown 代码块、不要输出任何解释性文字。"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FEWSHOT_COARSE: list[tuple[str, str]] = [
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 表壳材质:304SS 精度等级:1.6级 接头螺纹:M20×1.5 带缓冲钉 513结构",
|
||||||
|
"是否携带附件: 否\n附件类型: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:63mm 精度等级:2.5级 带不锈钢位号牌",
|
||||||
|
"是否携带附件: 否\n附件类型: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 张贴好装箱单并注明订单号 送货单盖发货章",
|
||||||
|
"是否携带附件: 否\n附件类型: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"接头螺纹:ZG1/4 带阻尼帽 不要阻尼钉",
|
||||||
|
"是否携带附件: 否\n附件类型: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 表壳材质:304SS 合格证随货",
|
||||||
|
"是否携带附件: 否\n附件类型: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 提供出厂检测报告 合格证随货",
|
||||||
|
"是否携带附件: 是\n附件类型: 资料",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 针型阀材质:304SS 配316L表弯管",
|
||||||
|
"是否携带附件: 是\n附件类型: 配件",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"配四氟垫片 法兰专用四氟垫片",
|
||||||
|
"是否携带附件: 是\n附件类型: 耗材",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"出厂检验报告 仪表使用说明书 配冷凝圈 过压保护器 配四氟垫片",
|
||||||
|
"是否携带附件: 是\n附件类型: 资料,配件,耗材",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 仅在 enable_other=True 时追加到 FEWSHOT_COARSE 后面
|
||||||
|
FEWSHOT_COARSE_OTHER: list[tuple[str, str]] = [
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 随货附带一个定制不锈钢防雨罩",
|
||||||
|
"是否携带附件: 是\n附件类型: 其他",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# fine 模式:额外输出第三行细分类目
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _fine_system_prompt(enable_other: bool) -> str:
|
||||||
|
ordered_categories = list(CATEGORY_NAMES) + ([OTHER_CATEGORY] if enable_other else [])
|
||||||
|
categories_str = "、".join(f'"{c}"' for c in ordered_categories)
|
||||||
|
other_line = (
|
||||||
|
f"\n其他类细分:{OTHER_FINE_TYPE}(仅当确认是随货附件、但资料/配件/耗材下所有具体"
|
||||||
|
f"细分类目都无法覆盖时才使用,不需要也不允许编造更具体的名称)"
|
||||||
|
if enable_other else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_background(enable_other)
|
||||||
|
+ f"""
|
||||||
|
|
||||||
|
【细分类目枚举 —— 第三行必须只从下面这些词里选,不能自己编造新词】
|
||||||
|
资料类细分:{"、".join(DOC_FINE_TYPES)}
|
||||||
|
配件类细分:{"、".join(PART_FINE_TYPES)}
|
||||||
|
耗材类细分:{"、".join(CONSUMABLE_FINE_TYPES)}{other_line}
|
||||||
|
|
||||||
|
【输出格式 —— 必须严格按此格式,只输出这三行,不要多余文字、不要标点符号、不要解释】
|
||||||
|
是否携带附件: 是
|
||||||
|
附件类型: 资料,配件
|
||||||
|
具体分类: 出厂检测检验报告,针型阀
|
||||||
|
|
||||||
|
【格式规则】
|
||||||
|
- 第一行只能是"是否携带附件: 是"或"是否携带附件: 否"。
|
||||||
|
- 第二行"附件类型: "后面,如果第一行是"否",留空;如果是"是",写{categories_str}中的一个或多个,用英文逗号分隔(不要空格)。
|
||||||
|
- 第三行"具体分类: "后面,如果第一行是"否",留空;如果是"是",从上面枚举的细分类目中选出所有识别到的,用英文逗号分隔(不要空格),不能选枚举之外的词,也不能只写大类名称(如"资料")。
|
||||||
|
- 第三行选出的每个细分类目,都必须能对应到第二行里的某个大类(例如选了"针型阀",第二行就必须包含"配件")。
|
||||||
|
- 不要输出 JSON、不要输出 markdown 代码块、不要输出任何解释性文字。"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FEWSHOT_FINE: list[tuple[str, str]] = [
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 表壳材质:304SS 精度等级:1.6级 接头螺纹:M20×1.5 带缓冲钉 513结构",
|
||||||
|
"是否携带附件: 否\n附件类型: \n具体分类: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:63mm 精度等级:2.5级 带不锈钢位号牌",
|
||||||
|
"是否携带附件: 否\n附件类型: \n具体分类: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 张贴好装箱单并注明订单号 送货单盖发货章",
|
||||||
|
"是否携带附件: 否\n附件类型: \n具体分类: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"接头螺纹:ZG1/4 带阻尼帽 不要阻尼钉",
|
||||||
|
"是否携带附件: 否\n附件类型: \n具体分类: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 表壳材质:304SS 合格证随货",
|
||||||
|
"是否携带附件: 否\n附件类型: \n具体分类: ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 提供出厂检测报告 合格证随货",
|
||||||
|
"是否携带附件: 是\n附件类型: 资料\n具体分类: 出厂检测检验报告",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 针型阀材质:304SS 配316L表弯管",
|
||||||
|
"是否携带附件: 是\n附件类型: 配件\n具体分类: 针型阀,表弯管",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"配四氟垫片 法兰专用四氟垫片",
|
||||||
|
"是否携带附件: 是\n附件类型: 耗材\n具体分类: 垫片",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"带不锈钢螺栓螺母固定 配304SS法兰隔膜",
|
||||||
|
"是否携带附件: 是\n附件类型: 配件\n具体分类: 紧固件,法兰隔膜",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"出厂检验报告 仪表使用说明书 配冷凝圈 过压保护器 配四氟垫片",
|
||||||
|
"是否携带附件: 是\n附件类型: 资料,配件,耗材\n具体分类: 出厂检测检验报告,说明书,冷凝圈,过压保护器,垫片",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 仅在 enable_other=True 时追加到 FEWSHOT_FINE 后面
|
||||||
|
FEWSHOT_FINE_OTHER: list[tuple[str, str]] = [
|
||||||
|
(
|
||||||
|
"表壳直径:100mm 随货附带一个定制不锈钢防雨罩",
|
||||||
|
"是否携带附件: 是\n附件类型: 其他\n具体分类: 其他",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt(mode: str, enable_other: bool = False) -> tuple[str, list[tuple[str, str]]]:
|
||||||
|
"""按模式(以及是否启用"其他"兜底类目)返回 (system_prompt, few_shot_examples)。
|
||||||
|
|
||||||
|
mode 只接受 "coarse" 或 "fine",其他值直接抛 ValueError——这里不做
|
||||||
|
宽松兼容,避免拼写错误的 mode 静默退化成某个默认行为。
|
||||||
|
|
||||||
|
enable_other 默认 False:不传该参数时,"其他"完全不出现在提示词里,
|
||||||
|
模型和之后的 parser 校验都不知道这个选项的存在,行为与不支持"其他"
|
||||||
|
之前完全一致。调用方(llm_client.py)必须把这里用的 enable_other 原样
|
||||||
|
传给 parser.parse_llm_output,两边必须一致,否则会出现"提示词允许但
|
||||||
|
校验拒绝"或反过来的不一致。
|
||||||
|
"""
|
||||||
|
if mode == "coarse":
|
||||||
|
prompt = _coarse_system_prompt(enable_other)
|
||||||
|
fewshot = FEWSHOT_COARSE + (FEWSHOT_COARSE_OTHER if enable_other else [])
|
||||||
|
return prompt, fewshot
|
||||||
|
if mode == "fine":
|
||||||
|
prompt = _fine_system_prompt(enable_other)
|
||||||
|
fewshot = FEWSHOT_FINE + (FEWSHOT_FINE_OTHER if enable_other else [])
|
||||||
|
return prompt, fewshot
|
||||||
|
raise ValueError(f"不支持的分类模式: {mode!r},只能是 'coarse' 或 'fine'")
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pyyaml>=6.0
|
||||||
|
pyodbc>=5.0
|
||||||
|
openai>=1.0
|
||||||
Reference in New Issue
Block a user