Files
attachment_classifier/main.py
Misaka_Company 2ae5621090 feat(write_attachments): add --append mode, fix batch timing and SQL Server param limit
- Add --append flag: diff source-table 总排号 against existing
  Common.Attachment SN and classify/write only the missing ones. --limit
  caps the per-run append count, --order sets the direction. Backed by
  new fetch_existing_attachment_sns() in db.py.
- Fix batch elapsed-time accounting: summarize_results() summed each
  task's per-item elapsed_ms, which overcounts under ThreadPoolExecutor
  concurrency (cumulative work time, not real wall-clock — 100 tasks on
  8 workers reported ~5x the actual runtime). Callers now time
  classify_batch() via perf_counter and pass wall_clock_ms; both the
  write_attachments [汇总] line and main.py --summary report wall-clock
  separately from the cumulative sum.
- Format durations >=1s in seconds (88851.4 ms -> 88.85 s) in the
  human-readable [汇总] line; structured JSON --summary fields stay in ms.
- Chunk all IN (...) lists to 2000 items to respect SQL Server's 2100
  bind-parameter hard limit (previously --append --limit 5000 failed at
  the fetch step with "COUNT 字段不正确"). Applied to fetch_params_by_ids
  (sn / id paths) and to the DELETE inside upsert_attachments.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 11:17:17 +08:00

199 lines
9.3 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
import time
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))
# 整批分类的真实墙钟:在并发 classify_batch 外层计时perf_counter
# summarize_results 累加的 total_elapsed_ms 是各任务自身耗时的总和(并发下的
# 累计工作量,会明显大于墙钟),二者之比 ≈ 并发增益wall_clock_ms 才是真实墙钟。
t0 = time.perf_counter()
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,
)
wall_ms = (time.perf_counter() - t0) * 1000
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, wall_clock_ms=wall_ms)
print("--- 运行汇总 ---", file=sys.stderr)
print(json.dumps(summary, ensure_ascii=False, indent=2), file=sys.stderr)
if __name__ == "__main__":
main()