Files
attachment_classifier/attachment_classifier.py
Misaka_Company 2110e6f38c feat: add order-attachment LLM classifier
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 13:35:28 +08:00

169 lines
8.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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])