362 lines
14 KiB
Python
362 lines
14 KiB
Python
"""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())
|