Files
InboundVerify/docs/superpowers/plans/2026-07-24-ingest-hook.md
Misaka_Company 81dea38310 docs: add ingest-hook design spec and implementation plan
Spec for mounting PostgreSQL ingest as a best-effort, kind-level,
synchronous hook on runtime.dispatch_task after a successful download,
plus the 5-task implementation plan.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 10:27:00 +08:00

22 KiB
Raw Blame History

下载后自动入库钩子 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 把 PostgreSQL 入库动作挂到 runtime.dispatch_task 下载成功分支使每次下载成功后自动、尽力而为、kind 级地把刚下载的数据 UPSERT 进 PG。

Architecture: 新增 store.ingest_task(site, kind)kind 级路由,复用现有 _ingest_*+ runtime._persist_to_db(site, kind)(同步内联钩子,懒导入 store绝不外抛+ state_store.ingest_state 表(结果可查,经 /status 暴露)+ 配置开关/超时。详见 spec docs/superpowers/specs/2026-07-24-ingest-hook-design.md

Tech Stack: Python 3.10+、psycopg(v3)、sqlite3、Playwright(不动)、FastAPI(仅 /status 加字段)、pyyaml。包以可编辑模式安装pip install -e .),命令走 .venv/Scripts/python.exe -m inbound_verify...

