feat: add order-attachment LLM classifier

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-07-24 13:35:28 +08:00
commit 2110e6f38c
12 changed files with 1745 additions and 0 deletions

41
.gitignore vendored Normal file
View 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
View 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` 开启。开启后新增第四大类"其他",只有
唯一细分值"其他",作为兜底——遇到确实是随货附件、但不属于以上任何一个
具体细分类目的情况才使用。关闭时模型和格式校验都不知道这个选项的存在,
行为与不支持"其他"之前完全一致。
**明确不算配件**:位号牌/铭牌/标牌(标识件);缓冲钉/阻尼钉/阻尼帽(均为
工艺处理,不算随货实物配件)
**明确不算资料**:装箱单/送货单/贴箱等物流包装指令;合格证(无论参数文本
是否提到,一律忽略——既不影响"是否携带附件"的判断,也不会出现在细分
类目里)
## 输出格式
默认逐行输出 JSONJSON 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
View 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
View 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
View 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
View 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
View 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="" 而非
# NoneHTTP层面调用是成功的必须一并捕获否则会把这种
# 情况误判为"调用成功、只是格式不对",掩盖了真正的原因。
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
View 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):
默认关闭。关闭时,模型和格式校验都不知道"其他"这个选项存在,行为与
未支持"其他"之前完全一致。开启后,遇到确实是随货附件、但不属于任何
已列出细分类目的情况,模型可以归为"其他",避免这类附件被漏判、或被
模型勉强套进不准确的类目里。命令行只能把它从""打开成"",无法
反过来用命令行强制关闭配置文件里已经打开的设置。
输出:
每个总排号一行 JSONJSON 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
pyyaml>=6.0
pyodbc>=5.0
openai>=1.0