app
Parts Search
Public Made by Adomby adom
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.
← Commit history
v2.0.14: Module 2.4.0: the panel gutter check now measures against the containing block
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.shpackage.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"- } ]-}+}
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" }
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