Global Constraints

  • Python 一律走项目虚拟环境.venv/Scripts/python.exe(全局 CLAUDE.md 协议)。命令默认在 InboundVerify/ 根目录执行。
  • 无 pytest 测试套件(本仓库约定,覆盖 writing-plans 默认的 TDD-pytest 步骤):每个任务的验证用 py_compile + black + 导入冒烟 + 功能/手动校验,不写 pytest。
  • 改完任何 .py 必须跑 Black.venv/Scripts/python.exe -m black inbound_verify
  • 不自动提交/推送(本仓库约定,覆盖 writing-plans 默认的"每任务即提交"):每个任务的 Commit 步骤仅在用户明确说"提交"/"commit"时执行;否则完成任务后停在待提交态,告知用户。
  • commit message 一律英文(全局 CLAUDE.md
  • 不改下载流程/比对逻辑4 站 -未到数据.xlsx 不入库(既有设计);不上连接池/后台线程/补入重试。
  • 导入约束:runtime 已 import comparestore 也 import compare;钩子里对 store 懒导入以回避成环。

File Structure

文件 责任 本计划改动
inbound_verify/store.py PG 持久化(建库/建表/入库 CLI _load_pg_config/_connect;加 ingest_enabled()ingest_task()CLI 加 ingest-one
inbound_verify/state_store.py SQLite 状态持久化 init_dbingest_state 表;加 set_ingest_state()get_all_ingest_state()
inbound_verify/runtime.py 两种模式共享核心(含 dispatch_task _persist_to_db();在 dispatch_task 成功分支调用
inbound_verify/cli/server.py FastAPI 服务模式 /status 返回加 ingest 字段
config.example.yaml 配置模板(提交) postgres 段加 auto_ingest/connect_timeout_seconds;修过期 db_store.pystore.py 注释
config.yaml 真实配置gitignored 同上两键 + 修注释
README.md 用户文档 DB CLI 命令列表加 ingest-one

任务依赖Task 2 依赖 Task 1Task 4 依赖 Task 1+2+3Task 5 依赖 Task 3。Task 3 独立。建议顺序 1→2→3→4→5。


Task 1: store.py 配置键 + 连接超时层

Files:

  • Modify: inbound_verify/store.py:48-78_load_pg_config_connect
  • Modify: config.example.yaml:72-89config.yamlpostgres 段)

Interfaces:

  • Consumes: 无(配置层根基)

  • Produces: _load_pg_config() 返回新增 auto_ingest: boolconnect_timeout_seconds: int_connect(dbname) 连接带 connect_timeout 且会话级 statement_timeout=30s;新函数 ingest_enabled() -> bool。后续任务依赖 ingest_enabled() 与超时连接。

  • Step 1: 改 _load_pg_config 加两键

inbound_verify/store.py_load_pg_config 返回 dictschema 之后追加两键:

    return {
        "host": pg.get("host", "127.0.0.1"),
        "port": int(pg.get("port", 5432)),
        "user": pg.get("user", "postgres"),
        "password": pg.get("password", ""),
        "dbname": pg.get("dbname", "CQHXDB"),
        "schema": pg.get("schema", "inbound_verify"),
        "auto_ingest": bool(pg.get("auto_ingest", True)),
        "connect_timeout_seconds": int(pg.get("connect_timeout_seconds", 5)),
    }
  • Step 2: 改 _connect 加 connect_timeout + statement_timeout

_connect 替换为(用 options 一次性设 search_path + statement_timeout等价于 spec 的 SET 但不引入额外事务):

def _connect(dbname):
    """用关键字参数连接(避开 conninfo 对密码特殊字符的解析)。
    options 设 search_path 到专用 schema + 会话级 statement_timeout=30s
    cpolar 隧道上防失控查询connect_timeout 守连接阶段)。"""
    c = _load_pg_config()
    return psycopg.connect(
        host=c["host"],
        port=c["port"],
        dbname=dbname,
        user=c["user"],
        password=c["password"],
        options=f"-c search_path={c['schema']} -c statement_timeout=30s",
        connect_timeout=c["connect_timeout_seconds"],
    )
  • Step 3: 加 ingest_enabled() 薄封装

_connect 之后、# 建库 / 建表 分节注释之前插入:

def ingest_enabled():
    """是否启用下载后自动入库config.yaml postgres.auto_ingest默认 True    供 runtime 钩子判定开关,避免它伸手进 _load_pg_config。"""
    return _load_pg_config()["auto_ingest"]
  • Step 4: 更新 store.py 模块 docstring 的命令列表

把文件顶部 docstring 的命令行小节,在 ingest 行后补一行 ingest-oneTask 2 会实现该命令docstring 先行):

  python -m inbound_verify.store ingest [site]   入库全站或单站(幂等 UPSERT
  python -m inbound_verify.store ingest-one <site> <kind>  仅入库指定站/类(钩子同款路由)
  python -m inbound_verify.store all             createdb → init → 全站 ingest 一条龙
  • Step 5: config.example.yaml 加两键 + 修过期注释

config.example.yaml 的 postgres 段:把 # PostgreSQL 数据持久化(到货核销数据入库,详见 db_store.py 改为 ... 详见 store.py;命令注释里的 python db_store.py ... 改为 python -m inbound_verify.store ...;在 schema: inbound_verify 之后追加:

  schema: inbound_verify
  # 下载成功后自动入库(钩子,见 runtime._persist_to_dbfalse=跳过(无 PG/cpolar 的开发机)。
  auto_ingest: true
  # PG 连接超时cpolar 抖动时快速失败,不拖垮下载 worker。
  connect_timeout_seconds: 5
  • Step 6: config.yaml 同步gitignored 本地文件)

config.yaml 做同样两键追加,并把注释里的 db_store.py 改为 store.py。保留其真实凭据不动。

  • Step 7: 编译 + Black
.venv/Scripts/python.exe -m py_compile inbound_verify/store.py
.venv/Scripts/python.exe -m black inbound_verify/store.py

Expected: py_compile 无输出black 报 reformattedleft unchanged

  • Step 8: 回归既有 CLI 不破
.venv/Scripts/python.exe -c "from inbound_verify import store; c=store._load_pg_config(); assert c['auto_ingest'] is True and c['connect_timeout_seconds']==5; assert store.ingest_enabled() is True; print('config ok')"

Expected: config ok

  • Step 9: Commitgated

仅当用户说"提交"时执行:

git add inbound_verify/store.py config.example.yaml
git commit -m "feat(store): add auto_ingest config + pg connect/statement timeouts"

config.yaml 已 gitignore不入提交。


Task 2: store.ingest_task + ingest-one CLI

Files:

  • Modify: inbound_verify/store.py(加 ingest_taskmain()ingest-one 分支)

Interfaces:

  • Consumes: Task 1 的 _connect(超时)、_load_pg_config、既有 _ingest_expected/_ingest_actual/_ingest_undelivered_baishi_read_business_datesALL_SITES_site_cfg

  • Produces: ingest_task(site: str, kind: str) -> int(返回入库总条数;__compare__/不支持组合返回 0

  • Step 1: 加 ingest_task

ingest(site) 函数之后、# 命令行 分节之前插入:

def ingest_task(site, kind):
    """按 (site, kind) 入库本次刚下载的文件(幂等 UPSERT返回总条数。
    与 ingest(site) 的区别:只入本次刷新的那一类,避免重读写另一类文件(同步钩子里减少阻塞)。
    kind 路由:
      expected/actual        各入其列;
      undelivered 百世        入未到;
      undelivered 4 站        _site_undelivered_handler 内部连带下了 expected+actual故入两者
      __compare__ / 其它组合  返回 0。
    """
    if site == "__compare__":
        return 0
    dates = _read_business_dates()
    total = 0
    with _connect(_load_pg_config()["dbname"]) as conn:
        with conn.cursor() as cur:
            if kind == "expected":
                total += _ingest_expected(cur, site, dates.get(site))
            elif kind == "actual":
                total += _ingest_actual(cur, site)
            elif kind == "undelivered":
                if site == "百世":
                    total += _ingest_undelivered_baishi(cur)
                else:  # 顺心/中通/韵达/安能
                    total += _ingest_expected(cur, site, dates.get(site))
                    total += _ingest_actual(cur, site)
            # 其它组合(如 百世/expected正常不经钩子触发防御性返回 0
        conn.commit()
    return total
  • Step 2: main()ingest-one 分支

main()elif cmd == "all": 分支之后、else: 之前插入:

    elif cmd == "ingest-one":
        kind = sys.argv[3] if len(sys.argv) > 3 else None
        if not site or kind not in ("expected", "actual", "undelivered"):
            print("用法: python -m inbound_verify.store ingest-one <site> <expected|actual|undelivered>")
            sys.exit(1)
        total = ingest_task(site, kind)
        print(f">> [ingest-one] {site}/{kind} 入库 {total} 条")
  • Step 3: 编译 + Black
.venv/Scripts/python.exe -m py_compile inbound_verify/store.py
.venv/Scripts/python.exe -m black inbound_verify/store.py

Expected: 无编译错误。

  • Step 4: 路由正确性(手动,需 PG 已 createdb+init 且 downloads/ 有数据)

逐条验证 kind 级只入对应文件:

.venv/Scripts/python.exe -m inbound_verify.store ingest-one 韵达 expected

Expected: stdout 只出现 [应到] 韵达N 条运单出现 [实到] 韵达 行;结尾 [ingest-one] 韵达/expected 入库 N 条

.venv/Scripts/python.exe -m inbound_verify.store ingest-one 顺心 undelivered

Expected: stdout 同时出现 [应到] 顺心[实到] 顺心undelivered→两者

.venv/Scripts/python.exe -m inbound_verify.store ingest-one 百世 undelivered

Expected: stdout 出现 [未到] 百世

若某站 downloads/ 无文件:_ingest_* 打印 [跳过] ... 文件不存在ingest_task 返回 0属正常非错误

  • Step 5: 无 PG 时的降级(手动,可选)

临时把 config.yamlhost 改成不可达地址,重跑 Step 4 任一命令:应在 connect_timeout_seconds(默认 5s内报 psycopg 连接错误并退出(非 hang。验证后改回真实 host。

  • Step 6: Commitgated
git add inbound_verify/store.py
git commit -m "feat(store): add kind-level ingest_task + ingest-one CLI subcommand"

Task 3: state_store ingest_state 表 + 读写函数

Files:

  • Modify: inbound_verify/state_store.py:32-113init_db 加表)、:219-247(加两函数)

Interfaces:

  • Consumes: 无(独立叶子,仅依赖 paths + sqlite3 + datetime

  • Produces: set_ingest_state(site, kind, ok, count=0, error=None) -> Noneget_all_ingest_state() -> dict(形状 {site: {kind: {ok, ingested_at, count, error}}}。Task 4 与 Task 5 依赖这两个。

  • Step 1: init_dbingest_state

init_db 内、site_settings 表 CREATE 之后(conn.commit() 之前)插入:

        conn.execute("""
            CREATE TABLE IF NOT EXISTS ingest_state (
                site        TEXT,
                kind        TEXT,
                ok          INTEGER,
                ingested_at TEXT,
                count       INTEGER,
                error       TEXT,
                PRIMARY KEY (site, kind)
            )
            """)
  • Step 2: 加 set_ingest_stateget_all_ingest_state

get_all_status() 函数之后插入:

def set_ingest_state(site, kind, ok, count=0, error=None):
    """记录一次入库结果UPSERT。ok: boolcount: 入库条数error: 失败原因或 None。"""
    with sqlite3.connect(STATE_DB_PATH) as conn:
        conn.execute(
            "INSERT INTO ingest_state (site, kind, ok, ingested_at, count, error) "
            "VALUES (?, ?, ?, ?, ?, ?) "
            "ON CONFLICT(site, kind) DO UPDATE SET "
            "ok=excluded.ok, ingested_at=excluded.ingested_at, "
            "count=excluded.count, error=excluded.error",
            (site, kind, 1 if ok else 0, _now(), int(count or 0), error or ""),
        )
        conn.commit()


def get_all_ingest_state():
    """返回 {site: {kind: {ok, ingested_at, count, error}}};库不存在返回 {}。"""
    if not os.path.exists(STATE_DB_PATH):
        return {}
    with sqlite3.connect(STATE_DB_PATH) as conn:
        rows = conn.execute(
            "SELECT site, kind, ok, ingested_at, count, error FROM ingest_state"
        ).fetchall()
    out = {}
    for site, kind, ok, ingested_at, count, error in rows:
        out.setdefault(site, {})[kind] = {
            "ok": bool(ok),
            "ingested_at": ingested_at or "",
            "count": int(count or 0),
            "error": error or "",
        }
    return out
  • Step 3: 编译 + Black
.venv/Scripts/python.exe -m py_compile inbound_verify/state_store.py
.venv/Scripts/python.exe -m black inbound_verify/state_store.py

Expected: 无错误。

  • Step 4: set/get 往返冒烟
.venv/Scripts/python.exe -c "from inbound_verify import state_store as s; s.init_db(); s.set_ingest_state('韵达','expected',True,count=42); s.set_ingest_state('韵达','expected',False,error='boom'); d=s.get_all_ingest_state(); r=d['韵达']['expected']; assert r['ok'] is False and r['count']==42 and r['error']=='boom' and r['ingested_at']; print('ingest_state ok')"

Expected: ingest_state ok(验证 UPSERT 覆盖:第二次写把 ok 改 Falsecount 保留 42error 写入)。

  • Step 5: Commitgated
git add inbound_verify/state_store.py
git commit -m "feat(state_store): add ingest_state table + set/get helpers"

Task 4: runtime._persist_to_db 钩子 + 挂到 dispatch_task

Files:

  • Modify: inbound_verify/runtime.py(加 _persist_to_db,位置在 _record_business_date 之后、dispatch_task 之前;改 dispatch_task 成功分支 :552-557

Interfaces:

  • Consumes: Task 1 store.ingest_enabled()、Task 2 store.ingest_task(site, kind)、Task 3 state_store.set_ingest_state(...);既有 state_storeruntime 已 import

  • Produces: _persist_to_db(site, kind)dispatch_task 在下载成功后调用;下载任务的成功判定不变

  • Step 1: 加 _persist_to_db

_record_business_date 函数之后(def dispatch_task 之前)插入:

def _persist_to_db(site, kind):
    """下载成功后把本次数据入库 PostgreSQL尽力而为绝不外抛不影响任务判定    - __compare__ 无源数据,跳过。
    - auto_ingest=false 时跳过(无 PG/cpolar 的开发机)。
    - 懒导入 store 以回避 import 顺序store↔compare 与 runtime↔compare 共存)。
    - 结果写 state_store.ingest_state供 /api/status 反映入库健康。
    所有写库/写状态都包 try/except失败仅告警绝不改变 dispatch_task 的 SUCCESS 判定。"""
    if site == "__compare__":
        return
    try:
        from inbound_verify import store  # 懒导入:冷路径(每下载一次),回避成环
    except Exception as e:
        print(f">> [warn] 入库模块不可用: {e}")
        return
    if not store.ingest_enabled():
        print(">> [入库] 已关闭 (auto_ingest=false),跳过")
        return
    try:
        count = store.ingest_task(site, kind)
        state_store.set_ingest_state(site, kind, ok=True, count=count)
        print(f">> [入库] {site}/{kind} 成功,{count} 条")
    except Exception as e:
        print(f">> [warn] 入库失败 {site}/{kind}: {e}")
        try:
            state_store.set_ingest_state(site, kind, ok=False, error=str(e))
        except Exception as e2:
            print(f">> [warn] 写入库状态也失败: {e2}")
  • Step 2: 挂到 dispatch_task 成功分支

dispatch_task 内的成功分支:

        _record_business_date(site, kind)
        return (state_store.TASK_SUCCESS, None)

改为:

        _record_business_date(site, kind)
        _persist_to_db(site, kind)
        return (state_store.TASK_SUCCESS, None)

_persist_to_db 绝不外抛,故不会被外层 except Exception 误判为任务失败。)

  • Step 3: 编译 + Black + 导入冒烟
.venv/Scripts/python.exe -m py_compile inbound_verify/runtime.py
.venv/Scripts/python.exe -m black inbound_verify/runtime.py
.venv/Scripts/python.exe -c "from inbound_verify.runtime import dispatch_task, _persist_to_db; print('runtime import ok')"

Expected: runtime import ok

  • Step 4: 端到端(手动,需站点已登录)

任选一种模式触发一次真实下载并观察钩子:

  • 服务模式:POST /tasks {"site":"韵达","kind":"expected"}(或经 dashboard 触发),下载完成后看 worker stdout
    • 成功:>> [入库] 韵达/expected 成功N 条
    • 失败(如 PG 未 init>> [warn] 入库失败 ...,且任务本身仍 successGET /tasks/{id} 验证)。
  • 交互模式:菜单 [6] 韵达应到,完成后看同样的 [入库] 行。

并查 state/state.db

.venv/Scripts/python.exe -c "from inbound_verify import state_store as s; print(s.get_all_ingest_state())"

Expected: 含 韵达/expected 的记录,ok 与 stdout 一致。

  • Step 5: 关开关降级(手动)

config.yamlauto_ingestfalse再触发一次下载stdout 应出现 >> [入库] 已关闭 (auto_ingest=false),跳过,且不连 PG任务仍 SUCCESS。验证后改回 true

  • Step 6: Commitgated
git add inbound_verify/runtime.py
git commit -m "feat(runtime): auto-ingest hook after successful download"

Task 5: /status 暴露 ingest 态 + README

Files:

  • Modify: inbound_verify/cli/server.py:189-196get_status
  • Modify: README.mdDB CLI 命令列表)

Interfaces:

  • Consumes: Task 3 state_store.get_all_ingest_state()

  • Produces: GET /status 返回体新增 ingest 字段。

  • Step 1: /statusingest 字段

get_status 的返回 dict

    return {
        "worker_ready": worker_state["ready"],
        "worker_error": worker_state["error"],
        "sites": state_store.get_all_status(),
    }

改为:

    return {
        "worker_ready": worker_state["ready"],
        "worker_error": worker_state["error"],
        "sites": state_store.get_all_status(),
        "ingest": state_store.get_all_ingest_state(),
    }

并把 docstring 顺手补一句(可选):"""各站登录态 + 数据态 + 入库态(前端状态盘用),另含 worker 就绪状态。"""

  • Step 2: README DB CLI 命令列表加 ingest-one

README.md 第五章"运行"下、DB CLI 注释行(# 或inbound-verify-db createdb|init|ingest|all 附近)补一行说明自动入库 + 新命令:

# DB CLI建库 / 初始化 / 灌数据 / 全流程 / 单站单类
.venv/Scripts/python.exe -m inbound_verify.store createdb   # 或 init | ingest | ingest-one <site> <kind> | all
# 注下载成功后会自动入库postgres.auto_ingest默认开ingest-one 用于手动重灌指定站/类。
  • Step 3: 编译 + Black
.venv/Scripts/python.exe -m py_compile inbound_verify/cli/server.py
.venv/Scripts/python.exe -m black inbound_verify/cli/server.py

Expected: 无错误。

  • Step 4: /status 含 ingest 字段(手动,服务模式已启动)
curl -s http://127.0.0.1:8000/status | python -m json.tool

(或浏览器 http://127.0.0.1:8000/docs/statusExpected: 返回体含 "ingest": {...} 键(无入库记录时为 {}Task 4 跑过后会有值)。经 dashboard 的 GET /api/status(代理)同样可见。

  • Step 5: 全量回归冒烟
.venv/Scripts/python.exe -m py_compile inbound_verify
.venv/Scripts/python.exe -m black inbound_verify
.venv/Scripts/python.exe -c "import inbound_verify.store, inbound_verify.state_store, inbound_verify.runtime, inbound_verify.cli.server; print('all imports ok')"

Expected: all imports okblack 无 diff。

  • Step 6: Commitgated
git add inbound_verify/cli/server.py README.md
git commit -m "feat(server): expose ingest state in /status; doc auto-ingest in README"

Self-Review 结论

  • Spec 覆盖spec §4.1 → Task 1+2§4.2 → Task 4§4.3 → Task 3§4.4 → Task 5§4.5 配置 → Task 1config 两文件§7 测试手段 → 各任务手动步ingest-one 路由、端到端、降级、回归)。无遗漏。
  • 占位符:无 TBD/TODO每步含完整代码与确切命令。
  • 类型/命名一致ingest_task(site, kind)ingest_enabled()set_ingest_state(site, kind, ok, count=0, error=None)get_all_ingest_state()_persist_to_db(site, kind) 在各任务间签名一致;/status 字段名 ingestget_all_ingest_state 返回一致。
  • 已标注偏离(a) 无 pytest → 用 py_compile/black/冒烟/手动代替Global Constraints(b) 不自动提交 → Commit 步骤 gatedGlobal Constraints + 每任务注明);(c) _connectoptions 设 statement_timeout 取代 spec 的 SET等价、无额外事务Task 1 Step 2 注释说明)。