← Commit history

v2.0.14: Module 2.4.0: the panel gutter check now measures against the containing block

John Lauer ·51a16ffe5b ·1mo ago ·parent e54b9ef
10 files changed +260−129
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-parts-search"-version = "2.0.13"+version = "2.0.14" dependencies = [  "base64",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-parts-search"-version = "2.0.13"+version = "2.0.14" edition = "2021" description = "Parts Search Electronics component search — CLI, backend HTTP service, and Hydrogen webview app" license = "MIT"
VERSION+1−1
@@ -1 +1 @@-2.0.13+2.0.14
install.sh

  
package.json+2−41
@@ -1,6 +1,6 @@ {   "slug": "adom-parts-search",-  "version": "2.0.14",+  "version": "2.0.4",   "type": "app",   "description": "One search box across Mouser, DigiKey and JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time. Dock Bar ready: serve, health, settings, Say it.",   "dependencies": {@@ -38,44 +38,5 @@     "SKILL.md",     "BUILD-SKILL.md",     "CHANGELOG.md"-  ],-  "changelog": "Module 2.4.0: the panel gutter check now measures against the containing block",-  "discovery_triggers": [-    "parts search",-    "find part",-    "source component",-    "source part",-    "compare distributors",-    "compare vendors",-    "Mouser or DigiKey",-    "BOM sourcing",-    "price this part",-    "where can I get",-    "check stock",-    "parts availability",-    "fastest delivery",-    "cheapest part",-    "component search",-    "part lookup",-    "price breaks"-  ],-  "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",-  "sample_prompts": [-    {-      "label": "source an STM32F103RBT6",-      "prompt": "source an STM32F103RBT6"-    },-    {-      "label": "where can I get an ESP32-S3-WROOM cheape",-      "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"-    },-    {-      "label": "compare Mouser, DigiKey and JLCPCB for a",-      "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"-    },-    {-      "label": "price this BOM across distributors",-      "prompt": "price this BOM across distributors"-    }   ]-}\ No newline at end of file+}
page.json+9−50
@@ -4,7 +4,7 @@   "slug": "adom-parts-search",   "title": "Parts Search",   "brief": "One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time.",-  "version": "2.0.14",+  "version": "2.0.4",   "install": {     "binary_name": "adom-parts-search",     "install_dir": "",@@ -33,25 +33,13 @@   },   "hero": {     "type": "image",-    "path": "screenshots/hero.png"+    "path": "docs/hero.png"   },   "sample_prompts": [-    {-      "label": "source an STM32F103RBT6",-      "prompt": "source an STM32F103RBT6"-    },-    {-      "label": "where can I get an ESP32-S3-WROOM cheape",-      "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"-    },-    {-      "label": "compare Mouser, DigiKey and JLCPCB for a",-      "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"-    },-    {-      "label": "price this BOM across distributors",-      "prompt": "price this BOM across distributors"-    }+    "source an STM32F103RBT6",+    "where can I get an ESP32-S3-WROOM cheapest and fastest",+    "compare Mouser, DigiKey and JLCPCB for an LM555",+    "price this BOM across distributors"   ],   "discovery_triggers": [     "parts search",@@ -66,41 +54,12 @@     "where can I get",     "check stock",     "parts availability",-    "fastest delivery",-    "cheapest part",-    "component search",-    "part lookup",-    "price breaks"+    "fastest delivery"   ],-  "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",+  "discovery_pitch": "Search Mouser + DigiKey + JLCPCB in parallel from one interface, with product photos and a Mouser-preferred best-source pick.",   "metadata": {     "platform": "linux"   },   "category": "tools",-  "org": "adom",-  "description": "One search box across Mouser, DigiKey and JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time. Dock Bar ready: serve, health, settings, Say it.",-  "dependencies": {-    "adom/adom-mouser": "*",-    "adom/adom-digikey": "*",-    "adom/adom-jlcpcb": "*"-  },-  "needs_sudo": false,-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  },-  "files": [-    "bin/**",-    "skills/**",-    "docs/icon.svg",-    "dockbar.json",-    "install.sh",-    "uninstall.sh",-    "package.json",-    "README.md",-    "SKILL.md",-    "BUILD-SKILL.md",-    "CHANGELOG.md"-  ],-  "changelog": "Module 2.4.0: the panel gutter check now measures against the containing block"+  "org": "adom" }\ No newline at end of file
src/app/ui.html+3
@@ -173,6 +173,8 @@ main>:first-child{margin-top:0} .recommendation .ricon{width:40px;height:40px;flex:none;border-radius:12px;background:rgba(0,184,177,.15);color:var(--accent);display:flex;align-items:center;justify-content:center} .recommendation .ricon svg{width:22px;height:22px} .recommendation.empty .ricon{background:rgba(210,153,34,.15)}+.recommendation .subst{margin-top:6px;padding:8px 10px;border-radius:8px;background:rgba(210,153,34,.14);border:1px solid rgba(210,153,34,.5);color:#f0c674;font:500 13px/1.45 var(--brand-body)}+.recommendation .exact{margin-top:4px;color:var(--accent);font:600 12px var(--brand-heading)} .recommendation .label{font:600 12px var(--brand-heading);letter-spacing:.02em;color:var(--accent)} .recommendation .main{font:600 18px/1.3 var(--brand-heading);color:var(--text);margin-top:2px} .recommendation .main code{font:600 14px var(--mono);color:var(--text);background:rgba(110,118,129,.2);padding:1px 6px;border-radius:4px}@@ -825,6 +827,7 @@ function renderRecommendation(rec,emptyState,policy){   if(rec){const vc=String(rec.winner).toLowerCase();     el.innerHTML='<div class="recommendation"><div class="ricon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></div><div class="rbody"><div class="label">Best source</div>'       +'<div class="main">Best source: <span style="color:var(--'+vc+')">'+escape(rec.winner)+'</span>, <code>'+escape(rec.mpn)+'</code>'+(rec.unit_price?' at '+escape(rec.unit_price)+' per unit':'')+', '+fmtStock(rec.stock)+' in stock</div>'+      +(rec.substitution?'<div class="subst" role="alert"><b>Not the part you asked for.</b> '+escape(rec.substitution)+'</div>':(rec.exact_match?'<div class="exact">Exact match for <code>'+escape(rec.requested_mpn||rec.mpn)+'</code></div>':''))       +'<div class="reason">'+escape(rec.reason)+'</div><div class="prov">Policy: '+((policy||settings.policy)==='cheapest'?'cheapest in stock':'Mouser preferred')+' (change it in Settings). Eligible hits need stock and a normal lead time.</div></div>'       +'</div>';     $('#col-'+vc).classList.add('preferred');const b=$('#badge-'+vc);b.hidden=false;b.classList.add('win');b.textContent='Recommended';return;}
src/cli.rs+16−1
@@ -24,9 +24,24 @@ pub fn run_search(args: SearchArgs) -> Result<(), String> {         agg.digikey.hits.len(),         agg.jlcpcb.hits.len(),         agg.recommendation.as_ref()-            .map(|r| format!("{} {}", r.winner.display(), r.mpn))+            .map(|r| {+                // Exactness belongs IN the one-line summary: it is what an AI+                // reads first, and "recommendation: Mouser X" alone gives no way+                // to tell X from the part that was asked for.+                if r.substitution.is_some() {+                    format!("{} {} (SUBSTITUTION, NOT the requested {})", r.winner.display(), r.mpn,+                        r.requested_mpn.clone().unwrap_or_default())+                } else if r.exact_match {+                    format!("{} {} (exact match)", r.winner.display(), r.mpn)+                } else {+                    format!("{} {}", r.winner.display(), r.mpn)+                }+            })             .unwrap_or_else(|| "none".into()),     );+    if let Some(note) = agg.recommendation.as_ref().and_then(|r| r.substitution.as_ref()) {+        eprintln!("Hint: {}", note);+    }     if crate::stderr_is_tty() {         eprintln!("{}{}OK:{} {}", crate::GREEN, crate::BOLD, crate::RESET, ok_line);     } else {
src/vendors.rs+227−34
@@ -114,6 +114,19 @@ pub struct Recommendation {     pub mpn: String,     pub unit_price: Option<String>,     pub stock: u64,+    /// What the user actually asked for, when the query was an exact MPN.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub requested_mpn: Option<String>,+    /// True when `mpn` is exactly the part the user asked for. False on a+    /// family/keyword query, and false on a SUBSTITUTION.+    pub exact_match: bool,+    /// Set ONLY when the query was an exact MPN and the recommendation is a+    /// DIFFERENT orderable part. A packaging or grade variant (DSER vs DSET, a+    /// -Q100 automotive part) is a different part: different reel, different+    /// qualification, sometimes a different datasheet. It must never win+    /// silently (adom/adom-parts-search#8).+    #[serde(skip_serializing_if = "Option::is_none")]+    pub substitution: Option<String>, }  /// Keep hits that contain the primary query token (>=3 char alphanumeric@@ -635,7 +648,7 @@ pub fn search_all(         }     } -    agg.recommendation = if opts.policy == "cheapest" { pick_cheapest(&agg) } else { pick_winner(&agg) };+    agg.recommendation = if opts.policy == "cheapest" { pick_cheapest(&agg, &agg.query) } else { pick_winner(&agg, &agg.query) };     if agg.recommendation.is_none() {         agg.empty_state = Some(build_empty_state(&agg));     }@@ -719,51 +732,231 @@ fn price_value(p: &Option<String>) -> Option<f64> {     cleaned.parse::<f64>().ok().filter(|v| *v > 0.0) } +/// Normalise an MPN for exact comparison. Case and surrounding whitespace are+/// noise; PUNCTUATION IS NOT. "74LVC2G17GV,125" and "74LVC2G17GV-Q100H" differ+/// exactly where it matters, so nothing inside the string is stripped.+fn norm_mpn(s: &str) -> String {+    s.trim().to_uppercase()+}++/// Was this query an exact MPN? True when ANY hit from ANY vendor carries an+/// MPN identical to the query. A family or keyword query ("LM358",+/// "10k 0402") matches no hit exactly and keeps the old behaviour.+fn is_exact_query(agg: &AggregatedResults, query: &str) -> bool {+    let q = norm_mpn(query);+    !q.is_empty()+        && agg.mouser.hits.iter().chain(agg.digikey.hits.iter()).chain(agg.jlcpcb.hits.iter())+            .any(|h| norm_mpn(&h.mpn) == q)+}++fn finish(h: &VendorHit, reason: String, query: &str, exact_query: bool) -> Recommendation {+    let exact = exact_query && norm_mpn(&h.mpn) == norm_mpn(query);+    Recommendation {+        winner: h.vendor,+        reason,+        mpn: h.mpn.clone(),+        unit_price: h.unit_price.clone(),+        stock: h.stock,+        requested_mpn: if exact_query { Some(query.trim().to_string()) } else { None },+        exact_match: exact,+        substitution: if exact_query && !exact {+            Some(format!(+                "You asked for {} but it is not available in stock from the vendors that answered. \+                 {} is a DIFFERENT orderable part (packaging or grade variant): check its own \+                 datasheet before substituting.",+                query.trim(), h.mpn+            ))+        } else { None },+    }+}+ /// The "cheapest" policy: lowest in-stock unit price across every vendor /// that answered, ties broken toward Mouser (shipping is still near zero).-fn pick_cheapest(agg: &AggregatedResults) -> Option<Recommendation> {+///+/// On an EXACT-MPN query the candidates are restricted to the exact part+/// whenever it is in stock anywhere: a cheaper variant is not a cheaper way to+/// buy the part that was asked for.+fn pick_cheapest(agg: &AggregatedResults, query: &str) -> Option<Recommendation> {     let long_lead = |lt: &str| lt.contains("280");+    let exact_query = is_exact_query(agg, query);+    let q = norm_mpn(query);+    let eligible = |h: &&VendorHit| h.stock > 0 && !long_lead(&h.lead_time) && price_value(&h.unit_price).is_some();+    let all: Vec<&VendorHit> = agg.mouser.hits.iter().chain(agg.digikey.hits.iter()).chain(agg.jlcpcb.hits.iter())+        .filter(eligible).collect();+    let exact: Vec<&VendorHit> = all.iter().copied().filter(|h| norm_mpn(&h.mpn) == q).collect();+    let pool = if exact_query && !exact.is_empty() { exact } else { all };     let mut best: Option<(f64, &VendorHit)> = None;-    for h in agg.mouser.hits.iter().chain(agg.digikey.hits.iter()).chain(agg.jlcpcb.hits.iter()) {-        if h.stock == 0 || long_lead(&h.lead_time) { continue; }-        let Some(v) = price_value(&h.unit_price) else { continue };+    for h in pool {+        let v = price_value(&h.unit_price)?;         let better = match best { None => true, Some((bv, bh)) => v < bv || (v == bv && h.vendor == Vendor::Mouser && bh.vendor != Vendor::Mouser) };         if better { best = Some((v, h)); }     }     let (_, h) = best?;-    Some(Recommendation {-        winner: h.vendor,-        reason: format!("Lowest in-stock unit price across the vendors that answered ({} at {}). Policy: cheapest, set in Settings.", h.vendor.display(), h.unit_price.clone().unwrap_or_default()),-        mpn: h.mpn.clone(),-        unit_price: h.unit_price.clone(),-        stock: h.stock,-    })+    let reason = format!("Lowest in-stock unit price across the vendors that answered ({} at {}). Policy: cheapest, set in Settings.", h.vendor.display(), h.unit_price.clone().unwrap_or_default());+    Some(finish(h, reason, query, exact_query)) } -fn pick_winner(agg: &AggregatedResults) -> Option<Recommendation> {+/// Mouser-preferred policy, EXACT PART FIRST.+///+/// The bug this ordering fixes (adom/adom-parts-search#8): the picker took the+/// first in-stock hit in vendor order with no idea what had been asked for, so+/// an exact search for TPS389001DSER recommended TPS389001DSET because Mouser+/// happened to list the variant first. For a parts tool, confidently+/// recommending a different part is about the worst available failure.+///+/// Order on an exact-MPN query:+///   1. the exact part, in stock at Mouser+///   2. the exact part, in stock at DigiKey  (the right part from the second+///      vendor beats the wrong part from the first)+///   3. a variant, FLAGGED as a substitution, never silent+/// A family/keyword query has no exact hit and keeps the vendor-order policy.+fn pick_winner(agg: &AggregatedResults, query: &str) -> Option<Recommendation> {     let long_lead = |lt: &str| lt.contains("280");-    let first_in_stock = |hits: &[VendorHit]| {-        hits.iter().find(|h| h.stock > 0 && !long_lead(&h.lead_time)).cloned()-    };-    let m = first_in_stock(&agg.mouser.hits);-    let d = first_in_stock(&agg.digikey.hits);-    if let Some(m) = &m {-        return Some(Recommendation {-            winner: Vendor::Mouser,-            reason: "Mouser has it in stock and is the preferred vendor: fastest, lowest-cost fulfillment.".into(),-            mpn: m.mpn.clone(),-            unit_price: m.unit_price.clone(),-            stock: m.stock,-        });+    let exact_query = is_exact_query(agg, query);+    let q = norm_mpn(query);+    let in_stock = |h: &&VendorHit| h.stock > 0 && !long_lead(&h.lead_time);++    if exact_query {+        if let Some(h) = agg.mouser.hits.iter().filter(in_stock).find(|h| norm_mpn(&h.mpn) == q) {+            return Some(finish(h, "Mouser has the exact part in stock and is the preferred vendor: fastest, lowest-cost fulfillment.".into(), query, true));+        }+        if let Some(h) = agg.digikey.hits.iter().filter(in_stock).find(|h| norm_mpn(&h.mpn) == q) {+            return Some(finish(h, "DigiKey has the exact part in stock; Mouser does not (out of stock, long-lead, or only a variant).".into(), query, true));+        }     }-    if let Some(d) = &d {-        return Some(Recommendation {-            winner: Vendor::DigiKey,-            reason: "Mouser is out of stock or long-lead; DigiKey has immediate stock.".into(),-            mpn: d.mpn.clone(),-            unit_price: d.unit_price.clone(),-            stock: d.stock,-        });+    if let Some(h) = agg.mouser.hits.iter().find(in_stock) {+        let reason = if exact_query {+            "The exact part is not in stock at Mouser or DigiKey. This is the closest in-stock Mouser part, a SUBSTITUTION.".to_string()+        } else {+            "Mouser has it in stock and is the preferred vendor: fastest, lowest-cost fulfillment.".to_string()+        };+        return Some(finish(h, reason, query, exact_query));+    }+    if let Some(h) = agg.digikey.hits.iter().find(in_stock) {+        let reason = if exact_query {+            "The exact part is not in stock at Mouser or DigiKey. This is the closest in-stock DigiKey part, a SUBSTITUTION.".to_string()+        } else {+            "Mouser is out of stock or long-lead; DigiKey has immediate stock.".to_string()+        };+        return Some(finish(h, reason, query, exact_query));     }     None }++#[cfg(test)]+mod recommendation_tests {+    //! Regression tests for adom/adom-parts-search#8: an exact-MPN search must+    //! never silently recommend a different orderable part. Fixtures mirror the+    //! two live cases John captured (C38: TPS389001DSER, C49: 74LVC2G17GV,125).+    use super::*;++    fn hit(vendor: Vendor, mpn: &str, stock: u64, price: &str) -> VendorHit {+        VendorHit {+            vendor, vendor_pn: format!("pn-{}", mpn), mpn: mpn.into(),+            manufacturer: "TI".into(), description: "test".into(), stock,+            unit_price: Some(price.into()), price_100: None, lead_time: "0".into(),+            image_url: None, datasheet_url: None, product_url: None, flags: vec![],+            cad_3d_url: None, cad_3d_format: None, cad_3d_source: None,+        }+    }+    fn agg(query: &str, mouser: Vec<VendorHit>, digikey: Vec<VendorHit>) -> AggregatedResults {+        let mut a = AggregatedResults::default();+        a.query = query.into();+        a.mouser.ok = true; a.mouser.hits = mouser;+        a.digikey.ok = true; a.digikey.hits = digikey;+        a+    }++    /// THE REPORTED CASE, in the reported order: the variant is listed FIRST+    /// and has MORE stock; the exact part is second. The exact part must win.+    #[test]+    fn exact_mpn_beats_a_variant_listed_first() {+        let a = agg("TPS389001DSER", vec![+            hit(Vendor::Mouser, "TPS389001DSET", 5600, "$2.22"),+            hit(Vendor::Mouser, "TPS389001DSER", 2190, "$1.92"),+        ], vec![]);+        let r = pick_winner(&a, &a.query).unwrap();+        assert_eq!(r.mpn, "TPS389001DSER");+        assert!(r.exact_match);+        assert!(r.substitution.is_none());+        assert_eq!(r.requested_mpn.as_deref(), Some("TPS389001DSER"));+    }++    /// Second live case: punctuation is part of the MPN. ",125" vs "-Q100H".+    #[test]+    fn exact_mpn_with_punctuation_beats_the_automotive_variant() {+        let a = agg("74LVC2G17GV,125", vec![+            hit(Vendor::Mouser, "74LVC2G17GV-Q100H", 4466, "$0.28"),+            hit(Vendor::Mouser, "74LVC2G17GV,125", 2080, "$0.19"),+        ], vec![]);+        let r = pick_winner(&a, &a.query).unwrap();+        assert_eq!(r.mpn, "74LVC2G17GV,125");+        assert!(r.exact_match);+    }++    /// The right part from the second vendor beats the wrong part from the first.+    #[test]+    fn exact_at_digikey_beats_a_variant_at_mouser() {+        let a = agg("TPS389001DSER",+            vec![hit(Vendor::Mouser, "TPS389001DSET", 5600, "$2.22")],+            vec![hit(Vendor::DigiKey, "TPS389001DSER", 300, "$2.05")]);+        let r = pick_winner(&a, &a.query).unwrap();+        assert_eq!(r.winner, Vendor::DigiKey);+        assert_eq!(r.mpn, "TPS389001DSER");+        assert!(r.exact_match);+    }++    /// Exact part exists but is OUT OF STOCK everywhere: a variant may be+    /// offered, but only as an explicit, flagged substitution.+    #[test]+    fn a_variant_is_only_ever_an_explicit_substitution() {+        let a = agg("74LVC2G07GV,125", vec![+            hit(Vendor::Mouser, "74LVC2G07GV,125", 0, "$0.19"),+            hit(Vendor::Mouser, "74LVC2G07GV-Q100H", 900, "$0.27"),+        ], vec![]);+        let r = pick_winner(&a, &a.query).unwrap();+        assert_eq!(r.mpn, "74LVC2G07GV-Q100H");+        assert!(!r.exact_match);+        let note = r.substitution.expect("a substitution must say so");+        assert!(note.contains("74LVC2G07GV,125") && note.contains("DIFFERENT"));+        assert!(r.reason.contains("SUBSTITUTION"));+    }++    /// A family/keyword query matches no hit exactly and keeps the old+    /// vendor-order behaviour, unflagged.+    #[test]+    fn family_queries_are_unchanged() {+        let a = agg("LM358", vec![+            hit(Vendor::Mouser, "LM358DR", 9000, "$0.12"),+            hit(Vendor::Mouser, "LM358P", 100, "$0.30"),+        ], vec![]);+        let r = pick_winner(&a, &a.query).unwrap();+        assert_eq!(r.mpn, "LM358DR");+        assert!(!r.exact_match);+        assert!(r.substitution.is_none());+        assert!(r.requested_mpn.is_none());+    }++    /// The cheapest policy must not "save money" by buying a different part.+    #[test]+    fn cheapest_policy_stays_on_the_exact_part() {+        let a = agg("TPS389001DSER", vec![+            hit(Vendor::Mouser, "TPS389001DSET", 5600, "$1.10"),+            hit(Vendor::Mouser, "TPS389001DSER", 2190, "$1.92"),+        ], vec![hit(Vendor::DigiKey, "TPS389001DSER", 50, "$1.80")]);+        let r = pick_cheapest(&a, &a.query).unwrap();+        assert_eq!(r.mpn, "TPS389001DSER");+        assert_eq!(r.winner, Vendor::DigiKey);+        assert!(r.exact_match);+    }++    /// Case and stray whitespace are noise; they must not defeat exactness.+    #[test]+    fn exactness_ignores_case_and_whitespace() {+        let a = agg("  tps389001dser ", vec![+            hit(Vendor::Mouser, "TPS389001DSET", 5600, "$2.22"),+            hit(Vendor::Mouser, "TPS389001DSER", 2190, "$1.92"),+        ], vec![]);+        assert_eq!(pick_winner(&a, &a.query).unwrap().mpn, "TPS389001DSER");+    }+}
uninstall.sh