Files
skills-win/kicad-flow/scripts/kf_flow.py
T
2026-09-19 19:26:02 +08:00

362 lines
14 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.
"""kf_flow.py — 驱动 kicad-flow 的 LangGraph 图(进程内,无 langgraph dev)。
图本体在 ~/skills_env.md 的 KICAD_FLOW_ROOT(app/main_graph.py),本脚本只做三件事:
1) 用 SqliteSaver 打开持久化 checkpoint,按 thread_id 续跑;
2) 把当前挂起的 interrupt 以结构化 JSON 打印给 Claude 会话;
3) 把 Claude 执行完真实动作后的结论 Command(resume=...) 回填、推进到下一个 interrupt。
**数据库落点**:与 altium-kicad 不同,kicad-flow 的 checkpoint 库落在
<被调用的 KiCad 工程>/.kicad-flow/checkpoint.sqlite,不在图工程根下。所以每个板子
一个库,`list` 也是按工程列线程。落点由图自己的 app/config.py 闸门强制校验。
为什么不直接用工程里的 run.py:run.py 的 resume 只接受一个位置参数 JSON 字符串,
在 PowerShell 里转义引号极易出错。本脚本用 --set key=value 免转义回填。
退出码:0 = 图已结束(done);2 = 停在 interrupt 等回填(waiting);1 = 出错。
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sqlite3
import sys
from pathlib import Path
# Windows 控制台默认 GBK,interrupt 里的中文 hint 会变乱码看不清。自己转 UTF-8,
# 别指望调用方每次都记得带 PYTHONIOENCODING。
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
def _env_lookup(key: str, default: str | None = None) -> str:
"""环境变量优先,其次 ~/skills_env.md 的 `KEY = value` 行(机器相关参数的真源)。"""
v = os.environ.get(key)
if v:
return v
path = os.path.join(os.path.expanduser("~"), "skills_env.md")
try:
with open(path, encoding="utf-8") as f:
for line in f:
m = re.match(rf"\s*{key}\s*=\s*(.+?)(?:\s+#.*)?$", line)
if m:
return m.group(1).strip()
except OSError:
pass
if default is None:
sys.exit(f"missing {key}: add it to ~/skills_env.md or set it as an env var")
return default
DEFAULT_GRAPH_ROOT = _env_lookup("KICAD_FLOW_ROOT", r"E:\LangGraph_Design\kicad-flow")
#: 与 app/state.py 的 max_*_retry 一一对应
RETRY_LIMITS = {
"partition": 3, "material": 5, "layout": 5, "vivado": 3, "mcu": 3,
"footprint": 5, "drawio-sync": 3, "recheck": 3, "requirement": 3,
}
# --------------------------------------------------------------------------- utils
def _out(*parts: str) -> None:
print(*parts, flush=True)
def _fatal(msg: str) -> None:
_out(f"[ERROR] {msg}")
sys.exit(1)
def slug_for(prj_dir: str) -> str:
"""E:\\Kicad_design\\MyBoard_V1p0 -> kf-myboard-v1p0"""
name = Path(prj_dir.rstrip("\\/")).name or "run"
name = re.sub(r"[^0-9A-Za-z]+", "-", name).strip("-").lower()
return f"kf-{name or 'run'}"
def coerce(raw: str):
"""--set 的值做最小类型推断:true/false→bool,纯数字→int,[ 或 { 开头→JSON。"""
s = raw.strip()
low = s.lower()
if low in ("true", "yes"):
return True
if low in ("false", "no"):
return False
if re.fullmatch(r"-?\d+", s):
return int(s)
if s[:1] in ("[", "{"):
try:
return json.loads(s)
except json.JSONDecodeError:
return raw
return raw
def pending_interrupt(snap):
for task in snap.tasks:
if task.interrupts:
return task.interrupts[0].value
return None
def load_graph_root(graph_root: Path):
"""把图工程根加进 sys.path,返回 (build_graph, config 模块)。"""
if not (graph_root / "app" / "main_graph.py").is_file():
_fatal(f"图工程根不对:{graph_root} 下没有 app\\main_graph.py。"
f"用 --graph-root 指定,设环境变量 KICAD_FLOW_ROOT,"
f"或改 ~/skills_env.md 的 KICAD_FLOW_ROOT。")
sys.path.insert(0, str(graph_root))
from app.main_graph import build_graph # noqa: E402
from app import config # noqa: E402
return build_graph, config
def init_state(layout: dict, thread_id: str, limits: dict[str, int]) -> dict:
state = {**layout, "thread_id": thread_id,
"references": [], "sim_results": [], "history": []}
for name, default in RETRY_LIMITS.items():
key = name.replace("-", "_")
state[f"{key}_retry"] = 0
state[f"max_{key}_retry"] = limits.get(name) or default
return state
# --------------------------------------------------------------------------- report
def report(graph, thread: dict, before_len: int) -> int:
"""打印当前状态;返回进程退出码。"""
snap = graph.get_state(thread)
values = snap.values or {}
history = values.get("history", [])
walked = [h.get("node") for h in history[before_len:]]
if walked:
_out("[NODES] " + " -> ".join(str(n) for n in walked))
_out(f"[THREAD] {thread['configurable']['thread_id']}")
if values.get("prj_dir"):
_out(f"[PATHS] prj_dir = {values['prj_dir']}")
_out(f"[PATHS] db = {values.get('flow_home', '')}\\checkpoint.sqlite")
_out(f"[PATHS] evidence = {values.get('runs_dir', '')}")
for key, label in (("drawio_path", "drawio"), ("schematic_path", "sch"),
("pcb_path", "pcb")):
if values.get(key):
_out(f"[FILES] {label:8s} = {values[key]}")
retries = {k: v for k, v in values.items()
if k.endswith("_retry") and not k.startswith("max_") and v}
if retries:
_out("[RETRY] " + json.dumps(retries))
if snap.next:
payload = pending_interrupt(snap)
_out(f"[WAITING] next node: {', '.join(snap.next)}")
if payload is not None:
_out("[INTERRUPT] " + json.dumps(payload, ensure_ascii=False, indent=2))
else:
_out("[INTERRUPT] (无 payload — 直接 resume 推进)")
return 2
if values.get("halted"):
_out(f"[SAFETY-END] {values.get('halted_reason', '')}")
_out(f"[REPORT] {values.get('report_path', '')}")
_out("先解决这个卡点,再用 `start --restart` 重跑,或用 --max-*-retry 调高上限。")
return 0
_out("[DONE] 图已跑完:原理图设计完成并已导入 PCB。")
_out(f"[REPORT] {values.get('report_path', '(未生成)')}")
_out(f"[PCB] {values.get('pcb_path', '(未回填)')}")
return 0
# --------------------------------------------------------------------------- commands
def _open(args):
"""解析工程路径 + 落点,返回 (build_graph, db_path, thread, layout)。"""
build_graph, config = load_graph_root(Path(args.graph_root))
prj_raw = getattr(args, "prj_dir", None)
if not prj_raw:
_fatal("缺少 --prj-dir(目标 KiCad 工程根目录)。kicad-flow 的数据库就建在它下面,"
"没有默认值,猜错会把库写到别处。")
try:
prj = config.resolve_project_dir(prj_raw)
thread_id = args.thread or slug_for(str(prj))
layout = config.prepare_project_layout(prj, thread_id)
except config.ProjectDirError as exc:
_fatal(str(exc))
return build_graph, Path(layout["db_path"]), \
{"configurable": {"thread_id": thread_id}}, layout
def cmd_start(args):
from langgraph.checkpoint.sqlite import SqliteSaver
build_graph, db_path, thread, layout = _open(args)
if args.restart:
n = wipe_thread(db_path, thread["configurable"]["thread_id"])
_out(f"[RESTART] 清掉 {n} 条 checkpoint 记录")
limits = {name: getattr(args, f"max_{name.replace('-', '_')}_retry", None)
for name in RETRY_LIMITS}
with SqliteSaver.from_conn_string(str(db_path)) as cp:
graph = build_graph(checkpointer=cp)
snap = graph.get_state(thread)
before = len((snap.values or {}).get("history", []))
if snap.next or snap.values:
_out("[RESUME] 该工程已有进行中的线程,直接续跑(不重置)")
graph.invoke(None, thread)
else:
graph.invoke(init_state(layout, thread["configurable"]["thread_id"], limits),
thread)
return report(graph, thread, before)
def cmd_status(args):
from langgraph.checkpoint.sqlite import SqliteSaver
build_graph, db_path, thread, _ = _open(args)
with SqliteSaver.from_conn_string(str(db_path)) as cp:
graph = build_graph(checkpointer=cp)
snap = graph.get_state(thread)
return report(graph, thread, len((snap.values or {}).get("history", [])))
def cmd_resume(args):
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command
build_graph, db_path, thread, _ = _open(args)
payload: dict = {}
if args.json:
try:
parsed = json.loads(args.json)
except json.JSONDecodeError as exc:
_fatal(f"--json 不是合法 JSON:{exc}")
if not isinstance(parsed, dict):
_fatal("--json 必须是一个对象")
payload.update(parsed)
for item in args.set or []:
if "=" not in item:
_fatal(f"--set 要写成 KEY=VALUE,收到:{item}")
k, v = item.split("=", 1)
payload[k.strip()] = coerce(v)
if not payload:
_fatal("回填内容不能为空 —— langgraph 把 falsy 的 resume 当作『没提供』,"
"会在同一个节点再中断一次,看起来像卡住。至少给一个键。")
with SqliteSaver.from_conn_string(str(db_path)) as cp:
graph = build_graph(checkpointer=cp)
snap = graph.get_state(thread)
if not snap.next:
_fatal("当前没有挂起的 interrupt,无需回填。先跑 status 看状态。")
before = len((snap.values or {}).get("history", []))
_out(f"[RESUME] {json.dumps(payload, ensure_ascii=False)}")
graph.invoke(Command(resume=payload), thread)
return report(graph, thread, before)
def cmd_list(args):
"""列出某个工程库里的所有线程(DB 按工程隔离,所以要给 --prj-dir)。"""
_, config = load_graph_root(Path(args.graph_root))
try:
prj = config.resolve_project_dir(args.prj_dir)
except config.ProjectDirError as exc:
_fatal(str(exc))
db = config.db_path(prj)
if not db.is_file():
_out(f"[EMPTY] {prj} 下还没有 kicad-flow 库({db} 不存在)")
return 0
con = sqlite3.connect(str(db))
try:
rows = con.execute(
"SELECT thread_id, COUNT(*) FROM checkpoints GROUP BY thread_id"
).fetchall()
except sqlite3.DatabaseError as exc:
_fatal(f"读 {db} 失败:{exc}")
finally:
con.close()
if not rows:
_out("[EMPTY] 库里没有线程")
return 0
_out(f"[DB] {db}")
for tid, n in rows:
_out(f" {tid:<28} {n} 条 checkpoint")
return 0
def wipe_thread(db_path: Path, thread_id: str) -> int:
if not db_path.is_file():
return 0
con = sqlite3.connect(str(db_path))
total = 0
try:
for table in ("checkpoints", "writes"):
try:
cur = con.execute(f"DELETE FROM {table} WHERE thread_id = ?", (thread_id,))
total += cur.rowcount or 0
except sqlite3.DatabaseError:
pass
con.commit()
finally:
con.close()
return total
def cmd_reset(args):
_, config = load_graph_root(Path(args.graph_root))
try:
prj = config.resolve_project_dir(args.prj_dir)
except config.ProjectDirError as exc:
_fatal(str(exc))
thread_id = args.thread or slug_for(str(prj))
n = wipe_thread(config.db_path(prj), thread_id)
_out(f"[RESET] {thread_id}:清掉 {n} 条记录")
return 0
# --------------------------------------------------------------------------- main
def main() -> int:
ap = argparse.ArgumentParser(prog="kf_flow", description="驱动 kicad-flow 的 LangGraph 图")
ap.add_argument("--graph-root", default=os.environ.get("KICAD_FLOW_ROOT", DEFAULT_GRAPH_ROOT),
help="图工程根(默认取 ~/skills_env.md 的 KICAD_FLOW_ROOT)")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("start", help="新建或续跑某个板子的原理图设计流程")
p.add_argument("--prj-dir", help="目标 KiCad 工程根目录(数据库就建在它下面)")
p.add_argument("--thread", help="线程 id(默认由 prj-dir 目录名推导)")
p.add_argument("--restart", action="store_true", help="清空该线程重新开始")
for name, default in RETRY_LIMITS.items():
p.add_argument(f"--max-{name}-retry", type=int,
help=f"{name} 回环重试上限(默认 {default})")
p.set_defaults(func=cmd_start)
p = sub.add_parser("status", help="看当前停在哪个 interrupt")
p.add_argument("--prj-dir")
p.add_argument("--thread")
p.set_defaults(func=cmd_status)
p = sub.add_parser("resume", help="回填结论并推进到下一个 interrupt")
p.add_argument("--prj-dir")
p.add_argument("--thread")
p.add_argument("--set", action="append", metavar="KEY=VALUE",
help="免转义回填;true/false→bool,纯数字→int,[ 或 { 开头→JSON")
p.add_argument("--json", help="直接给 JSON 对象(与 --set 可混用)")
p.set_defaults(func=cmd_resume)
p = sub.add_parser("list", help="列出该工程库里的所有线程")
p.add_argument("--prj-dir")
p.set_defaults(func=cmd_list)
p = sub.add_parser("reset", help="删除某个线程的 checkpoint")
p.add_argument("--prj-dir")
p.add_argument("--thread")
p.set_defaults(func=cmd_reset)
args = ap.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())