sync: WIN10-I9-64G 2026-09-19

This commit is contained in:
Myfro9
2026-09-19 19:26:02 +08:00
parent 5359cf8d1f
commit da85f2bbfd
5 changed files with 985 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
"""drawio_read.py — 把任意 drawio 文件规整成纯 mxGraphModel XML 打到 stdout。
为什么需要它:drawio MCP 的 new_diagram 写出来的**不是**纯 mxfile XML,而是
SVG 外壳 + `content="<mxfile>..."`,里面的 diagram 段还是 base64+raw-deflate
压缩的。人手在 drawio 桌面版里存的则常常是纯 XML。两种都要能读,否则
n18(按原理图回写 drawio)和 d21(原理图与 drawio 一致性比对)根本没法比。
用法:
python drawio_read.py <drawio 文件> # 打印 mxGraphModel XML
python drawio_read.py <drawio 文件> --summary # 只打印模块/连线清单
"""
from __future__ import annotations
import argparse
import base64
import html
import re
import sys
import urllib.parse
import zlib
from pathlib import Path
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
def _inflate(payload: str) -> str:
"""base64 + raw-deflate + urlencode 三层解开(drawio 的压缩 diagram 段)。"""
blob = zlib.decompress(base64.b64decode(payload), -15)
return urllib.parse.unquote(blob.decode("utf-8"))
def to_xml(path: Path) -> str:
"""返回纯 mxGraphModel/mxfile XML,无论源文件是哪种封装。"""
raw = path.read_text(encoding="utf-8", errors="replace")
# 形态 1:SVG 外壳,真内容在 content 属性里
if not raw.lstrip().startswith(("<mxfile", "<mxGraphModel")):
m = re.search(r'content="([^"]*)"', raw)
if not m:
raise SystemExit(f"{path} 既不是 mxfile,也找不到 SVG 的 content 属性")
raw = html.unescape(m.group(1))
# 形态 2:diagram 段被压缩
d = re.search(r"<diagram[^>]*>\s*([A-Za-z0-9+/=]+)\s*</diagram>", raw)
if d:
try:
return _inflate(d.group(1))
except (zlib.error, ValueError) as exc:
raise SystemExit(f"{path} 的 diagram 段解压失败:{exc}")
# 形态 3:已经是明文 XML
return raw
_CELL = re.compile(r'<mxCell\b[^>]*?id="([^"]*)"[^>]*?>', re.S)
_VAL = re.compile(r'value="([^"]*)"')
_SRC = re.compile(r'source="([^"]*)"')
_TGT = re.compile(r'target="([^"]*)"')
def summarize(xml: str) -> str:
"""列出模块与连线,供 d21 做原理图/框图一致性比对时逐条核。"""
names: dict[str, str] = {}
edges: list[tuple[str, str, str]] = []
for m in re.finditer(r"<mxCell\b.*?(?:/>|</mxCell>)", xml, re.S):
tag = m.group(0)
cid = re.search(r'id="([^"]*)"', tag)
if not cid:
continue
val = _VAL.search(tag)
label = html.unescape(val.group(1)) if val else ""
label = re.sub(r"<[^>]+>", " ", label) # 去掉 value 里的 HTML 标签
label = re.sub(r"\s*[\r\n]+\s*", " / ", label) # 多行 value 压成一行
label = re.sub(r"\s{2,}", " ", label).strip()
if 'vertex="1"' in tag:
if "edgeLabel" in tag: # 边上的 Yes/No 标签不是模块
continue
names[cid.group(1)] = label
elif 'edge="1"' in tag:
s, t = _SRC.search(tag), _TGT.search(tag)
edges.append((s.group(1) if s else "?", t.group(1) if t else "?", label))
def short(cid: str) -> str:
"""连线清单里只用模块名首段,否则整行被模块说明撑爆。"""
return names.get(cid, cid).split(" / ")[0]
lines = [f"模块 {len(names)} 个:"]
lines += [f" [{k}] {v}" for k, v in names.items()]
lines.append(f"连线 {len(edges)} 条:")
lines += [f" {short(s)} -> {short(t)}" + (f" ({lbl})" if lbl else "")
for s, t, lbl in edges]
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(description="把任意 drawio 文件规整成 mxGraphModel XML")
ap.add_argument("path", type=Path)
ap.add_argument("--summary", action="store_true", help="只打印模块/连线清单")
args = ap.parse_args()
if not args.path.is_file():
raise SystemExit(f"文件不存在:{args.path}")
xml = to_xml(args.path)
print(summarize(xml) if args.summary else xml)
return 0
if __name__ == "__main__":
sys.exit(main())
+361
View File
@@ -0,0 +1,361 @@
"""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())