app
Adom DS2SF - Datasheet to Symbol and Footprint
Public Made by Adomby adom
Datasheet to symbol + footprint + provenance JSON — pins and pads, extracted from the PDF.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
use anyhow::{anyhow, Context as _, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
use std::io::Write;
use std::process::{Command, Stdio};
use crate::context::Context;
use crate::io::{note, warn};
/// Outcome of the symbol pass. Lets cli.rs distinguish "model refused with explanation"
/// (which is recoverable — try a different PDF) from a hard parse/CLI failure.
pub enum SymbolOutcome {
Ok(SymbolPass),
Refused {
review_required: Vec<Value>,
raw_response: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolPass {
#[serde(rename = "symbolName")]
pub symbol_name: String,
pub manufacturer: Option<String>,
pub package: Option<String>,
pub description: Option<String>,
#[serde(default = "default_category")]
pub category: String,
#[serde(rename = "referencePrefix", default = "default_ref_prefix")]
pub reference_prefix: String,
pub pins: Vec<Pin>,
#[serde(default)]
pub groups: Vec<Group>,
/// Optional KiCad standard-library baseline for discrete parts
/// (transistors / diodes / passives). Lets downstream sym_create reuse
/// the canonical symbol shape instead of authoring a generic rectangle.
#[serde(default, rename = "discreteBaseline", skip_serializing_if = "Option::is_none")]
pub discrete_baseline: Option<DiscreteBaseline>,
#[serde(default)]
pub variants: Vec<Value>,
#[serde(default, rename = "review_required")]
pub review_required: Vec<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscreteBaseline {
pub library: String,
pub symbol: String,
}
fn default_category() -> String { "ic".to_string() }
fn default_ref_prefix() -> String { "U".to_string() }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pin {
pub number: String,
pub name: String,
#[serde(rename = "type")]
pub kind: String,
#[serde(default)]
pub group: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub source_page: u32,
#[serde(default)]
pub source_table_caption: String,
#[serde(default = "default_confidence")]
pub confidence: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub notes: String,
}
fn default_confidence() -> String { "medium".to_string() }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FootprintPass {
pub package_name: String,
#[serde(default)]
pub is_standard_jedec: bool,
#[serde(default)]
pub package_name_source_page: u32,
#[serde(rename = "padCount")]
pub pad_count: u32,
#[serde(rename = "bodyDimensions")]
pub body_dimensions: BodyDims,
#[serde(rename = "leadDimensions")]
pub lead_dimensions: Value,
#[serde(default)]
pub mechanical: Option<Value>,
#[serde(rename = "padDescriptions", default)]
pub pad_descriptions: serde_json::Map<String, Value>,
#[serde(rename = "symbolPinMap", default)]
pub symbol_pin_map: serde_json::Map<String, Value>,
#[serde(default, rename = "review_required")]
pub review_required: Vec<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BodyDims {
pub x: f64,
pub y: f64,
pub thickness_max: Option<f64>,
#[serde(default)]
pub source_page: u32,
#[serde(default)]
pub figure_label: String,
}
const KICAD_ERC_TYPES: &[&str] = &[
"power_in", "power_out", "input", "output", "bidirectional",
"passive", "unconnected", "tri_state",
];
const ADOM_GROUPS: &[&str] = &[
"POWER", "GROUND", "I2C", "SPI", "UART", "USB", "QSPI", "GPIO",
"ADC", "DAC", "CONTROL", "TIMING", "ALERT", "NC", "DEBUG", "RF",
"CAN", "ETHERNET", "PWM", "AUDIO", "VIDEO", "OTHER",
];
pub fn run_symbol_pass(ctx: &Context, model: &str) -> Result<SymbolOutcome> {
note(format!("[1a] symbol-pass calling claude (model={model})"));
let prompt = build_symbol_prompt(ctx);
let raw = call_claude(&prompt, model, "symbol-pass-1", &ctx.chip_dir)?;
fs::write(ctx.cache_dir().join("claude-symbol-raw.txt"), &raw).ok();
let parsed_json = extract_json(&raw)?;
// Refusal path: empty pins + populated review_required = the model declined to
// hallucinate (e.g. wrong PDF). This is recoverable upstream — bubble out as
// a Refused outcome with the review_required so the result.json carries it.
if let Some(rr) = is_refusal(&parsed_json) {
return Ok(SymbolOutcome::Refused {
review_required: rr,
raw_response: raw,
});
}
match validate_symbol(&parsed_json) {
Ok(()) => {
let pass: SymbolPass = serde_json::from_value(parsed_json)
.context("deserializing symbol-pass JSON")?;
Ok(SymbolOutcome::Ok(pass))
}
Err(e) => {
warn(format!("symbol-pass validation failed: {e} — retrying once"));
let retry_prompt = format!(
"{}\n\nYour previous response failed validation:\n{}\n\nRespond again with corrected JSON only.",
prompt, e
);
let raw2 = call_claude(&retry_prompt, model, "symbol-pass-2", &ctx.chip_dir)?;
fs::write(ctx.cache_dir().join("claude-symbol-raw-retry.txt"), &raw2).ok();
let parsed2 = extract_json(&raw2)?;
if let Some(rr) = is_refusal(&parsed2) {
return Ok(SymbolOutcome::Refused {
review_required: rr,
raw_response: raw2,
});
}
validate_symbol(&parsed2)
.with_context(|| "symbol-pass validation failed after retry")?;
let pass: SymbolPass = serde_json::from_value(parsed2)
.context("deserializing symbol-pass JSON (retry)")?;
Ok(SymbolOutcome::Ok(pass))
}
}
}
/// Detects a "refusal" — empty pins[] but review_required[] is populated, meaning
/// Claude explained why it can't extract (typically wrong PDF for a module).
fn is_refusal(v: &Value) -> Option<Vec<Value>> {
let pins_empty = v
.get("pins")
.and_then(|p| p.as_array())
.map(|a| a.is_empty())
.unwrap_or(true);
if !pins_empty {
return None;
}
let rr = v
.get("review_required")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
if rr.is_empty() { None } else { Some(rr) }
}
pub fn run_footprint_pass(ctx: &Context, model: &str, sym: &SymbolPass) -> Result<FootprintPass> {
note(format!("[1b] footprint-pass calling claude (model={model})"));
let prompt = build_footprint_prompt(ctx, sym);
let raw = call_claude(&prompt, model, "footprint-pass-1", &ctx.chip_dir)?;
fs::write(ctx.cache_dir().join("claude-footprint-raw.txt"), &raw).ok();
let parsed = extract_json(&raw)?;
match validate_footprint(&parsed, sym) {
Ok(()) => {
let fp: FootprintPass = serde_json::from_value(parsed)
.context("deserializing footprint-pass JSON")?;
Ok(fp)
}
Err(e) => {
warn(format!("footprint-pass validation failed: {e} — retrying once"));
let retry_prompt = format!(
"{}\n\nYour previous response failed validation:\n{}\n\nRespond again with corrected JSON only.",
prompt, e
);
let raw2 = call_claude(&retry_prompt, model, "footprint-pass-2", &ctx.chip_dir)?;
fs::write(ctx.cache_dir().join("claude-footprint-raw-retry.txt"), &raw2).ok();
let parsed2 = extract_json(&raw2)?;
validate_footprint(&parsed2, sym)
.with_context(|| "footprint-pass validation failed after retry")?;
let fp: FootprintPass = serde_json::from_value(parsed2)
.context("deserializing footprint-pass JSON (retry)")?;
Ok(fp)
}
}
}
pub fn cache_symbol(ctx: &Context, sym: &SymbolPass) -> Result<()> {
let p = ctx.symbol_cache_path();
fs::write(&p, serde_json::to_string_pretty(sym)?)
.with_context(|| format!("writing {}", p.display()))?;
Ok(())
}
pub fn cache_footprint(ctx: &Context, fp: &FootprintPass) -> Result<()> {
let p = ctx.footprint_cache_path();
fs::write(&p, serde_json::to_string_pretty(fp)?)
.with_context(|| format!("writing {}", p.display()))?;
Ok(())
}
pub fn load_cached_symbol(ctx: &Context) -> Result<SymbolPass> {
let p = ctx.symbol_cache_path();
let raw = fs::read_to_string(&p)
.with_context(|| format!("reading cached symbol at {}", p.display()))?;
serde_json::from_str(&raw).context("parsing cached symbol JSON")
}
pub fn merge_review_required(sym: &SymbolPass, fp: Option<&FootprintPass>) -> Vec<Value> {
let mut all = sym.review_required.clone();
if let Some(f) = fp {
all.extend(f.review_required.clone());
}
all
}
fn build_symbol_prompt(ctx: &Context) -> String {
let manuf = ctx.manufacturer.as_deref().unwrap_or("(unknown manufacturer)");
let pkg_hint = ctx.package_hint.as_deref().unwrap_or("(no package hint)");
let desc = ctx.description_hint.as_deref().unwrap_or("");
let ds_path = ctx.datasheet_path.display();
let mpn = &ctx.mpn;
format!(
r#"You are extracting symbol/pinout data for the chip "{mpn}" from its datasheet PDF.
CONTEXT (from upstream chip-fetcher):
- MPN: {mpn}
- Manufacturer: {manuf}
- Package hint: {pkg_hint}
- Family/description hint: {desc}
DATASHEET PDF (please use the Read tool to read this file):
{ds_path}
THIS CALL IS ONLY ABOUT PIN/SYMBOL DATA. Ignore mechanical drawings and footprint
land patterns — a separate call will handle those. Your output must be valid JSON
matching the schema below; do not return explanatory text outside the JSON block.
If the datasheet covers multiple package variants, restrict the pinout to the
variant matching the package hint above.
SCHEMA:
{{
"symbolName": "string (use the MPN, possibly stripped of suffixes)",
"manufacturer": "string",
"package": "string (concise package name, e.g. 'VSSOP-10')",
"description": "1-3 sentence chip summary",
"category": "ic | discrete",
"referencePrefix": "U (IC) | Q (transistor) | D (diode/LED) | R | C | L | Y (crystal) | F (fuse) | BT | J/P (connector) | SW | K (relay) | T (transformer)",
"discreteBaseline": {{ "library": "Transistor_FET | Device | ...", "symbol": "Q_PMOS_GSD | LED | ..." }},
"pins": [
{{
"number": "string (pin number as it appears on the package; e.g. '1' or 'A4')",
"name": "string (e.g. 'VDD', 'SCL', 'GPIO0')",
"type": "one of: power_in, power_out, input, output, bidirectional, passive, unconnected, tri_state",
"group": "one of: POWER, GROUND, I2C, SPI, UART, USB, QSPI, GPIO, ADC, DAC, CONTROL, TIMING, ALERT, NC, DEBUG, RF, CAN, ETHERNET, PWM, AUDIO, VIDEO, OTHER",
"description": "1-3 sentence engineering description with V/I/Ω where applicable",
"source_page": 0,
"source_table_caption": "string (e.g. 'Table 7-1. Pin description')",
"confidence": "high | medium | low",
"notes": "optional — muxed-pin protocols, alt-functions, reset-state, etc."
}}
],
"groups": [
{{ "name": "POWER", "description": "2-4 sentence group description" }}
],
"variants": [
{{
"package": "string (e.g. 'VSSOP-10')",
"ordering_suffixes": ["IDGSR", "IDGS"],
"pin_count": 10,
"body_dims_approx_mm": [3.0, 3.0, 1.1],
"source_pages": [1, 48],
"compatible_pinout": true,
"notes": "optional — differences from the main extraction (thermal pad, extra pins, etc.)"
}}
],
"review_required": [
{{ "reason": "muxed_pin_protocol_select", "pins": ["A4","A5"] }}
]
}}
MULTI-VARIANT DISCOVERY:
- Most datasheets describe the chip in multiple package variants (e.g. SOIC-8 + TSSOP-8,
or QFN-32 + LQFP-48). List ALL variants you find in the "variants" array.
- For each variant: the package name, any ordering code suffixes that map to it
(check the ordering information or package marking table), approximate body
dimensions if visible, and which pages describe it.
- compatible_pinout = true when the variant has the same pin-name assignments as
the main extraction (just a different physical package). Set false when pin
assignments differ (different number of pins, or pins rearranged).
- The main pins[] / groups[] still focus on the variant matching package_hint above.
The variants[] array is a discovery catalog for chip-fetcher to offer the user
alternative packages (smaller, cheaper, different thermal characteristics).
- Include at least the variant matching package_hint in the array.
RULES:
- Every pin MUST include source_page (the 1-indexed PDF page where the pin's row appears in the pinout table).
- Use canonical Adom group names exactly. Collapse separate VCC/GND tables into POWER unless the datasheet has a clear distinction.
- For muxed pins (e.g. SDA/MOSI), keep the slash-delimited name and add a note explaining how the protocol is selected.
- When uncertain, set confidence to "low" and add a row to review_required.
- If the data isn't in the datasheet, omit the field and surface it in review_required — DO NOT hallucinate.
- IF THE PART IS A MODULE (chip + supporting components packaged together with castellated edge pads, e.g. ESP32-WROOM, NORA, ATWINC, SARA-R5, etc.), extract the MODULE's external pinout — typically 20-60 castellated pads — NOT the internal SoC's pinout. The module's pin numbers are usually 1..N matching silkscreen labels on the module's edge. Add a review_required entry of reason "module_external_pinout" so downstream tools know this symbol represents a module.
- IF THE DATASHEET DESCRIBES SIGNAL CLASSES BUT DEFERS PER-PACKAGE PIN-NUMBER ASSIGNMENTS to a separate supplement (Lattice iCE40 family is the textbook case — main datasheet has only signal-level descriptions like CRESET_B, SPI_SCK, G0–G6, RGB0/1/2, generic PIOT_xx/PIOB_xx, with the SG48/UG121-pin-number mapping deferred to "iCE40 UltraPlus Pinout Files" or a "Package Diagrams" supplement) — DO NOT EXTRACT PARTIAL SIGNAL DATA. Instead emit `pins: []` and add a `review_required` entry with reason `pinout_supplement_required` listing the supplement filename or document title the datasheet references. The upstream caller (chip-fetcher) will walk to a sibling PDF named `*-package-diagrams.pdf` / `*-pinout*.pdf` / `*-hardware-checklist.pdf` and re-call.
DISCRETE COMPONENT CLASSIFICATION (CRITICAL — gets the schematic refdes right):
If the part is a discrete component (NOT an integrated circuit), set:
- category: "discrete" (NOT "ic")
- referencePrefix to the EE-canonical letter:
Part type | category | referencePrefix
--------------------------------------------|------------|----------------
MOSFET / BJT / JFET / IGBT / transistor | discrete | Q
Diode / LED / Schottky / TVS / Zener / SBD | discrete | D
Resistor (chip resistor, current sense) | discrete | R
Capacitor (cap, varactor, trimcap) | discrete | C
Inductor / choke / ferrite bead | discrete | L
Crystal / resonator / TCXO / oscillator | discrete | Y
Fuse | discrete | F
Battery | discrete | BT
Connector / jack / receptacle / header | discrete | J or P
Switch / button / tactile | discrete | SW
Relay | discrete | K
Transformer | discrete | T
- For 3-lead transistors, pins are conventionally G/D/S (MOSFET/JFET) or B/C/E (BJT)
- For 2-lead diodes/LEDs, pins are conventionally Anode/A and Cathode/K (or numbered 1/2)
- For 2-lead passives (resistor/cap/inductor), pins are conventionally numbered 1/2
If the part has a control input + multiple outputs / inputs / interfaces, it's an IC: keep
category: "ic" and referencePrefix: "U". Op-amps, comparators, voltage references,
ADCs, DACs, microcontrollers, FPGAs, sensors with I2C/SPI — all ICs. Don't be fooled
by low pin counts: an 8-pin op-amp is an IC, not a discrete.
For discrete parts you may also propose a discreteBaseline so downstream sym_create can
reuse a KiCad standard symbol. Examples:
- P-channel MOSFET → discreteBaseline: {{ library: "Transistor_FET", symbol: "Q_PMOS_GSD" }}
- N-channel MOSFET → {{ library: "Transistor_FET", symbol: "Q_NMOS_GDS" }}
- LED → {{ library: "Device", symbol: "LED" }}
- Diode → {{ library: "Device", symbol: "D" }}
- TVS / Zener → {{ library: "Device", symbol: "D_TVS" }} or "D_Zener"
Return ONLY the JSON object. No prose before or after.
"#,
)
}
fn build_footprint_prompt(ctx: &Context, sym: &SymbolPass) -> String {
let manuf = ctx.manufacturer.as_deref().unwrap_or("(unknown)");
let pkg_hint = ctx.package_hint.as_deref().unwrap_or("(no hint)");
let ds_path = ctx.datasheet_path.display();
let mpn = &ctx.mpn;
let pin_map = serde_json::to_string_pretty(
&sym.pins
.iter()
.map(|p| json!({"number": p.number, "name": p.name}))
.collect::<Vec<_>>(),
)
.unwrap_or_default();
format!(
r#"You are extracting package outline + recommended land-pattern data for "{mpn}".
CONTEXT:
- MPN: {mpn}
- Manufacturer: {manuf}
- Package hint (from upstream chip-fetcher): {pkg_hint}
DATASHEET PDF (use the Read tool):
{ds_path}
SYMBOL PIN MAP (canonical from the prior symbol-pass — DO NOT re-derive these):
{pin_map}
THIS CALL IS ONLY ABOUT THE PACKAGE OUTLINE AND RECOMMENDED LAND PATTERN.
Ignore pin functional descriptions; you already have the pin name → number map.
Mechanical drawings + recommended-solder-pattern figures are usually near the back
of the datasheet (e.g. §12, §13, §"Mechanical data", §"Package information").
SCHEMA:
{{
"package_name": "string (e.g. 'VSSOP-10', 'VQFN-32', 'OLGA-16'). Drop dimensions/marking-codes from this — keep the canonical JEDEC/IPC name when one exists.",
"is_standard_jedec": true,
"package_name_source_page": 0,
"padCount": 0,
"bodyDimensions": {{ "x": 0.0, "y": 0.0, "thickness_max": 0.0,
"source_page": 0, "figure_label": "string" }},
"leadDimensions": {{ "pitch_x": 0.0, "pitch_y": 0.0,
"pad_w": 0.0, "pad_h": 0.0,
"thermal_pad": null,
"source_page": 0, "figure_label": "string" }},
"mechanical": {{
"jedec_dims": {{
"A": {{ "value": 0.0, "min": null, "max": null, "label": "Total package height", "source_page": 0, "figure_label": "string" }},
"A1": {{ "value": 0.0, "label": "Standoff height (body bottom to PCB)", "source_page": 0, "figure_label": "string" }},
"A2": {{ "value": 0.0, "label": "Body height (without leads)", "source_page": 0, "figure_label": "string" }},
"D": {{ "value": 0.0, "label": "Body length", "source_page": 0, "figure_label": "string" }},
"E": {{ "value": 0.0, "label": "Body width (across leads/pads)", "source_page": 0, "figure_label": "string" }},
"E1": {{ "value": 0.0, "label": "Body width (molded body only, excluding leads)", "source_page": 0, "figure_label": "string" }},
"b": {{ "value": 0.0, "label": "Lead width", "source_page": 0, "figure_label": "string" }},
"e": {{ "value": 0.0, "label": "Lead pitch", "source_page": 0, "figure_label": "string" }},
"L": {{ "value": 0.0, "label": "Lead foot length (toe to heel)", "source_page": 0, "figure_label": "string" }},
"L1": {{ "value": 0.0, "label": "Lead length from body edge", "source_page": 0, "figure_label": "string" }}
}},
"lead_shape": "gull_wing | j_lead | flat | ball | straight_pin | castellated | no_lead",
"pin1_indicator": "chamfer | dot | bar | notch | none",
"pin1_indicator_page": 0,
"exposed_pad_paste_coverage_pct": null,
"keepout_distance_mm": null,
"courtyard_source_page": null,
"courtyard_figure_label": null
}},
"padDescriptions": {{ "1": "VDD — 1-line pad description, may include footprint-specific notes (thermal pad, drill, plating)" }},
"symbolPinMap": {{ "1": "VDD" }},
"review_required": [
{{ "reason": "non_standard_package_no_kicad_baseline", "package": "..." }}
]
}}
IMPORTANT — THE `mechanical` SECTION:
- Read the mechanical drawing page(s) carefully. Look for the JEDEC dimension
callout letters (A, A1, A2, D, E, E1, b, e, L, L1). Most datasheets label
them in a table adjacent to the mechanical drawing.
- Not every dimension applies to every package. Include only what's present.
QFN has A/A1/D/E/b/e but no L (flat pads). SOIC has A/A1/D/E/E1/b/e/L.
BGA has A/A1/D/E/e + ball diameter (report as b). DIP has A/A1/D/E/b/e/L.
- Use nominal values in mm. When only min/max are given, set value to the midpoint.
- lead_shape: look at the mechanical drawing's side view. Gull-wing leads
bend outward from the body and down. J-leads curl under. Flat pads sit
under the body (QFN/LGA). Balls are solder spheres (BGA).
- pin1_indicator: look at the TOP view for a chamfer, dot, bar, or notch
marking pin 1. Report the type and the page.
- exposed_pad_paste_coverage_pct: some datasheets specify solder paste
coverage % for the center EP (e.g. "apply 50% paste coverage"). Include
if stated.
RULES:
- Every dimension entry MUST include source_page and figure_label (e.g. "Figure 12-1. Mechanical Drawing", "Figure 13. Recommended Land Pattern").
- If the package is a standard JEDEC/IPC package (SOIC, SOT, QFN, LQFP, BGA, etc.) and you can identify it confidently, set is_standard_jedec: true.
- If the package is non-standard (e.g. a custom optical LGA, a module with castellated edges), set is_standard_jedec: false and fill bodyDimensions + leadDimensions from the mechanical drawing precisely.
- padDescriptions and symbolPinMap MUST reuse the pin numbers + names from the SYMBOL PIN MAP above. padCount MUST equal the length of that map.
- Dimensions in millimeters. If the datasheet lists nominal/min/max, use the nominal.
- When data isn't in the datasheet, omit the field and surface in review_required — DO NOT hallucinate dimensions.
Return ONLY the JSON object. No prose before or after.
"#,
)
}
fn call_claude(prompt: &str, model: &str, tag: &str, add_dir: &std::path::Path) -> Result<String> {
// Shell `claude -p --output-format json --model <model> --add-dir <chip-dir>`.
// Feed the prompt on stdin. The --add-dir flag whitelists the chip directory so
// Claude's Read tool can read the PDF without permission prompts.
note(format!(" → claude -p ({tag}, {} chars prompt, add-dir={})", prompt.len(), add_dir.display()));
let mut child = Command::new("claude")
.arg("-p")
.arg("--output-format").arg("json")
.arg("--model").arg(model)
.arg("--add-dir").arg(add_dir)
.arg("--max-budget-usd").arg("5.00")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("spawning `claude -p` — is the claude CLI on PATH?")?;
{
let stdin = child.stdin.as_mut().ok_or_else(|| anyhow!("claude stdin"))?;
stdin.write_all(prompt.as_bytes())?;
}
let output = child.wait_with_output().context("waiting for claude")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("claude CLI exited {}: {}", output.status, stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
// The --output-format json envelope wraps the model's text response in a JSON object.
if let Ok(envelope) = serde_json::from_str::<Value>(&stdout) {
let is_error = envelope.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false);
let result_text = envelope.get("result").and_then(|v| v.as_str()).unwrap_or("");
if is_error {
return Err(anyhow!(
"claude CLI returned an error envelope: {}",
result_text
));
}
if !result_text.is_empty() {
note(format!(
" ← claude finished ({} duration_ms, ${} cost)",
envelope.get("duration_ms").map(|v| v.to_string()).unwrap_or_default(),
envelope.get("total_cost_usd").map(|v| v.to_string()).unwrap_or_default(),
));
return Ok(result_text.to_string());
}
if let Some(s) = envelope.get("text").and_then(|v| v.as_str()) {
return Ok(s.to_string());
}
}
Ok(stdout)
}
fn extract_json(raw: &str) -> Result<Value> {
// Strip code fences if present, then locate the outermost {...} block.
let s = raw.trim();
let s = s.strip_prefix("```json").unwrap_or(s);
let s = s.strip_prefix("```").unwrap_or(s);
let s = s.strip_suffix("```").unwrap_or(s).trim();
let start = match s.find('{') {
Some(i) => i,
None => {
// Haiku sometimes returns a prose-only refusal ("Unable to proceed
// — this PDF is the Adafruit user guide, not the NXP datasheet")
// without a JSON wrapper. Detect refusal language and synthesize
// an envelope so the upstream pipeline still gets a structured
// needs_better_pdf result with try_alternate_pdf hints.
let lower = raw.to_lowercase();
let refusal_markers = [
"unable to proceed",
"user guide",
"shield",
"not the actual",
"not the datasheet",
"wrong document",
"different chip",
"different variant",
"module datasheet is required",
"this guide",
"manufacturer datasheet is required",
// Per-package-pinout-supplement refusal (Lattice iCE40 case
// — main datasheet describes signal classes only and defers
// SG48/SG78/UG121-pin assignments to a separate Pinout
// Files supplement).
"external pinout file",
"supplement document",
"pinout files",
"package-specific pinout",
"package-diagram",
"pinout supplement",
"deferred to the external",
"pinout file is referenced",
];
if refusal_markers.iter().any(|m| lower.contains(m)) {
return Ok(serde_json::json!({
"pins": [],
"review_required": [{
"reason": "module_external_pinout_missing",
"details": raw.lines().take(10).collect::<Vec<_>>().join(" "),
}]
}));
}
return Err(anyhow!("no JSON object in claude response"));
}
};
let mut depth = 0i32;
let mut end = None;
let bytes = s.as_bytes();
let mut in_str = false;
let mut esc = false;
for (i, &b) in bytes.iter().enumerate().skip(start) {
if esc {
esc = false;
continue;
}
match b {
b'\\' if in_str => esc = true,
b'"' => in_str = !in_str,
b'{' if !in_str => depth += 1,
b'}' if !in_str => {
depth -= 1;
if depth == 0 {
end = Some(i + 1);
break;
}
}
_ => {}
}
}
let end = end.ok_or_else(|| anyhow!("unbalanced JSON braces"))?;
let obj = &s[start..end];
serde_json::from_str::<Value>(obj).with_context(|| {
let snippet: String = obj.chars().take(400).collect();
format!("parsing JSON: {snippet}…")
})
}
fn validate_symbol(v: &Value) -> Result<()> {
let pins = v.get("pins").and_then(|x| x.as_array()).ok_or_else(|| anyhow!("missing pins[]"))?;
if pins.is_empty() {
// Empty pins[] + review_required explanation = Claude refused to hallucinate
// (e.g. wrong PDF for a module product). Treat this as a non-retry-able state;
// the caller surfaces it via review_required.
let has_explanation = v
.get("review_required")
.and_then(|r| r.as_array())
.map(|a| !a.is_empty())
.unwrap_or(false);
if has_explanation {
return Err(anyhow!(
"pins[] is empty but review_required is populated — extractor refused to hallucinate; consult review_required and try --pdf <path> to a more-specific datasheet"
));
}
return Err(anyhow!("pins[] is empty"));
}
let mut seen = std::collections::HashSet::new();
for (i, p) in pins.iter().enumerate() {
let num = p.get("number").and_then(|x| x.as_str())
.ok_or_else(|| anyhow!("pins[{i}].number missing"))?;
if !seen.insert(num.to_string()) {
return Err(anyhow!("duplicate pin number '{num}'"));
}
let name = p.get("name").and_then(|x| x.as_str())
.ok_or_else(|| anyhow!("pins[{i}].name missing"))?;
let kind = p.get("type").and_then(|x| x.as_str())
.ok_or_else(|| anyhow!("pins[{i}].type missing for pin {num}"))?;
if !KICAD_ERC_TYPES.contains(&kind) {
return Err(anyhow!(
"pins[{i}].type '{kind}' invalid for pin {num} ({name}); must be one of {KICAD_ERC_TYPES:?}"
));
}
let group = p.get("group").and_then(|x| x.as_str()).unwrap_or("");
if !group.is_empty() && !ADOM_GROUPS.contains(&group) {
return Err(anyhow!(
"pins[{i}].group '{group}' invalid for pin {num} ({name}); must be one of {ADOM_GROUPS:?}"
));
}
let page = p.get("source_page").and_then(|x| x.as_u64()).unwrap_or(0);
if page == 0 {
return Err(anyhow!(
"pins[{i}] '{name}' (pin {num}) missing source_page"
));
}
}
Ok(())
}
fn validate_footprint(v: &Value, sym: &SymbolPass) -> Result<()> {
let pad_count = v.get("padCount").and_then(|x| x.as_u64())
.ok_or_else(|| anyhow!("padCount missing"))?;
if (pad_count as usize) != sym.pins.len() {
// Soft-warn: modules can legitimately have a different padCount from the
// symbol's pin count (e.g. ESP32-WROOM module exposes 41 castellated pads
// while the internal SoC has more pins). Surface in review_required, do
// not error — the extraction is still useful.
warn(format!(
"padCount {} differs from symbol pin count {} — proceeding (likely a module symbol/module footprint mismatch; surfaced in review_required)",
pad_count, sym.pins.len()
));
}
let body = v.get("bodyDimensions").ok_or_else(|| anyhow!("bodyDimensions missing"))?;
if body.get("source_page").and_then(|x| x.as_u64()).unwrap_or(0) == 0 {
return Err(anyhow!("bodyDimensions.source_page missing"));
}
let lead = v.get("leadDimensions").ok_or_else(|| anyhow!("leadDimensions missing"))?;
if lead.get("source_page").and_then(|x| x.as_u64()).unwrap_or(0) == 0 {
return Err(anyhow!("leadDimensions.source_page missing"));
}
// padDescriptions keys: warn (don't error) if there's a key the symbol map doesn't
// know about — same module-vs-IC reason.
let valid_nums: std::collections::HashSet<&str> =
sym.pins.iter().map(|p| p.number.as_str()).collect();
if let Some(desc) = v.get("padDescriptions").and_then(|x| x.as_object()) {
for k in desc.keys() {
if !valid_nums.contains(k.as_str()) {
warn(format!(
"padDescriptions key '{k}' is not in the symbol pin map (module/SoC mismatch?)"
));
break; // one warning is enough
}
}
}
Ok(())
}