Files
attachment_classifier/main.py
Misaka_Company ee566e3fbd refactor: --id 改为真实 ID 列,新增 --sn 对应总排号
此前接口 --id 实际按总排号列查询,与数据库真实 ID 字段语义混淆。
现明确区分两种键类型:
- --id  -> 数据库真实 ID 列(config id_field)
- --sn  -> 总排号列(config id_column,即原先 --id 的语义)
- --ids-file 视为总排号(键类型 sn)

db.py: fetch_params_by_ids 改为接受 (标识符, 键类型) 列表,返回
  {标识符: {param, sn}};id 键回取对应总排号;新增 id_field 配置读取
classifier.py: classify_batch/classify_single 透传键类型,输出 zong_pai_hao
  一律为回查到的总排号,not_found 时回退输入标识符便于追溯
main.py / write_attachments.py: 新增 --sn,--id 改指真实 ID,二者可混用;
  全表扫描回退仍按总排号(sn)查询
README.md: 对齐 --id/--sn 语义并补充 id_field 配置说明

注:config.yaml 含密钥被 .gitignore 忽略,id_field 仅存于本地配置;
db.py 在未配置 id_field 时优雅降级为按总排号查询并告警。

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-07-24 16:00:21 +08:00

193 lines
8.8 KiB
Python
Raw 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""布莱迪压力表 - 订单附件识别 命令行入口。
用法:
python main.py --sn 26B742 # 单个总排号(粗分类,默认)
python main.py --sn 26B742,26B743,26B744 # 批量,逗号分隔
python main.py --id 802 # 单个数据库真实 ID
python main.py --id 802,803,804 # 批量真实 ID逗号分隔
python main.py --ids-file ids.txt # 批量,文件每行一个总排号(键类型 sn)
python main.py --sn 26B742 --mode fine # 精分类(输出"大类:细分"格式)
python main.py --id 802 --config other.yaml # 指定其他配置文件
python main.py --sn 26B742 --pretty # 格式化输出JSON(默认单行紧凑)
python main.py --sn 26B742 --log-dir /tmp/logs # 覆盖配置文件里的日志目录
python main.py --sn 26B742 --enable-other # 允许模型使用"其他"兜底类目
python main.py --sn 26B742 --summary # 额外在 stderr 打印本批运行汇总统计
指定方式(两种键类型可混用):
--id 数据库真实 ID 列(id_field),如 802
--sn 总排号列(id_column),如 26B742即原先 --id 的语义)
--ids-file 每行一个总排号(键类型 sn)
无论用哪种方式指定,输出 JSON 中的 zong_pai_hao 一律为回查到的总排号。
分类模式(--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, summarize_results # noqa: E402
from config_loader import ConfigError, load_config # noqa: E402
def _parse_ids(args: argparse.Namespace) -> list[tuple[str, str]]:
"""从 --id / --sn / --ids-file 解析出 (标识符, 键类型) 列表。
--id -> 键类型 "id"(数据库真实 ID 列)
--sn -> 键类型 "sn"(总排号列,即原先 --id 的语义)
--ids-file -> 每行一个总排号,键类型 "sn"
三者可同时提供、合并后返回(顺序:--id, --sn, --ids-file
"""
items: list[tuple[str, str]] = []
if args.id:
items.extend((s.strip(), "id") for s in args.id.split(",") if s.strip())
if args.sn:
items.extend((s.strip(), "sn") for s in args.sn.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:
items.extend((line.strip(), "sn") for line in f if line.strip())
return items
def main() -> None:
parser = argparse.ArgumentParser(
description="布莱迪压力表订单附件识别工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--id", type=str, default=None,
help="数据库真实 ID如 802单个或逗号分隔的多个例如: 802 或 802,803",
)
parser.add_argument(
"--sn", type=str, default=None,
help="总排号(如 26B742单个或逗号分隔的多个例如: 26B742 或 26B742,26B743"
"与 --id 同义但键类型不同(--sn 查总排号列,--id 查真实 ID 列)",
)
parser.add_argument(
"--ids-file", type=str, default=None,
help="包含总排号的文本文件路径,每行一个总排号(键类型 sn",
)
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"
"(缺省为关闭);本开关只能从命令行打开,无法用命令行强制关闭配置文件里已打开的设置",
)
parser.add_argument(
"--summary", action="store_true",
help="在 stderr 额外打印本批运行的汇总统计(耗时/token/缓存命中率);"
"stdout 仍只输出干净的 JSON Lines 结果,便于管道/重定向",
)
args = parser.parse_args()
if not args.id and not args.sn and not args.ids_file:
parser.error("必须提供 --id / --sn / --ids-file 其中之一")
ids = _parse_ids(args)
if not ids:
print("[错误] 未解析到任何有效的标识符(--id / --sn / --ids-file 均为空)", 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"],
id_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 args.summary:
summary = summarize_results(results)
print("--- 运行汇总 ---", file=sys.stderr)
print(json.dumps(summary, ensure_ascii=False, indent=2), file=sys.stderr)
if __name__ == "__main__":
main()