main
John Lauer Initial publish: canonical per-vendor PCB fab design rules. 13 vendors, 56 records (vendor, process, layer count) read… bafc3ce 1mo ago
#!/usr/bin/env python3
"""Convert a research-agent output file into canonical records JSON.

Usage: python3 tools/import_agent_json.py <agent-output.log> [<agent-output2.log> ...]

Each input file holds one fenced or bare JSON object (or array of objects)
in the agent schema:
  {"vendor": str, "vendorUrl"?: str,
   "processes": [{"process": str, "layers": {"2"|"4"|"6": {...fields...}}, "notes"?: str}],
   "unreachable"?: [str]}
Field values are {"mm": f, "mil": f, "condition"?: str} or null.

Writes rules/records/<slug>.json in the canonical page schema, preserving
every condition string and source URL. Existing files are overwritten only
for vendors present in the input.
"""

import json
import pathlib
import re
import sys

ROOT = pathlib.Path(__file__).resolve().parent.parent


def slugify(s):
    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")


def extract_json(text):
    dec = json.JSONDecoder()
    objs = []
    i = 0
    while True:
        j = len(text)
        for ch in "{[":
            k = text.find(ch, i)
            if k != -1 and k < j:
                j = k
        if j == len(text):
            break
        try:
            obj, end = dec.raw_decode(text[j:])
        except json.JSONDecodeError:
            i = j + 1
            continue
        objs.append(obj)
        i = j + end
    if not objs:
        raise ValueError("no JSON found")
    if len(objs) == 1:
        return objs[0]
    # flatten arrays of vendor objects into one list
    flat = []
    for o in objs:
        if isinstance(o, list):
            flat.extend(o)
        else:
            flat.append(o)
    return flat


def num(v, sub, default=None):
    if not v:
        return default
    return v.get(sub, default)


def r2(v):
    return round(v, 2) if isinstance(v, (int, float)) else v


def r4(v):
    return round(v, 4) if isinstance(v, (int, float)) else v


def conv_dim(v):
    """{mm, mil, condition} -> canonical, or None."""
    if not v:
        return None
    out = {}
    if v.get("mm") is not None:
        out["mm"] = r4(v["mm"])
    if v.get("mil") is not None:
        out["mil"] = r2(v["mil"])
    if v.get("condition"):
        out["condition"] = v["condition"]
    return out or None


def conv_record(process, layers_n, f):
    ann = f.get("annularRing")
    annular = None
    if ann and ann.get("mm") is not None:
        annular = {"viaMm": r4(ann["mm"]), "viaMil": r2(ann.get("mil"))}
        if ann.get("condition"):
            annular["note"] = ann["condition"]
    via = f.get("minVia") or {}
    drill_mm = num(f.get("minDrill"), "mm")
    pad_mm = via.get("padMm")
    pad_mil = via.get("padMil")
    if pad_mm is None and drill_mm is not None and annular and annular.get("viaMm"):
        pad_mm = round(drill_mm + 2 * annular["viaMm"], 4)
        pad_mil = r2(pad_mm / 0.0254)
    rec = {
        "process": process,
        "layerCount": int(layers_n),
        "minTraceWidth": conv_dim(f.get("minTraceWidth")),
        "minSpacing": conv_dim(f.get("minSpacing")),
        "minDrill": conv_dim(f.get("minDrill")),
        "minVia": ({"drillMm": r4(via["drillMm"]), "drillMil": r2(via.get("drillMil")),
                    "padMm": r4(pad_mm) if pad_mm is not None else None,
                    "padMil": r2(pad_mil) if pad_mil is not None else None} if via.get("drillMm") is not None else None),
        "annularRing": annular,
        "copperToBoardEdge": conv_dim(f.get("copperToBoardEdge")),
        "holeToHole": conv_dim(f.get("holeToHole")),
        "maskExpansion": conv_dim(f.get("maskExpansion")),
        "maskSliver": conv_dim(f.get("maskSliver")),
        "silkLineWidth": conv_dim(f.get("silkLineWidth")),
        "silkToCopper": conv_dim(f.get("silkToCopper")),
        "minCopperClearance": conv_dim(f.get("minCopperClearance")),
        "boardThicknessOptionsMm": f.get("boardThicknessOptions"),
        "copperWeightOptionsOz": f.get("copperWeightOptions"),
        "surfaceFinishes": f.get("surfaceFinishes"),
        "maxBoardSize": f.get("maxBoardSize"),
        "notes": (f.get("notes") or "").strip() or None,
        "sources": f.get("sources") or [],
    }
    return rec


def main():
    sess = pathlib.Path("/home/adom/.kimi-code/sessions/wd_project_e6a5dc78aec3/session_500e40b4-c113-4618-948b-e81bb2d33c95/agents/main/tasks")
    for arg in sys.argv[1:]:
        path = pathlib.Path(arg)
        if not path.exists():
            path = sess / arg / "output.log"
        obj = extract_json(path.read_text())
        vendors = obj if isinstance(obj, list) else [obj]
        for v in vendors:
            recs = []
            for proc in v.get("processes", []):
                for layers_n in sorted(proc.get("layers", {}), key=int):
                    recs.append(conv_record(proc["process"], layers_n, proc["layers"][layers_n]))
            out = {
                "vendor": v["vendor"],
                "vendorSlug": slugify(v["vendor"]),
                "vendorUrl": v.get("vendorUrl"),
                "records": recs,
            }
            if v.get("unreachable"):
                out["unreachable"] = v["unreachable"]
            dest = ROOT / "rules" / "records" / f"{out['vendorSlug']}.json"
            dest.write_text(json.dumps(out, indent=2) + "\n")
            print(f"{dest.name}: {len(recs)} records" + (f" (unreachable: {len(out['unreachable'])})" if out.get("unreachable") else ""))


if __name__ == "__main__":
    main()