尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

graphify 增量更新与 cluster-only 详解:OpenCode 技能中 --update 运行手册的完整实现解析

graphify 增量更新与 cluster-only 详解:OpenCode 技能中 --update 运行手册的完整实现解析 graphify 增量更新与 cluster-only 详解:OpenCode 技能中 --update 运行手册的完整实现解析【免费下载链接】graphifyTurn any codebase, with its docs, SQL schemas, configs, and PDFs, into a queryable knowledge graph. A /graphify skill for Claude Code, Cursor, Codex, and Gemini CLI: local deterministic AST parsing, every edge explained, no vector store.项目地址: https://gitcode.com/GitHub_Trending/graph/graphify本文以 OpenCode 技能参考文档 graphify/skills/opencode/references/update.md 为主体,完整拆解 graphify 的--update增量重抽取流程与--cluster-only聚类重跑流程:从变更检测、code-only 快速通道、build_merge合并、manifest 落盘到 graph diff 的每一步操作,并结合仓库源码中detect_incremental、build_merge、save_manifest、graph_diff的真实实现,说明每个关键参数与历史 issue 注释背后的设计原因,帮助读者理解并复现一次零 LLM 浪费的增量构建。这份参考文档何时被加载OpenCode 技能的入口文档 graphify/skill-opencode.md 中定义了两种非默认子命令:/graphify path --update # incremental - re-extract only new/changed files /graphify path --cluster-only # rerun clustering on existing graph并在「For --update and --cluster-only」一节明确写道:两者均为非默认子命令,--update只重抽取新增或修改的文件,--cluster-only在现有图上重跑聚类,完整流程见references/update.md。因此本文主角 references/update.md 的加载条件非常明确:只有当用户传入了--update或--cluster-only时才会读取该文件;首次全量构建永远不读它。它本质上是一份给 Agent 执行的运行手册(runbook),所有命令都通过graphify-out/.graphify_python记录的解释器执行,保证 Python 环境与首次构建时一致。--update 增量重抽取:完整流程适用场景:自上次运行以来新增或修改了文件。核心理念是只重抽取变更文件,节省 token 和时间。整个流程分为六个环节。环节一:变更检测,生成 .graphify_incremental.json手册第一步调用detect_incremental,扫描目标目录并与 manifest 比对,结果落盘到graphify-out/.graphify_incremental.json:$(cat graphify-out/.graphify_python) -c import sys, json from graphify.detect import detect_incremental, save_manifest from pathlib import Path result detect_incremental(Path(INPUT_PATH)) new_total result.get(new_total, 0) print(json.dumps(result, indent2, ensure_asciiFalse)) Path(graphify-out/.graphify_incremental.json).write_text(json.dumps(result, ensure_asciiFalse), encoding\utf-8\) deleted list(result.get(deleted_files, [])) if new_total 0 and not deleted: print(No files changed since last run. Nothing to update.) raise SystemExit(0) if deleted: print(f{len(deleted)} deleted file(s) to prune.) if new_total 0: print(f{new_total} new/changed file(s) to re-extract.) 其中INPUT_PATH是技能模板占位符,执行时替换为实际的扫描根路径。三个分支语义:无变化则提前退出;有删除则报告待剪枝文件数;有新增/修改则报告待重抽取文件数。对应源码 graphify/detect.py 中,detect_incremental的 docstring 说明了判定规则:它先执行一次全量detect(),再读取上次运行留下的 manifest;支持kindsemantic与kindast两种口径:前者在semantic_hash缺失或内容变化时判定为已变更(供extract使用,确保只跑过 AST 的文件会被重新做语义抽取),后者以ast_hash为口径(供update使用);快速路径:mtime 未变且 hash 一致直接判定未变更,几乎零磁盘 IO;mtime 变化才走 MD5 内容哈希比对;manifest 不存在时视为首次运行,把全部文件当作new_files返回。环节二:填充 .graphify_detect.json,让后续步骤看到增量状态手册要求改写检测状态文件,使无条件读取它的 Steps 3A–6 感知到增量运行语义:$(cat graphify-out/.graphify_python) -c import json from pathlib import Path r json.loads(Path(graphify-out/.graphify_incremental.json).read_text(encoding\utf-8\)) Path(graphify-out/.graphify_detect.json).write_text(json.dumps({ files: r.get(new_files, {}), all_files: r.get(files, {}), total_files: r.get(new_total, 0), total_words: r.get(total_words, 0), skipped_sensitive: r.get(skipped_sensitive, []), needs_graph: True, }, ensure_asciiFalse), encoding\utf-8\) 字段职责在原文中写得很清楚:files携带变更子集,驱动 Step 3A 的 AST 抽取与 Step 3B0 的缓存检查(只对变化部分);all_files携带全量语料,供任何需要全局上下文的步骤使用。环节三:code-only 判定,决定是否跳过 LLM若存在新增文件,手册先判断所有变更文件是否都是代码文件:$(cat graphify-out/.graphify_python) -c import json from pathlib import Path result json.loads(open(graphify-out/.graphify_incremental.json, encodingutf-8).read()) if Path(graphify-out/.graphify_incremental.json).exists() else {} code_exts {.py,.ts,.js,.go,.rs,.java,.cpp,.c,.rb,.swift,.kt,.cs,.scala,.php,.cc,.cxx,.hpp,.h,.kts,.lua,.toc,.f,.F,.f90,.F90,.f95,.F95,.f03,.F03,.f08,.F08} new_files result.get(new_files, {}) all_changed [f for files in new_files.values() for f in files] code_only all(Path(f).suffix.lower() in code_exts for f in all_changed) print(code_only:, code_only) 两个分支的后续动作截然不同:code_only为 True:输出[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed),只跑 Step 3A(AST)处理变更文件,完全跳过 Step 3B(不派发任何语义子代理),然后直接进入合并与 Steps 4–8。这是增量更新最省钱的常见路径——代码文件走的是本地确定性 AST 解析,天然不需要 LLM。code_only为 False(任一变更文件是文档/论文/图片/视频):若new_files[video]非空,必须先按 references/transcribe.md(Step 2.5)对这些视频文件做转写,然后重写.graphify_detect.json,把转写文本路径移入files[document]并删除files[video]——否则原始.mp4/.mp3路径会被当作不可读媒体直接喂给语义子代理(对应文档注释中的 issue #1392)。完成该处理后再照常执行完整的 Steps 3A–3C 流水线。环节四:纯删除场景,构造空抽取结果如果只有删除、没有新增文件,需要写一个空抽取结果,让合并步骤得以执行剪枝:if [ ! -f graphify-out/.graphify_extract.json ]; then echo [graphify update] Only deletions -- creating empty extraction for merge. $(cat graphify-out/.graphify_python) -c import json from pathlib import Path Path(graphify-out/.graphify_extract.json).write_text(json.dumps({nodes:[],edges:[],hyperedges:[],input_tokens:0,output_tokens:0}), encodingutf-8) fi空结构里input_tokens/output_tokens记为 0,保证后续 token 统计不产生虚假数值。环节五:build_merge 合并——参数注释就是踩坑史合并是整个手册最核心的一段,其内联注释几乎逐条对应一次线上事故。手册要求:$(cat graphify-out/.graphify_python) -c import json from pathlib import Path from graphify.build import build_merge from graphify.detect import save_manifest # Load new extraction and incremental state new_extraction json.loads(Path(graphify-out/.graphify_extract.json).read_text(encoding\utf-8\)) incremental json.loads(Path(graphify-out/.graphify_incremental.json).read_text(encoding\utf-8\)) deleted list(incremental.get(deleted_files, [])) # prune_sources is ONLY for genuinely DELETED files. Changed/re-extracted files are # handled by build_merges replace-on-re-extract (#1344): every source_file in # new_chunks is dropped from the base before merge, so old/stale nodes dont survive. # Do NOT add changed here: with root passed, prune_set relativizes to the same base # as the freshly merged nodes and would DELETE the re-extracted content (#1178 is moot # now that replace — not the dedup pass — reconciles changed files). prune list(deleted) or None # Use build_merge() — reads graph.json directly without NetworkX round-trip # so edge direction (calls, implements, imports) is always preserved (#801). # Pass root so prune_sources (absolute paths from detect_incremental) are # relativized to match the graphs relative source_file values; without it # nothing is pruned and stale nodes accumulate on every update (#1361). # directedIS_DIRECTED: replace IS_DIRECTED with True if --directed was given, else # False. Without it a --directed --update silently rebuilds undirected and collapses # reciprocal A-B edges (#1392). G build_merge( [new_extraction], graph_pathgraphify-out/graph.json, prune_sourcesprune, rootINPUT_PATH, directedIS_DIRECTED, ) print(f[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges) # Write merged result back to .graphify_extract.json so Step 4 sees the full graph merged_out { nodes: [{id: n, **d} for n, d in G.nodes(dataTrue)], edges: [ # Explicit source/target last so they win over any stale attrs in d. {**{k: val for k, val in d.items() if k not in (_src, _tgt, source, target)}, source: d.get(_src, u), target: d.get(_tgt, v)} for u, v, d in G.edges(dataTrue) ], # G.graph[hyperedges] holds hyperedges from both existing graph.json # and new_extraction (build_merge combines them). Falling back to # new_extraction only would silently drop prior-run hyperedges (#801). hyperedges: list(G.graph.get(hyperedges, [])), input_tokens: new_extraction.get(input_tokens, 0), output_tokens: new_extraction.get(output_tokens, 0), } Path(graphify-out/.graphify_extract.json).write_text(json.dumps(merged_out, ensure_asciiFalse), encoding\utf-8\) print(f[graphify update] Merged extraction written ({len(merged_out[\nodes\])} nodes, {len(merged_out[\edges\])} edges)) # Save manifest so next --update diffs against todays state, not the # prior runs baseline (prevents ghost-node reports on subsequent updates). # root matches the build_merge call above so the manifest keys stay relative to # the scan root — portable across clones/machines, so --update keeps matching # cached files instead of missing every one after a move (#1417). # # Only stamp semantic files (docs/papers/images) that ACTUALLY produced output # THIS run (new_extraction is this runs fresh extraction, read above before the # merge overwrote the file): a changed doc whose chunk failed must stay unstamped # so the next --update re-queues it, otherwise it is marked done and its content # is lost forever (#2015). Mirrors the library extract path # (cli._stamped_manifest_files clear_semantic scan_corpus). from graphify.cli import _stamped_manifest_files _manifest_files _stamped_manifest_files(incremental[files], new_extraction, Path(INPUT_PATH)) # Changed semantic files dispatched this run but NOT stamped had their chunk fail # or be omitted; clear any stale semantic_hash so they are re-queued (#1948). _sem_types (document, paper, image) _dispatched {f for t, fl in incremental.get(new_files, {}).items() if t in _sem_types for f in fl} _stamped {f for fl in _manifest_files.values() for f in fl} _cleared _dispatched - _stamped # scan_corpus the RAW full corpus so in-root files newly excluded since last run # are dropped rather than masquerading as deletions; untouched rows preserved (#1908). _scan {f for fl in incremental[files].values() for f in fl} save_manifest(_manifest_files, rootINPUT_PATH, scan_corpus_scan, clear_semantic_cleared or None) print([graphify update] Manifest saved.) 这段代码承载了四条关键约束,每条都有源码级佐证:1.prune_sources只收真删除文件。被修改/重抽取的文件由build_merge的replace-on-re-extract机制处理(#1344):new_chunks中出现的每个source_file,其旧节点和边在合并前先从基座图中丢弃,因此旧节点不会残留。注释特别警告不要把changed也塞进 prune 集合——在传入root的情况下,prune 集合会被相对化到与新合并节点相同的基准,反而会把刚重建的内容删掉。这一点在当前 graphify/build.py 的build_merge实现中得到印证:它不仅做替换,而且是分层(tier-scoped)替换——同一文件的 AST 层与语义层各自独立,A 层重抽取只替换 A 层的旧贡献,不会误删另一层的节点(#2333/#2336)。2.root是剪枝生效的前提。detect_incremental返回的是绝对路径,而graph.json里存的是相对source_file。不传root时两边基准不一致,剪枝集合永远匹配不上,每次 update 都会累积幽灵节点(#1361)。源码里build_merge还有兜底:省略root时会回退到从图上记录的扫描根推断(#1571)。3.directed必须显式传入。IS_DIRECTED是模板占位符:用户给了--directed就填True,否则False。漏传会让一次--directed --update静默按无向图重建,把互指的 A-B 边折叠(#1392)。当前build_merge的签名是directed: bool | None None,默认None时会继承磁盘图的既有方向标记(#2342),这为手写调用之外的调用方多提供了一层保护,但技能手册仍要求显式传值。4. 合并结果必须写回.graphify_extract.json。Step 4 读取的是这个文件,必须看到旧图 新抽取的完整合并结果;超边(hyperedges)取自G.graph[hyperedges],它同时包含旧图与新抽取两份(#801);边的source/target显式放在字典末尾,以压过 data 属性中可能残留的_src/_tgt旧值。环节六:manifest 落盘——stamp、clear、scan 三件套合并完成后立即更新 manifest,让下一次--update以今天的状态为基准做 diff(否则会报告幽灵节点)。上面代码中 manifest 相关的三个动作,与 graphify/detect.py 中save_manifest的 docstring 一一对应:root保持键的相对化:manifest 键相对扫描根存储(posix 风格正斜杠),跨克隆、跨机器可移植,目录搬迁后--update仍能命中缓存文件(#1417,save_manifest注释中对应 #777 的根相对化设计);只 stamp 本次真正产出结果的语义文件:_stamped_manifest_files(incremental[files], new_extraction, Path(INPUT_PATH))只标记本次确实生成了输出的 document/paper/image 文件。一份变更文档的 chunk 若抽取失败,必须保持未 stamp,下次--update才会重新排队;否则它被标记为已完成,内容永远丢失(#2015)。注意new_extraction是在合并覆盖文件之前读到的本次新鲜抽取,所以判定基准准确;clear_semantic清理陈旧哈希:本次派发(_dispatched,类型为document/paper/image)却未 stamp(_stamped)的文件,说明其 chunk 失败或被遗漏,必须清空旧semantic_hash才能被重新排队(#1948)。save_manifest的 docstring 明确:这些文件不在files参数里,seed 循环本会把上一次的semantic_hash原样复制过去,从而掩盖遗漏;scan_corpus传原始全量语料:这样自上次运行以来被新排除规则(ignore 文件/--exclude)挡在扫描之外的 in-root 文件会被直接丢弃,而不是伪装成删除项;未被触碰的 manifest 行保持不变(#1908)。docstring 同时强调:必须传 RAW 的 detect 输出而非过滤子集,否则会连失败 chunk、--code-only文档行一并抹掉。完成后,照常在合并图上执行 Steps 4–8(聚类、分析、报告等)。更新后展示 graph diff手册要求在合并之前备份旧图,合并后与 Step 4 一起展示差异:cp graphify-out/graph.json graphify-out/.graphify_old.json # merge 前 # ... 执行合并与 Steps 4-8 ... rm -f graphify-out/.graphify_old.json # 结束后清理diff 命令:$(cat graphify-out/.graphify_python) -c import json from graphify.analyze import graph_diff from graphify.build import build_from_json from networkx.readwrite import json_graph import networkx as nx from pathlib import Path # Load old graph (before update) from backup written before merge old_data json.loads(Path(graphify-out/.graphify_old.json).read_text(encoding\utf-8\)) if Path(graphify-out/.graphify_old.json).exists() else None new_extract json.loads(Path(graphify-out/.graphify_extract.json).read_text(encoding\utf-8\)) G_new build_from_json(new_extract, directedIS_DIRECTED) if old_data: G_old json_graph.node_link_graph(old_data, edgeslinks) diff graph_diff(G_old, G_new) print(diff[summary]) if diff[new_nodes]: print(New nodes:, , .join(n[label] for n in diff[new_nodes][:5])) if diff[new_edges]: print(New edges:, len(diff[new_edges])) 底层实现在 graphify/analyze.py 的graph_diff(G_old, G_new):返回new_nodes、removed_nodes、new_edges、removed_edges与一行summary。值得注意的实现细节是边比对使用了方向感知的边键:有向图用(u, v, relation)元组,无向图用排序端点加 relation——这与前述directed参数必须正确的要求一脉相承,方向错了 diff 结论也会失真。--cluster-only:在现有图上重跑聚类--cluster-only跳过 Steps 1–3,直接:graphify cluster-only .手册对这条命令的行为界定是自包含:重新聚类、为社区命名,并从现有图重新生成GRAPH_REPORT.md、graph.json、graph.html。同时有一条硬性禁令:不要重跑 Steps 5–9。原因:这些步骤读取的中间文件(.graphify_extract.json、.graphify_detect.json、.graphify_analysis.json)已被上一次构建的清理步骤(Step 9)删除,重跑只会抛FileNotFoundError(文档注释归因于 #1392)。执行完成后,照常向用户呈现刷新后的GRAPH_REPORT.md摘要即可。设计脉络与测试佐证理解这份手册的来龙去脉,可以参考仓库中的设计计划 docs/superpowers/plans/2026-05-04-incremental-updates-dedup.md。它规划了同一条增量链路的两个独立特性:语义缓存 增量图更新:detect_incrementalbuild_merge manifest/语义缓存(check_semantic_cache/save_semantic_cache)接入 extract 流水线,增量模式自动检测(manifest.json与graph.json同时存在即进入增量),并引入--dedup-llm开关;实体去重管线:graphify/dedup.py 实现归一化精确匹配 → 熵门槛 → MinHash/LSH 候选阻塞 → Jaro-Winkler 校验 → 同社区加权 → union-find 合并的完整管线,由build()/build_merge()在构建后、聚类前调用。这也解释了为什么手册中合并调用默认开启去重:增量合并时新旧节点的标签级近似重复会在同一次build_merge中被收敛。对应的测试文件可用于验证上述行为:tests/test_incremental.py 覆盖 manifest 写入、增量模式自动检测与无 manifest 时的全量回退;tests/test_dedup.py 覆盖熵门槛、shingle、精确/模糊合并、边重连、自环丢弃与社区加权等断言。关键约束速查约束出处(issue 编号见原文档注释)违反后果prune_sources只放真正删除的文件,changed 交给 replace-on-re-extract#1344 / #1178把 changed 混入 prune 会删掉刚重建的节点合并必须传root#1361绝对剪枝路径与相对source_file不匹配,幽灵节点逐次累积directed必须按--directed显式给值#1392有向图被静默重建为无向,互指边被折叠超边取G.graph[hyperedges]而非只取新抽取#801上一轮超边被静默丢弃视频文件必须先转写再进语义管线#1392原始.mp4/.mp3被当作不可读媒体喂给子代理只 stamp 本次有产出的语义文件,派发未 stamp 者清哈希#2015 / #1948失败的文档被误标完成,内容永久丢失scan_corpus传原始全量语料#1908新排除文件伪装成删除项--cluster-only不重跑 Steps 5–9#1392读取已被清理的中间文件,FileNotFoundError综合来看,这份 OpenCode 技能参考文档虽然篇幅不长,却把增量构建中最容易出错的环节——变更检测口径、code-only 快速通道、替换与剪枝的边界、方向性保持、manifest 的 stamp/clear 语义——全部固化成了可执行命令与防错注释。对读者而言,直接复用手册中的命令序列即可在 OpenCode 中完成一次低成本的--update;而若要自行编写或审计自动化脚本,则应重点核对build_merge与save_manifest的参数约束,这些约束在 graphify/build.py 与 graphify/detect.py 的 docstring 中均有完整说明。【免费下载链接】graphifyTurn any codebase, with its docs, SQL schemas, configs, and PDFs, into a queryable knowledge graph. A /graphify skill for Claude Code, Cursor, Codex, and Gemini CLI: local deterministic AST parsing, every edge explained, no vector store.项目地址: https://gitcode.com/GitHub_Trending/graph/graphify创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表