- 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>
348 lines
15 KiB
Python
348 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""基于源表总排号,调用 classify_batch 分类,将结果按约定写入 Common.Attachment。
|
||
|
||
落库约定(详见项目记忆 MEMORY.md,与 db.py 中的常量保持一致):
|
||
- has_attachment=true (status=ok):
|
||
每个 type 一行。fine 模式 type 形如 "大类:细分",按首个冒号拆成 (大类, 小类);
|
||
coarse 模式 type 是大类,小类统一填 '无'。
|
||
- has_attachment=false (无附件): 写哨兵行 (SN, '无附件', '无')。
|
||
- has_attachment=null (not_found / llm_error / db_error): 不写(属"无法确定/失败")。
|
||
|
||
用法:
|
||
python write_attachments.py # 全量写入(按真实 ID 升序)
|
||
python write_attachments.py --limit 10 # 仅前 10 个(按真实 ID 升序,测试)
|
||
python write_attachments.py --limit 10 --order desc # 按真实 ID 降序取前 10 个
|
||
python write_attachments.py --range 800,805 # 真实 ID 在 [800,805] 闭区间内
|
||
python write_attachments.py --range 800,805 --order desc --limit 3 # 组合:区间内降序取前3
|
||
python write_attachments.py --append --limit 10 # 追加模式:仅补写源表中还没写过的前 10 个总排号
|
||
python write_attachments.py --append --order desc --limit 10 # 追加模式:从最大 ID 端开始补 10 个
|
||
python write_attachments.py --ids-file ids.txt # 指定总排号(文件,每行一个)
|
||
python write_attachments.py --sn 26B742 # 单个总排号写入(键类型 sn)
|
||
python write_attachments.py --sn 26B742,26B743 # 多个总排号(逗号分隔)写入
|
||
python write_attachments.py --id 802 # 单个数据库真实 ID 写入(键类型 id)
|
||
python write_attachments.py --id 802,803 # 多个真实 ID(逗号分隔)写入
|
||
python write_attachments.py --mode coarse # 粗分类写入
|
||
python write_attachments.py --dry-run # 只打印将写入的行,不落库
|
||
python write_attachments.py --config other.yaml
|
||
|
||
指定方式(--id / --sn / --ids-file 可混用):
|
||
--id 数据库真实 ID 列(id_field),如 802
|
||
--sn 总排号列(id_column),如 26B742(即原先 --id 的语义)
|
||
--ids-file 每行一个总排号(键类型 sn)
|
||
无论用哪种方式,结果均按回查到的总排号写入 Common.Attachment.SN。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
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
|
||
from db import ( # noqa: E402
|
||
ATTACHMENT_SCHEMA,
|
||
ATTACHMENT_TABLE,
|
||
DatabaseError,
|
||
SENTINEL_MAJOR,
|
||
SENTINEL_MINOR,
|
||
NO_MINOR,
|
||
fetch_all_ids,
|
||
fetch_existing_attachment_sns,
|
||
get_engine,
|
||
init_schema,
|
||
upsert_attachments,
|
||
)
|
||
|
||
|
||
def _format_duration(ms: float | None) -> str:
|
||
"""把毫秒耗时格式化为人类友好的时长字符串。
|
||
|
||
超过 1 秒(>=1000ms)按秒输出(保留 2 位小数),否则按毫秒(保留 1 位小数)。
|
||
例:88851.4 -> "88.85 s",458.9 -> "458.9 ms"。None / 异常值回退为 "0 ms"。
|
||
"""
|
||
try:
|
||
v = float(ms) # type: ignore[arg-type]
|
||
except (TypeError, ValueError):
|
||
return "0 ms"
|
||
if v >= 1000:
|
||
return f"{v / 1000:.2f} s"
|
||
return f"{v:.1f} ms"
|
||
|
||
|
||
def convert_results_to_rows(results: list[dict], mode: str) -> tuple[list[tuple[str, str, str]], list[str]]:
|
||
"""将 classify_batch 的结果列表转成可写入 Common.Attachment 的行。
|
||
|
||
返回 (rows, skipped):
|
||
rows - list[(SN, MajorCategory, MinorCategory)]
|
||
skipped- list[总排号],has_attachment 为 None(not_found/失败)未写入的
|
||
"""
|
||
rows: list[tuple[str, str, str]] = []
|
||
skipped: list[str] = []
|
||
for r in results:
|
||
zph = r["zong_pai_hao"]
|
||
has = r["has_attachment"]
|
||
if has is None:
|
||
skipped.append(zph)
|
||
continue
|
||
if has is True:
|
||
for t in r["types"]:
|
||
t = t.strip()
|
||
if not t:
|
||
continue
|
||
if mode == "fine" and ":" in t:
|
||
major, minor = t.split(":", 1)
|
||
major = major.strip()
|
||
minor = minor.strip() or NO_MINOR
|
||
else:
|
||
major = t
|
||
minor = NO_MINOR
|
||
rows.append((zph, major, minor))
|
||
else: # has is False -> 无附件
|
||
rows.append((zph, SENTINEL_MAJOR, SENTINEL_MINOR))
|
||
return rows, skipped
|
||
|
||
|
||
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)。
|
||
--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="将订单附件分类结果写入 Common.Attachment",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog=__doc__,
|
||
)
|
||
parser.add_argument("--config", type=str, default="config.yaml")
|
||
parser.add_argument(
|
||
"--id", type=str, default=None,
|
||
help="数据库真实 ID(如 802),单个或逗号分隔的多个;提供后按真实 ID 列查询,"
|
||
"不再全表扫描(--limit 此时无效)",
|
||
)
|
||
parser.add_argument(
|
||
"--sn", type=str, default=None,
|
||
help="总排号(如 26B742),单个或逗号分隔的多个(即原先 --id 的语义);"
|
||
"提供后直接按总排号写入,不再全表扫描(--limit 此时无效)",
|
||
)
|
||
parser.add_argument(
|
||
"--limit", type=int, default=None,
|
||
help="取排序+范围过滤后的前 N 条总排号,测试用;与 --id/--sn/--ids-file 互斥",
|
||
)
|
||
parser.add_argument(
|
||
"--order", type=str, default="asc", choices=["asc", "desc"],
|
||
help="全表扫描时的排序方向(按真实 ID 列排序),默认 asc;与 --id/--sn/--ids-file 互斥",
|
||
)
|
||
parser.add_argument(
|
||
"--range", type=str, default=None,
|
||
help="按真实 ID 列的值域过滤,闭区间,格式 START,END(如 800,805);"
|
||
"与 --order/--limit 可组合;与 --id/--sn/--ids-file 互斥",
|
||
)
|
||
parser.add_argument(
|
||
"--ids-file", type=str, default=None,
|
||
help="包含总排号的文本文件路径,每行一个总排号(键类型 sn);可单独使用或与 --id/--sn 合并",
|
||
)
|
||
parser.add_argument(
|
||
"--append", action="store_true",
|
||
help="追加模式:对比源表全部总排号与 Common.Attachment 已有 SN,仅对"
|
||
"「源表存在、Attachment 尚未写入」的总排号分类写入。--limit 此时是"
|
||
"「本次最多追加多少个 SN」的上限,--order 决定从哪一端(升/降序)补起;"
|
||
"与 --id/--sn/--ids-file 互斥",
|
||
)
|
||
parser.add_argument(
|
||
"--mode", type=str, default=None, choices=["coarse", "fine"],
|
||
help="分类粒度,不指定则用配置文件 business.default_mode",
|
||
)
|
||
parser.add_argument("--log-dir", type=str, default=None)
|
||
parser.add_argument("--enable-other", action="store_true")
|
||
parser.add_argument(
|
||
"--dry-run", action="store_true",
|
||
help="只打印将要写入/跳过的统计,不连接目标表写入",
|
||
)
|
||
parser.add_argument(
|
||
"--init-db", action="store_true",
|
||
help="仅初始化目标库:幂等创建 Common.Attachment 表(含 Common schema),不进行分类/写入",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
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,
|
||
)
|
||
logger = logging.getLogger("write_attachments")
|
||
|
||
# 仅初始化目标表(幂等建 Common schema + Attachment 表),然后退出
|
||
if args.init_db:
|
||
try:
|
||
init_schema(get_engine(cfg["database"]))
|
||
except DatabaseError as e:
|
||
print(f"[init-db 失败] {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
print(f"[init-db] 已完成:目标表 {ATTACHMENT_SCHEMA}.{ATTACHMENT_TABLE} 已就绪(幂等)")
|
||
return
|
||
|
||
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)
|
||
)
|
||
|
||
# 1) 取总排号列表
|
||
if args.id or args.sn or args.ids_file:
|
||
if args.append:
|
||
print("[错误] --append 不能与 --id/--sn/--ids-file 同时使用", file=sys.stderr)
|
||
sys.exit(1)
|
||
ids = _parse_ids(args)
|
||
if not ids:
|
||
print("[错误] --id / --sn / --ids-file 未解析到任何有效的标识符", file=sys.stderr)
|
||
sys.exit(1)
|
||
n_id = sum(1 for _, k in ids if k == "id")
|
||
n_sn = sum(1 for _, k in ids if k == "sn")
|
||
logger.info("从 --id/--sn/--ids-file 读取 %d 个标识符(id=%d, sn=%d)", len(ids), n_id, n_sn)
|
||
elif args.append:
|
||
# 追加模式:源表全部总排号 − Common.Attachment 已有 SN = 待补写集合。
|
||
# 全表拉取源表总排号(按真实 ID 排序),再做差集;--order 控制补写方向,
|
||
# --limit 此时是"本次最多追加多少个 SN"的上限。
|
||
all_sns = fetch_all_ids(cfg["database"], order=args.order)
|
||
existing = fetch_existing_attachment_sns(cfg["database"])
|
||
missing = [s for s in all_sns if s not in existing]
|
||
print(
|
||
f"[追加] 源表 {len(all_sns)} 个总排号,Common.Attachment 已有 "
|
||
f"{len(existing)} 个,待追加 {len(missing)} 个(order={args.order})"
|
||
)
|
||
if args.limit is not None:
|
||
missing = missing[: args.limit]
|
||
print(f"[追加] 应用上限 --limit {args.limit},本次将处理 {len(missing)} 个")
|
||
ids = [(s, "sn") for s in missing]
|
||
logger.info("追加模式:待处理 %d 个总排号", len(ids))
|
||
else:
|
||
# 全表扫描路径:--range/--order/--limit 仅在此生效
|
||
if args.range:
|
||
parts = [p.strip() for p in args.range.split(",")]
|
||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||
print("[错误] --range 需为 START,END 两个数字,如 800,805", file=sys.stderr)
|
||
sys.exit(1)
|
||
try:
|
||
id_min, id_max = int(parts[0]), int(parts[1])
|
||
except ValueError:
|
||
print("[错误] --range 的 START/END 必须为整数", file=sys.stderr)
|
||
sys.exit(1)
|
||
else:
|
||
id_min = id_max = None
|
||
sn_list = fetch_all_ids(
|
||
cfg["database"], limit=args.limit, order=args.order,
|
||
id_min=id_min, id_max=id_max,
|
||
)
|
||
# 全表扫描得到的是总排号,键类型统一为 sn
|
||
ids = [(s, "sn") for s in sn_list]
|
||
logger.info(
|
||
"源表读取 %d 个总排号(order=%s%s%s)", len(ids), args.order,
|
||
f", range=[{id_min},{id_max}]" if args.range else "",
|
||
f", limit={args.limit}" if args.limit else "",
|
||
)
|
||
|
||
if not ids:
|
||
print("[提示] 没有可处理的总排号,退出")
|
||
return
|
||
|
||
# 2) 分类
|
||
logger.info("开始分类 (mode=%s, enable_other=%s)", mode, enable_other)
|
||
# 整批分类的真实墙钟:在并发 classify_batch 外层计时(perf_counter)。
|
||
# 注意 summarize_results 里累加的 total_elapsed_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
|
||
|
||
# 3) 转换
|
||
rows, skipped = convert_results_to_rows(results, mode)
|
||
|
||
# 统计各状态
|
||
n_ok = sum(1 for r in results if r["status"] == "ok")
|
||
n_empty = sum(1 for r in results if r["status"] == "empty_param")
|
||
n_notfound = sum(1 for r in results if r["status"] == "not_found")
|
||
n_err = sum(1 for r in results if r["status"].endswith("error"))
|
||
|
||
print(
|
||
f"[统计] 输入 {len(results)} 个总排号: "
|
||
f"ok={n_ok} empty_param={n_empty} not_found={n_notfound} error={n_err}"
|
||
)
|
||
print(
|
||
f"[统计] 将写入 {len(rows)} 行, "
|
||
f"跳过(无附件判定=null) {len(skipped)} 个"
|
||
)
|
||
|
||
# 运行汇总:墙钟耗时 + 各任务累计工作量 + token 总量/缓存命中率。
|
||
# 走 stdout 与上面的 [统计] 一致;不属于落库动作本身,仅作诊断输出。
|
||
summary = summarize_results(results, wall_clock_ms=wall_ms)
|
||
print(
|
||
f"[汇总] 总耗时 {_format_duration(summary['wall_clock_ms'])}, "
|
||
f"LLM 累计 {_format_duration(summary['total_elapsed_ms'])} "
|
||
f"(并发 {cfg['business']['max_workers']}), "
|
||
f"调用 {summary['llm_calls']} 次, "
|
||
f"token: prompt={summary['total_prompt_tokens']} "
|
||
f"completion={summary['total_completion_tokens']} "
|
||
f"total={summary['total_tokens']}, "
|
||
f"缓存命中率 {summary['avg_cache_hit_rate']} "
|
||
f"(hit={summary['total_cache_hit_tokens']} "
|
||
f"miss={summary['total_cache_miss_tokens']})"
|
||
)
|
||
|
||
if args.dry_run:
|
||
print("[dry-run] 不落库。示例行(前 10):")
|
||
for row in rows[:10]:
|
||
print(" ", row)
|
||
return
|
||
|
||
# 4) 写入
|
||
target = f"{ATTACHMENT_SCHEMA}.{ATTACHMENT_TABLE}"
|
||
try:
|
||
deleted, inserted = upsert_attachments(cfg["database"], rows)
|
||
print(f"[完成] 已写入 {target}: 删除旧行 {deleted}, 插入新行 {inserted}")
|
||
except DatabaseError as e:
|
||
print(f"[写入失败] {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|