Ver Fonte

improve filter

Talegorithm há 1 mês atrás
pai
commit
47d13cf4ee

+ 12 - 1
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -61,8 +61,19 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
     node.id !== undefined &&
     String(focusedTreeNodeId) === String(node.id);
 
+  // 只在"明确的用户导航行为"下 scroll,避免 filter 变化导致 focused 节点切换时整页跳动:
+  //   1. isSelected 变为 true(用户点了这个树节点本身)
+  //   2. focusTrigger 递增(用户按 < / > 切换匹配节点 / 点 tag 跳转)
+  // 过滤条件变化导致 shouldScrollIntoView 从 false→true 的情况**不**触发 scroll。
+  const prevFocusTriggerRef = useRef(focusTrigger);
   useEffect(() => {
-    if (nodeRef.current && (isSelected || shouldScrollIntoView)) {
+    if (!nodeRef.current) return;
+    const focusBumped = focusTrigger !== prevFocusTriggerRef.current;
+    prevFocusTriggerRef.current = focusTrigger;
+
+    if (isSelected) {
+      nodeRef.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
+    } else if (focusBumped && shouldScrollIntoView) {
       nodeRef.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
     }
   }, [isSelected, shouldScrollIntoView, focusTrigger]);

+ 16 - 0
knowhub/frontend/src/hooks/useDashboardFilter.ts

@@ -82,6 +82,19 @@ export function useDashboardFilter(graph: DashboardGraph | null) {
     }));
   };
 
+  // 全局 AND/OR:UI 上只暴露一个开关,所有 type 的 multiMode 保持同步
+  // 读取时以 req 为 proxy(6 个 type 通过 setter 永远同步)
+  const globalMultiMode: 'AND' | 'OR' = multiMode.req;
+  const setGlobalMultiMode = (mode: 'AND' | 'OR') => {
+    setMultiMode({
+      tree: mode, pattern: mode, req: mode,
+      proc: mode, cap: mode, tool: mode,
+    });
+  };
+  const toggleGlobalMultiMode = () => {
+    setGlobalMultiMode(globalMultiMode === 'AND' ? 'OR' : 'AND');
+  };
+
   // The engine —— 同 rank 约束下,只会有一个 type 持有选择
   const filterResults = useMemo(() => {
     const empty = {
@@ -169,6 +182,9 @@ export function useDashboardFilter(graph: DashboardGraph | null) {
     setSingleSelection,
     clearAll,
     toggleMultiMode,
+    globalMultiMode,
+    setGlobalMultiMode,
+    toggleGlobalMultiMode,
     getItemState,
     filterResults,
     hasActiveFilters: filterResults.active.size > 0

+ 13 - 3
knowhub/frontend/src/pages/Dashboard.tsx

@@ -1399,7 +1399,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     const id = colonIdx >= 0 ? nodeId.slice(colonIdx + 1) : '';
 
     setManualFocusedTreeNodeId(null);
-    setTreeFocusTrigger(prev => prev + 1);
+    // 选中卡片不再 bump treeFocusTrigger —— 避免页面整体被 scrollIntoView 拉回左侧
 
     // Call the dashboard filter engine to toggle selection natively
     dashFilter.toggleSelection(type as any, id);
@@ -1584,7 +1584,17 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
         {/* 右对齐:复选说明 + 三态图例 */}
         <div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
-          <span className="text-slate-400">支持同列复选取交集</span>
+          <span className="text-slate-400">
+            支持同列复选取
+            <button
+              type="button"
+              onClick={dashFilter.toggleGlobalMultiMode}
+              className="mx-0.5 px-1 rounded text-sky-600 hover:text-sky-700 hover:bg-sky-50 underline underline-offset-2 decoration-dotted cursor-pointer transition-colors font-semibold"
+              title="点击切换同列复选的组合逻辑"
+            >
+              {dashFilter.globalMultiMode === 'AND' ? '交集' : '并集'}
+            </button>
+          </span>
           <div className="flex items-center gap-1.5">
             <span className="inline-block w-4 h-3 rounded border-l-4 border-l-sky-400 border border-sky-300 bg-sky-50" />
             <span>直接关联</span>
@@ -1683,7 +1693,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
               focusTrigger={columnFocusTrigger.pattern ?? 0}
               onSelectItemset={(itemsetId, currentOrderIds) => {
                 setManualFocusedTreeNodeId(null);
-                setTreeFocusTrigger(prev => prev + 1);
+                // 不 bump treeFocusTrigger——选中 pattern 卡片不应该滚动页面
 
                 if (itemsetId !== null) {
                   dashFilter.toggleSelection('pattern', String(itemsetId));

+ 244 - 0
knowhub/knowhub_db/versioning_contract.py

@@ -0,0 +1,244 @@
+"""
+KnowHub 版本冗余契约(Versioning Redundancy Contract)
+===================================================
+
+**背景**:我们对核心实体(requirement / capability / resource / strategy)采用
+**严格冗余**的版本策略——每个版本都持有自己的一套完整行,而不是用别名 / 指针复用。
+这样 `version='tao_dev'` 可以独立演化而不影响 `version='v0'`。
+
+但"冗余"这件事在系统里的**落点分散**:
+  - ingest 脚本(新版本从头入库)
+  - duplicate 脚本(从已有版本复制出新版本)
+  - migration 脚本(加表 / 加列 / 回填)
+
+历史上栽过的坑:**新加了一张 junction 表,但 ingest/duplicate 脚本忘了同步**,导致
+对应版本的 req 在那张表里**零覆盖**——dashboard 关联筛选直接失效。典型案例:
+`requirement_pattern` 和 `requirement_node`(2026-04 修复,见
+`scripts/backfill_requirement_pattern_versions.py`)。
+
+---
+
+## 本契约做三件事
+
+1. **列表化** —— 单一真相源:所有 `requirement_id` 外键表在此集中声明。
+   以后加新 junction 表,**必须**往 `REQUIREMENT_JUNCTION_TABLES` 追加一行。
+
+2. **分类冗余语义** —— 每张表标注 `copy_semantics`:
+   - `'versioned'`      : 有 `version` 列,**可以整批 bulk-copy**(remap requirement_id)
+   - `'fresh-per-version'` : 无 version 列,payload 另一端是版本化 entity
+                             → 必须由 ingest / duplicate 脚本 **fresh 生成**,不能 bulk-copy
+
+3. **工具函数** ——
+   - `duplicate_versioned_junctions(cur, suffix, version)` :把所有 `'versioned'` 表的
+     基础行复制一份给带后缀的 req。幂等,可重跑。
+   - `audit_req_junction_coverage(cur, version)` :诊断——
+     告诉你某版本在每张表里有多少 req 被覆盖、缺多少。
+
+---
+
+## 契约(Contract Rules)
+
+任何人引入**新的 `requirement_id` 外键表**时必须:
+
+1. 在 `REQUIREMENT_JUNCTION_TABLES` 里添加 `JunctionSpec` 条目
+2. 决定其 `copy_semantics`:
+   - 如果是"跨版本语义相同的外部引用"(比如 itemset_id、node_id 这种只是个 id),
+     **加 `version` 列并标 `'versioned'`**——可以 bulk-copy,省事
+   - 如果 payload 指向的 entity 本身是版本化的(比如 capability_id、resource_id),
+     则标 `'fresh-per-version'`——必须在 ingest/duplicate 脚本里自己管
+3. 在 ingest / duplicate 脚本里加上对应的写入逻辑(如果是 fresh-per-version)
+
+---
+
+## 反面模式(DON'T)
+
+- 不要**只加表不标契约**。下次有人做新版本,这张表又漏一次
+- 不要给 `'versioned'` 表的 PK 少包含 `version` 列——会冲突覆盖
+- 不要把 `ON CONFLICT DO UPDATE` 用在加过新列的表上(AnalyticDB beam 限制,
+  见 `docs/db-operations.md §1`)——改用 `ON CONFLICT DO NOTHING`
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import List, Literal, Optional
+
+CopySemantics = Literal['versioned', 'fresh-per-version']
+
+
+@dataclass(frozen=True)
+class JunctionSpec:
+    """某张 requirement 外键关系表的版本冗余规格。"""
+    table: str
+    """表名,如 'requirement_pattern'"""
+
+    payload_columns: List[str]
+    """除 requirement_id 和 version 之外,需要被原样保留的列(建 bulk-copy SQL 用)。
+    对 'fresh-per-version' 的表,此字段仅用于文档 / 诊断,不参与自动复制。"""
+
+    has_version_column: bool
+    """该表是否有 version 列。'versioned' 必为 True;'fresh-per-version' 通常为 False。"""
+
+    copy_semantics: CopySemantics
+    """决定 new-version 的行从哪来:
+       - 'versioned'         : 从 base-version 行直接复制
+       - 'fresh-per-version' : 由 ingest/duplicate 脚本生成"""
+
+    notes: str = ''
+    """对维护者的说明:什么时候要 touch 这张表,payload 另一端怎么版本化等。"""
+
+
+# -----------------------------------------------------------------------------
+# 单一真相源:所有含 requirement_id 的 junction 表
+# -----------------------------------------------------------------------------
+REQUIREMENT_JUNCTION_TABLES: List[JunctionSpec] = [
+    JunctionSpec(
+        table='requirement_pattern',
+        payload_columns=['itemset_id', 'execution_id'],
+        has_version_column=True,
+        copy_semantics='versioned',
+        notes='pattern (itemset) 来自外部服务 (aiddit),跨 knowhub 版本语义相同,整批复制即可。',
+    ),
+    JunctionSpec(
+        table='requirement_node',
+        payload_columns=['node_id', 'execution_id', 'node_path'],
+        has_version_column=True,
+        copy_semantics='versioned',
+        notes='category_tree 的节点 id 全局共享,整批复制即可。',
+    ),
+    JunctionSpec(
+        table='requirement_capability',
+        payload_columns=['capability_id'],
+        has_version_column=False,
+        copy_semantics='fresh-per-version',
+        notes='capability 本身版本化(id 带 __<ver> 后缀)。由 ingest/duplicate 脚本 '
+              '随 cap 一起生成(见 taodev_ingest.py / version_step2_duplicate_*.py)。',
+    ),
+    JunctionSpec(
+        table='requirement_resource',
+        payload_columns=['resource_id'],
+        has_version_column=False,
+        copy_semantics='fresh-per-version',
+        notes='resource 本身版本化。随 resource 一起由 ingest/duplicate 脚本生成。',
+    ),
+    JunctionSpec(
+        table='requirement_strategy',
+        payload_columns=['strategy_id', 'is_selected', 'coverage_score', 'coverage_explanation'],
+        has_version_column=False,
+        copy_semantics='fresh-per-version',
+        notes='strategy 本身版本化。随 strategy 一起生成。',
+    ),
+    JunctionSpec(
+        table='requirement_knowledge',
+        payload_columns=['knowledge_id', 'relation_type', 'is_selected',
+                         'coverage_score', 'coverage_explanation'],
+        has_version_column=False,
+        copy_semantics='fresh-per-version',
+        notes='knowledge 使用 v0 共享基层;关联关系由业务逻辑 / LLM pipeline 生成,'
+              '非 bulk-copy 场景。',
+    ),
+]
+
+
+# -----------------------------------------------------------------------------
+# 工具函数
+# -----------------------------------------------------------------------------
+def duplicate_versioned_junctions(
+    cur,
+    suffix: str,
+    version: str,
+    *,
+    req_table: str = 'requirement',
+    dry_run: bool = False,
+    on_progress=None,
+) -> dict:
+    """
+    把所有 `copy_semantics == 'versioned'` 的 junction 表的 base 行复制给带 suffix 的 reqs。
+
+    例:`duplicate_versioned_junctions(cur, '__td', 'tao_dev')`
+    会把 `requirement_pattern` / `requirement_node` 里 requirement_id 为 base 的行
+    全部复制一份给 requirement_id 为 base + '__td' 且 version='tao_dev' 的 reqs。
+
+    幂等(ON CONFLICT DO NOTHING)。
+
+    Args:
+        cur:      psycopg2 cursor(autocommit=True 连接)
+        suffix:   req id 后缀,如 '__td'
+        version:  目标 version 值,如 'tao_dev'
+        req_table:通常固定 'requirement';可指定以便在测试 schema 上跑
+        dry_run:  True 时只打印 SQL,不执行
+        on_progress: 可选回调 (table:str, inserted:int, elapsed:float) → None
+
+    Returns:
+        {table_name: inserted_rowcount, ...}
+    """
+    result: dict = {}
+    import time
+    for spec in REQUIREMENT_JUNCTION_TABLES:
+        if spec.copy_semantics != 'versioned':
+            continue
+        payload_csv = ', '.join(spec.payload_columns)
+        source_csv = ', '.join(f'src.{c}' for c in spec.payload_columns)
+        sql = f"""
+            INSERT INTO {spec.table} (requirement_id, {payload_csv}, version)
+            SELECT r.id, {source_csv}, %s
+              FROM {spec.table} src
+              JOIN {req_table} r ON r.id = src.requirement_id || %s
+             WHERE r.version = %s
+            ON CONFLICT DO NOTHING
+        """
+        if dry_run:
+            result[spec.table] = 0
+            if on_progress:
+                on_progress(spec.table, 0, 0.0)
+            continue
+        t0 = time.time()
+        cur.execute(sql, (version, suffix, version))
+        inserted = cur.rowcount or 0
+        result[spec.table] = inserted
+        if on_progress:
+            on_progress(spec.table, inserted, time.time() - t0)
+    return result
+
+
+def audit_req_junction_coverage(cur, version: str, *, req_table: str = 'requirement') -> dict:
+    """
+    诊断:给出某版本下每张 junction 表的 req 覆盖情况。
+
+    Returns:
+        {
+            'version': 'tao_dev',
+            'total_reqs': 99,
+            'tables': {
+                'requirement_pattern':   {'covered': 77, 'missing': 22, 'semantics': 'versioned'},
+                'requirement_node':      {'covered': 99, 'missing': 0,  'semantics': 'versioned'},
+                ...
+            }
+        }
+    """
+    cur.execute(f'SELECT COUNT(*) AS c FROM {req_table} WHERE version = %s', (version,))
+    total = cur.fetchone()['c']
+    out = {'version': version, 'total_reqs': total, 'tables': {}}
+    for spec in REQUIREMENT_JUNCTION_TABLES:
+        cur.execute(
+            f"""
+            SELECT COUNT(DISTINCT r.id) AS c
+              FROM {req_table} r
+              JOIN {spec.table} j ON j.requirement_id = r.id
+             WHERE r.version = %s
+            """,
+            (version,),
+        )
+        covered = cur.fetchone()['c']
+        out['tables'][spec.table] = {
+            'covered':   covered,
+            'missing':   total - covered,
+            'semantics': spec.copy_semantics,
+        }
+    return out
+
+
+def junction_table_names(semantics: Optional[CopySemantics] = None) -> List[str]:
+    """便捷函数:列出所有(或指定语义的)junction 表名。"""
+    if semantics is None:
+        return [s.table for s in REQUIREMENT_JUNCTION_TABLES]
+    return [s.table for s in REQUIREMENT_JUNCTION_TABLES if s.copy_semantics == semantics]

+ 114 - 0
scripts/audit_req_junctions.py

@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""
+Diagnose: 列出每个已知版本在所有 requirement junction 表里的覆盖情况。
+
+无写入。适合在 CI / 定期巡检 / "感觉哪里不对时先跑一下" 的场景。
+
+用法:
+  python3 scripts/audit_req_junctions.py
+  python3 scripts/audit_req_junctions.py --versions tao_dev,v0
+"""
+import argparse
+import sys
+import time
+from pathlib import Path
+
+from dotenv import load_dotenv
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+load_dotenv(PROJECT_ROOT / '.env')
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore  # noqa
+from knowhub.knowhub_db.versioning_contract import (  # noqa
+    audit_req_junction_coverage,
+    REQUIREMENT_JUNCTION_TABLES,
+)
+
+
+def log(m): print(f'[{time.strftime("%H:%M:%S")}] {m}', flush=True)
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--versions', default=None,
+                    help='逗号分隔的版本列表;默认自动从 requirement 表取 DISTINCT version')
+    args = ap.parse_args()
+
+    log('== req junction 覆盖诊断 ==')
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    cur.execute("SET statement_timeout = '30s'")
+
+    if args.versions:
+        versions = [v.strip() for v in args.versions.split(',') if v.strip()]
+    else:
+        cur.execute("SELECT DISTINCT version FROM requirement ORDER BY version")
+        versions = [r['version'] for r in cur.fetchall()]
+
+    log(f'扫描版本:{versions}')
+    log(f'契约声明的 junction 表 {len(REQUIREMENT_JUNCTION_TABLES)} 张:')
+    for spec in REQUIREMENT_JUNCTION_TABLES:
+        log(f'  - {spec.table}  [{spec.copy_semantics}]')
+
+    # 先把每个版本的 audit 全量收上来,再做版本间对比
+    audits = {v: audit_req_junction_coverage(cur, v) for v in versions}
+
+    for v in versions:
+        audit = audits[v]
+        log('')
+        log(f'>> version={v!r}  total reqs = {audit["total_reqs"]}')
+        for table, info in audit['tables'].items():
+            log(f'    {table:<24} covered {info["covered"]}/{audit["total_reqs"]:<4} '
+                f'[{info["semantics"]}]')
+
+    # 版本间比对:versioned 表各版本 covered 数应该相等。
+    # 差异 = 冗余漏;一致(即便覆盖不满)= 源数据缺口,非本契约问题。
+    log('')
+    log('========== 版本冗余一致性检查 ==========')
+    any_critical = False
+    any_info = False
+    for spec_table in [t for t in audits[versions[0]]['tables']
+                       if audits[versions[0]]['tables'][t]['semantics'] == 'versioned']:
+        per_version_covered = {v: audits[v]['tables'][spec_table]['covered'] for v in versions}
+        max_cov = max(per_version_covered.values())
+        max_total = max(audits[v]['total_reqs'] for v in versions)
+        gaps = {v: max_cov - c for v, c in per_version_covered.items() if c < max_cov}
+        if gaps:
+            any_critical = True
+            log(f'  🚨 {spec_table}: versioned 表在版本间不一致')
+            for v, c in per_version_covered.items():
+                marker = ' ← 少' if v in gaps else ''
+                log(f'       {v:<15} covered {c}/{audits[v]["total_reqs"]}{marker}')
+            log(f'       建议:跑 scripts/backfill_requirement_pattern_versions.py')
+        else:
+            # 一致——即使覆盖不满也不是本契约的问题
+            data_gap = max_total - max_cov
+            if data_gap > 0:
+                any_info = True
+                log(f'  ℹ️  {spec_table}: 版本间一致 (所有版本都 {max_cov}/{max_total}),'
+                    f'但源数据本身缺 {data_gap} 条——非冗余问题')
+            else:
+                log(f'  ✅ {spec_table}: 所有版本覆盖一致 {max_cov}/{max_total}')
+
+    # fresh-per-version 表:版本间 coverage 差异由 ingest 决定,这里只报告,不红灯
+    log('')
+    log('========== fresh-per-version 表(仅供参考)==========')
+    for spec_table in [t for t in audits[versions[0]]['tables']
+                       if audits[versions[0]]['tables'][t]['semantics'] == 'fresh-per-version']:
+        per_version_covered = {v: audits[v]['tables'][spec_table]['covered'] for v in versions}
+        per_version_total = {v: audits[v]['total_reqs'] for v in versions}
+        line = '  '.join(f'{v}={per_version_covered[v]}/{per_version_total[v]}' for v in versions)
+        log(f'  {spec_table:<24}  {line}')
+
+    log('')
+    if any_critical:
+        log('❌ 检测到版本冗余缺口')
+        sys.exit(2)
+    if any_info:
+        log('✅ 冗余契约无问题(个别 versioned 表有源数据缺口,见 ℹ️  行)')
+    else:
+        log('✅ 冗余契约无问题')
+
+
+if __name__ == '__main__':
+    main()

+ 124 - 0
scripts/backfill_requirement_pattern_versions.py

@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""
+Backfill versioned junction tables for requirement(strict-redundancy policy)。
+
+使用 knowhub.knowhub_db.versioning_contract 里声明的契约——
+只处理 `copy_semantics='versioned'` 的表(当前:requirement_pattern / requirement_node),
+其余表(cap / resource / strategy / knowledge)属于 'fresh-per-version',
+由 ingest/duplicate 脚本自行生成,不在此处触碰。
+
+Suffix → version 映射:目前只有 tao_dev 版带独立 req 行。
+
+执行安全(见 docs/db-operations.md):
+  - autocommit=True
+  - SET statement_timeout='120s'
+  - 先 kill idle-in-tx
+  - ON CONFLICT DO NOTHING(幂等)
+  - 每步打印 + flush
+"""
+import sys
+import time
+from pathlib import Path
+
+from dotenv import load_dotenv
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+load_dotenv(PROJECT_ROOT / '.env')
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore  # noqa: E402
+from knowhub.knowhub_db.versioning_contract import (  # noqa: E402
+    duplicate_versioned_junctions,
+    audit_req_junction_coverage,
+    REQUIREMENT_JUNCTION_TABLES,
+)
+
+
+SUFFIX_TO_VERSION = {
+    '__td': 'tao_dev',
+}
+
+
+def log(msg: str) -> None:
+    print(f'[{time.strftime("%H:%M:%S")}] {msg}', flush=True)
+
+
+def _print_audit(audit: dict) -> None:
+    log(f"  version={audit['version']!r}  reqs={audit['total_reqs']}")
+    for table, info in audit['tables'].items():
+        tag = ''
+        if info['semantics'] == 'versioned':
+            tag = ' ✅' if info['missing'] == 0 else f"  ⚠️ 缺 {info['missing']}"
+        log(f"    {table:<24} covered {info['covered']}/{audit['total_reqs']} "
+            f"  [{info['semantics']}]{tag}")
+
+
+def main():
+    log('== 启动 backfill_requirement_pattern_versions (via contract) ==')
+    log(f'契约声明 junction 表 {len(REQUIREMENT_JUNCTION_TABLES)} 张:')
+    for spec in REQUIREMENT_JUNCTION_TABLES:
+        log(f'  - {spec.table}  [{spec.copy_semantics}]')
+
+    log('连接 KnowHub DB...')
+    store = PostgreSQLCapabilityStore()
+    cur = store._get_cursor()
+    log('连接成功')
+
+    try:
+        cur.execute("SET statement_timeout = '120s'")
+        log('SET statement_timeout = 120s')
+
+        # Kill idle-in-tx
+        cur.execute("""
+            SELECT pid FROM pg_stat_activity
+             WHERE state = 'idle in transaction'
+               AND pid != pg_backend_pid()
+               AND datname = current_database()
+        """)
+        pids = [r['pid'] for r in cur.fetchall()]
+        if pids:
+            log(f'发现 {len(pids)} 个 idle-in-tx:{pids},terminate')
+            for pid in pids:
+                cur.execute('SELECT pg_terminate_backend(%s)', (pid,))
+        else:
+            log('无 idle-in-tx 会话')
+
+        # ---- PRE-CHECK ---------------------------------------------------
+        log('')
+        log('========== PRE-CHECK ==========')
+        for version in set(SUFFIX_TO_VERSION.values()):
+            _print_audit(audit_req_junction_coverage(cur, version))
+
+        # ---- BACKFILL ----------------------------------------------------
+        log('')
+        log('========== BACKFILL ==========')
+
+        def _progress(table: str, inserted: int, elapsed: float) -> None:
+            log(f'  [{table}] ✓ +{inserted} 行 ({elapsed:.2f}s)')
+
+        for suffix, version in SUFFIX_TO_VERSION.items():
+            log(f'-- suffix={suffix!r} → version={version!r} --')
+            result = duplicate_versioned_junctions(
+                cur, suffix, version, on_progress=_progress,
+            )
+            log(f'  合计:{sum(result.values())} 行')
+
+        # ---- POST-CHECK --------------------------------------------------
+        log('')
+        log('========== POST-CHECK ==========')
+        for version in set(SUFFIX_TO_VERSION.values()):
+            _print_audit(audit_req_junction_coverage(cur, version))
+
+        log('')
+        log('== DONE ==')
+    except Exception as e:
+        log(f'❌ 错误:{type(e).__name__}: {e}')
+        raise
+    finally:
+        try:
+            cur.close()
+        except Exception:
+            pass
+
+
+if __name__ == '__main__':
+    main()