@seeed-studio/solution-cli 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/knowledge.js CHANGED
@@ -125,7 +125,7 @@ export function loadClientConfig() {
125
125
 
126
126
  const SUPPORTED_LOCALES = new Set(['en', 'zh', 'ja']);
127
127
  const HUB_TYPES = new Set(['scenario', 'tech', 'reference_design']);
128
- const HUB_SECTIONS = new Set(['overview', 'links', 'description', 'architecture', 'products', 'deployment', 'playbook', 'integration']);
128
+ const HUB_SECTIONS = new Set(['overview', 'links', 'description', 'architecture', 'products', 'deployment', 'playbook', 'integration', 'cases']);
129
129
 
130
130
  function levenshteinDistance(s1, s2) {
131
131
  const m = s1.length;
@@ -262,6 +262,12 @@ export function computeSectionCapabilities(record, rowType, core = null) {
262
262
  section_status.integration = isCustomContract ? 'documented' : 'inferred';
263
263
  }
264
264
 
265
+ const hasCases = rowType === 'scenario' || rowType === 'tech' || Boolean(record?.cases?.length || record?.cases_count);
266
+ if (hasCases) {
267
+ available_sections.push('cases');
268
+ section_status.cases = 'documented';
269
+ }
270
+
265
271
  return { available_sections, section_status };
266
272
  }
267
273
 
@@ -645,6 +651,14 @@ function populateSectionData(row, section, record, rowType, loc, selected, conte
645
651
  guideText: guideDoc?.body || '',
646
652
  });
647
653
  row.integration = contract;
654
+ } else if (section === 'cases') {
655
+ row.cases = {
656
+ items: [],
657
+ availability: 'unavailable',
658
+ source: 'bundled_manifest',
659
+ content_locale: null,
660
+ fallback: false,
661
+ };
648
662
  } else if (section === 'overview') {
649
663
  row.links = { landing: row.landing_urls, wiki: record.wiki_url || null, repo: record.repo_url || null };
650
664
  row.description = { content_locale: summary.locale, fallback: summary.locale !== null && summary.locale !== loc, source_role: 'summary', summary: summary.value || '' };
@@ -671,7 +685,11 @@ export function getHubRecord(id, { type = null, locale = 'zh', section = null, s
671
685
  } else {
672
686
  requestedSections = ['overview'];
673
687
  }
674
- requestedSections = requestedSections.map((s) => s.trim().toLowerCase()).filter(Boolean);
688
+ requestedSections = requestedSections
689
+ .map((s) => s.trim().toLowerCase())
690
+ .map((s) => s === 'stories' ? 'cases' : s)
691
+ .filter(Boolean);
692
+ requestedSections = Array.from(new Set(requestedSections));
675
693
  if (requestedSections.length === 0) requestedSections = ['overview'];
676
694
 
677
695
  for (const sec of requestedSections) {
@@ -711,6 +729,339 @@ export function getHubRecord(id, { type = null, locale = 'zh', section = null, s
711
729
  return row;
712
730
  }
713
731
 
732
+ /**
733
+ * Asynchronously fetches success cases / customer stories for a scenario from Typesense.
734
+ *
735
+ * @param {string} scenarioId
736
+ * @param {Object} options
737
+ * @param {'zh' | 'en' | 'ja'} [options.locale='zh']
738
+ * @param {number} [options.limit=10]
739
+ * @returns {Promise<Object>}
740
+ */
741
+ export async function fetchCasesForScenario(scenarioId, { locale = 'zh', limit = 10 } = {}) {
742
+ const loc = normalizeLocale(locale);
743
+ const locKey = loc === 'zh' ? 'zh_hans' : loc;
744
+
745
+ if (process.env.SENSECRAFT_OFFLINE_ONLY) {
746
+ return {
747
+ items: [],
748
+ availability: 'unavailable',
749
+ source: 'bundled_manifest',
750
+ content_locale: null,
751
+ fallback: false,
752
+ };
753
+ }
754
+
755
+ const clientConfig = loadClientConfig();
756
+ const host = process.env.TYPESENSE_HOST || clientConfig?.typesense?.host || 'https://rel-search.seeedstudio.com';
757
+ const keyCases = process.env.TYPESENSE_SEARCH_KEY_CC_SUCCESS_CASES || process.env.TYPESENSE_SEARCH_KEY_SUCCESS_CASE || clientConfig?.typesense?.search_key_cc_success_cases;
758
+ const keySolution = process.env.TYPESENSE_SEARCH_KEY_SOLUTION || process.env.TYPESENSE_SEARCH_KEY || clientConfig?.typesense?.search_key_solution;
759
+
760
+ const items = [];
761
+ const seenSlugs = new Set();
762
+ const fetchErrors = [];
763
+
764
+ try {
765
+ const promises = [];
766
+
767
+ // 1. Fetch from cc_success_cases collection by solution_ids
768
+ if (keyCases) {
769
+ const casesFilter = `solution_ids:[=${scenarioId}] && published:true`;
770
+ const casesUrl = `${host}/collections/cc_success_cases/documents/search?q=*&filter_by=${encodeURIComponent(casesFilter)}&per_page=${limit}`;
771
+ promises.push(
772
+ fetchJson(casesUrl, { 'X-TYPESENSE-API-KEY': keyCases }, 2500)
773
+ .then((res) => ({ type: 'cc_cases', hits: res?.hits || [] }))
774
+ .catch((err) => {
775
+ fetchErrors.push(`cc_success_cases: ${err.message}`);
776
+ return { type: 'cc_cases', hits: [] };
777
+ })
778
+ );
779
+ }
780
+
781
+ // 2. Fetch from Solution collection to parse related_posts
782
+ if (keySolution) {
783
+ const solUrl = `${host}/collections/Solution/documents/search?q=*&filter_by=id:=${encodeURIComponent(scenarioId)}&per_page=1`;
784
+ promises.push(
785
+ fetchJson(solUrl, { 'X-TYPESENSE-API-KEY': keySolution }, 2500)
786
+ .then((res) => ({ type: 'solution_doc', hits: res?.hits || [] }))
787
+ .catch((err) => {
788
+ fetchErrors.push(`Solution related_posts: ${err.message}`);
789
+ return { type: 'solution_doc', hits: [] };
790
+ })
791
+ );
792
+ }
793
+
794
+ const results = await Promise.all(promises);
795
+
796
+ for (const res of results) {
797
+ if (res.type === 'cc_cases') {
798
+ for (const hit of res.hits) {
799
+ const doc = hit.document || {};
800
+ const slug = doc.slug_zh_hans || doc.slug_en || doc.id || doc.pid;
801
+ if (!slug || seenSlugs.has(slug)) continue;
802
+ seenSlugs.add(slug);
803
+
804
+ const titleText = onlineLocalizedText(doc, loc, {
805
+ en: ['title_en'], zh: ['title_zh_hans'], ja: ['title_ja'],
806
+ });
807
+ const excerptText = onlineLocalizedText(doc, loc, {
808
+ en: ['excerpt_en'], zh: ['excerpt_zh_hans'], ja: ['excerpt_ja'],
809
+ });
810
+
811
+ // URL safety check: verify absolute http(s) URL without speculative guessing
812
+ let url = null;
813
+ const candidateUrl = doc[`url_${locKey}`] || doc.url_en || doc.url_zh_hans;
814
+ if (candidateUrl && typeof candidateUrl === 'string' && /^https?:\/\//i.test(candidateUrl)) {
815
+ url = appendUtm(candidateUrl, {
816
+ source: 'sensecraft_agent',
817
+ medium: 'solution_consult',
818
+ campaign: scenarioId,
819
+ content: 'success_case',
820
+ });
821
+ }
822
+
823
+ items.push({
824
+ id: doc.id || doc.pid,
825
+ title: titleText.value || doc.title_zh_hans || doc.title_en || slug,
826
+ description: excerptText.value || doc.excerpt_zh_hans || doc.excerpt_en || '',
827
+ hardware: doc.hardware || [],
828
+ family_ids: doc.family_ids || [],
829
+ industry: doc.industry || [],
830
+ region: doc.region || [],
831
+ image: doc.cover_image || null,
832
+ case_slug: slug,
833
+ url,
834
+ content_locale: titleText.locale || loc,
835
+ fallback: Boolean(titleText.locale && titleText.locale !== loc),
836
+ });
837
+ }
838
+ } else if (res.type === 'solution_doc') {
839
+ const doc = res.hits?.[0]?.document || {};
840
+ try {
841
+ const pData = JSON.parse(doc[`page_data_${locKey}`] || doc.page_data_zh_hans || doc.page_data_en || '{}');
842
+ const posts = pData.related_posts?.list || [];
843
+ if (Array.isArray(posts)) {
844
+ for (const p of posts) {
845
+ const slug = p.case || String(p.id);
846
+ if (!slug || seenSlugs.has(slug)) continue;
847
+ seenSlugs.add(slug);
848
+
849
+ let url = null;
850
+ if (p.url && typeof p.url === 'string' && /^https?:\/\//i.test(p.url)) {
851
+ url = appendUtm(p.url, {
852
+ source: 'sensecraft_agent',
853
+ medium: 'solution_consult',
854
+ campaign: scenarioId,
855
+ content: 'success_case',
856
+ });
857
+ }
858
+
859
+ items.push({
860
+ id: String(p.id || slug),
861
+ title: p.title || slug,
862
+ description: p.description || '',
863
+ hardware: [],
864
+ family_ids: [],
865
+ industry: [],
866
+ region: [],
867
+ image: p.image || null,
868
+ case_slug: slug,
869
+ url,
870
+ content_locale: loc,
871
+ fallback: false,
872
+ });
873
+ }
874
+ }
875
+ } catch (_) {}
876
+ }
877
+ }
878
+
879
+ const hasErrors = fetchErrors.length > 0;
880
+ const isUnavailable = items.length === 0 && hasErrors;
881
+
882
+ return {
883
+ items,
884
+ availability: items.length > 0 ? 'available' : (isUnavailable ? 'unavailable' : 'empty'),
885
+ source: items.length > 0 ? 'typesense' : 'bundled_manifest',
886
+ content_locale: items.length > 0 ? loc : null,
887
+ fallback: false,
888
+ warning: isUnavailable ? `Failed to query online success cases: ${fetchErrors.join('; ')}` : null,
889
+ };
890
+ } catch (err) {
891
+ return {
892
+ items: [],
893
+ availability: 'unavailable',
894
+ source: 'bundled_manifest',
895
+ content_locale: null,
896
+ fallback: false,
897
+ warning: `Failed to fetch online cases for ${scenarioId}: ${err.message}`,
898
+ };
899
+ }
900
+ }
901
+
902
+ /**
903
+ * Asynchronous orchestrator for getHubRecord that enriches dynamic online sections (e.g. cases).
904
+ *
905
+ * @param {string} id
906
+ * @param {Object} options
907
+ * @returns {Promise<Object>}
908
+ */
909
+ export async function getHubRecordAsync(id, options = {}) {
910
+ const row = getHubRecord(id, options);
911
+ if (!row) return null;
912
+
913
+ const loc = normalizeLocale(options.locale || 'zh');
914
+ let requestedSections = [];
915
+ if (options.sections) {
916
+ if (Array.isArray(options.sections)) requestedSections = options.sections.flatMap((s) => String(s).split(','));
917
+ else if (typeof options.sections === 'string') requestedSections = options.sections.split(',');
918
+ } else if (options.section) {
919
+ requestedSections = String(options.section).split(',');
920
+ } else {
921
+ requestedSections = ['overview'];
922
+ }
923
+ requestedSections = requestedSections.map((s) => s.trim().toLowerCase()).map((s) => s === 'stories' ? 'cases' : s);
924
+
925
+ if (requestedSections.includes('cases')) {
926
+ const casesRes = await fetchCasesForScenario(id, { locale: loc, limit: 10 });
927
+ row.cases = casesRes;
928
+ if (casesRes.warning) {
929
+ row.warnings = row.warnings || [];
930
+ row.warnings.push(casesRes.warning);
931
+ }
932
+ if (casesRes.items && casesRes.items.length > 0) {
933
+ row.section_status = row.section_status || {};
934
+ row.section_status.cases = 'documented';
935
+ if (!row.available_sections?.includes('cases')) {
936
+ row.available_sections = [...(row.available_sections || []), 'cases'];
937
+ }
938
+ }
939
+ }
940
+
941
+ return row;
942
+ }
943
+
944
+ /**
945
+ * Asynchronously retrieves a single success case / customer story in detail, including full body_md.
946
+ *
947
+ * @param {string} caseIdOrSlug - Case ID, numeric PID, or localized slug
948
+ * @param {Object} [options]
949
+ * @param {'zh' | 'en' | 'ja'} [options.locale='zh']
950
+ * @returns {Promise<Object>} Catalog DTO with case record
951
+ */
952
+ export async function getCaseRecord(caseIdOrSlug, { locale = 'zh' } = {}) {
953
+ const loc = normalizeLocale(locale);
954
+ const locKey = loc === 'zh' ? 'zh_hans' : loc;
955
+
956
+ if (!caseIdOrSlug || typeof caseIdOrSlug !== 'string' || !caseIdOrSlug.trim()) {
957
+ throw new Error('Case ID or slug must be a non-empty string.');
958
+ }
959
+ const cleanTarget = caseIdOrSlug.trim();
960
+
961
+ if (process.env.SENSECRAFT_OFFLINE_ONLY) {
962
+ return createCatalogDto({
963
+ source: 'bundled_manifest',
964
+ freshness: 'UNKNOWN',
965
+ records: [],
966
+ warnings: [`Offline mode enabled (SENSECRAFT_OFFLINE_ONLY=1). Success case details require online query.`],
967
+ });
968
+ }
969
+
970
+ const clientConfig = loadClientConfig();
971
+ const host = process.env.TYPESENSE_HOST || clientConfig?.typesense?.host || 'https://rel-search.seeedstudio.com';
972
+ const keyCases = process.env.TYPESENSE_SEARCH_KEY_CC_SUCCESS_CASES || process.env.TYPESENSE_SEARCH_KEY_SUCCESS_CASE || clientConfig?.typesense?.search_key_cc_success_cases;
973
+
974
+ if (!keyCases) {
975
+ return createCatalogDto({
976
+ source: 'bundled_manifest',
977
+ freshness: 'UNKNOWN',
978
+ records: [],
979
+ warnings: ['Missing Typesense API key for success cases.'],
980
+ });
981
+ }
982
+
983
+ try {
984
+ const safeTarget = cleanTarget.replace(/["'\[\]\(\)\{\}\\]/g, '');
985
+ const isNum = /^\d+$/.test(safeTarget);
986
+ const filterParts = [
987
+ `id:=[${safeTarget}]`,
988
+ `slug_zh_hans:=[${safeTarget}]`,
989
+ `slug_en:=[${safeTarget}]`,
990
+ `slug_ja:=[${safeTarget}]`,
991
+ ];
992
+ if (isNum) {
993
+ filterParts.push(`pid:=[${safeTarget}]`);
994
+ }
995
+ const filterBy = `(${filterParts.join(' || ')}) && published:true`;
996
+ const searchUrl = `${host}/collections/cc_success_cases/documents/search?q=*&filter_by=${encodeURIComponent(filterBy)}&per_page=1`;
997
+
998
+ const data = await fetchJson(searchUrl, { 'X-TYPESENSE-API-KEY': keyCases }, 3500);
999
+ const hit = data?.hits?.[0]?.document;
1000
+
1001
+ if (!hit) {
1002
+ return createCatalogDto({
1003
+ source: 'typesense',
1004
+ freshness: 'FRESH',
1005
+ records: [],
1006
+ warnings: [`Success case "${cleanTarget}" not found.`],
1007
+ });
1008
+ }
1009
+
1010
+ const titleText = onlineLocalizedText(hit, loc, {
1011
+ en: ['title_en'], zh: ['title_zh_hans'], ja: ['title_ja'],
1012
+ });
1013
+ const excerptText = onlineLocalizedText(hit, loc, {
1014
+ en: ['excerpt_en'], zh: ['excerpt_zh_hans'], ja: ['excerpt_ja'],
1015
+ });
1016
+ const bodyText = onlineLocalizedText(hit, loc, {
1017
+ en: ['body_md_en', 'body_md'], zh: ['body_md_zh_hans', 'body_md'], ja: ['body_md_ja', 'body_md'],
1018
+ });
1019
+
1020
+ let url = null;
1021
+ const candidateUrl = hit[`url_${locKey}`] || hit.url_en || hit.url_zh_hans;
1022
+ if (candidateUrl && typeof candidateUrl === 'string' && /^https?:\/\//i.test(candidateUrl)) {
1023
+ url = appendUtm(candidateUrl, {
1024
+ source: 'sensecraft_agent',
1025
+ medium: 'case_detail',
1026
+ campaign: hit.id || cleanTarget,
1027
+ content: 'success_case_full',
1028
+ });
1029
+ }
1030
+
1031
+ const record = {
1032
+ id: hit.id || String(hit.pid),
1033
+ pid: hit.pid || null,
1034
+ title: titleText.value || hit.title_zh_hans || hit.title_en || cleanTarget,
1035
+ slug: hit.slug_zh_hans || hit.slug_en || cleanTarget,
1036
+ excerpt: excerptText.value || hit.excerpt_zh_hans || hit.excerpt_en || '',
1037
+ body_md: bodyText.value || hit.body_md_zh_hans || hit.body_md_en || hit.body_md || '',
1038
+ hardware: hit.hardware || [],
1039
+ family_ids: hit.family_ids || [],
1040
+ solution_ids: hit.solution_ids || [],
1041
+ industry: hit.industry || [],
1042
+ region: hit.region || [],
1043
+ cover_image: hit.cover_image || null,
1044
+ url,
1045
+ published_at: hit.published_at || null,
1046
+ content_locale: bodyText.locale || titleText.locale || loc,
1047
+ fallback: Boolean((bodyText.locale || titleText.locale) && (bodyText.locale || titleText.locale) !== loc),
1048
+ };
1049
+
1050
+ return createCatalogDto({
1051
+ source: 'typesense',
1052
+ freshness: 'FRESH',
1053
+ records: [record],
1054
+ });
1055
+ } catch (err) {
1056
+ return createCatalogDto({
1057
+ source: 'typesense',
1058
+ freshness: 'UNKNOWN',
1059
+ records: [],
1060
+ warnings: [`Failed to fetch case "${cleanTarget}": ${err.message}`],
1061
+ });
1062
+ }
1063
+ }
1064
+
714
1065
  /**
715
1066
  * Detects if a search query matches known hardware product families, models, or SKUs.
716
1067
  *
@@ -820,6 +1171,17 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
820
1171
  const rawLanding = doc.external_url || doc.landing_url || localizedLanding(bundledScenario, locale) || null;
821
1172
  const archPreview = extractArchitecturePreview(doc, bundledScenario, locale, 'scenario');
822
1173
 
1174
+ let casesCount = 0;
1175
+ let casesPreview = [];
1176
+ try {
1177
+ const pData = JSON.parse(doc[`page_data_${locKey}`] || doc.page_data_zh_hans || doc.page_data_en || '{}');
1178
+ const posts = pData.related_posts?.list || [];
1179
+ if (Array.isArray(posts) && posts.length > 0) {
1180
+ casesCount = posts.length;
1181
+ casesPreview = posts.slice(0, 2).map((p) => p.title).filter(Boolean);
1182
+ }
1183
+ } catch (_) {}
1184
+
823
1185
  onlineScenarios.push({
824
1186
  type: resolvedType,
825
1187
  id: docId,
@@ -845,6 +1207,8 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
845
1207
  }),
846
1208
  repo_url: doc.repo_url || '',
847
1209
  diagnostic_playbook: pb,
1210
+ cases_count: casesCount,
1211
+ ...(casesPreview.length > 0 ? { cases_preview: casesPreview } : {}),
848
1212
  source: 'typesense',
849
1213
  });
850
1214
  }
@@ -1096,15 +1460,273 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
1096
1460
  });
1097
1461
  }
1098
1462
 
1463
+ /**
1464
+ * Supported product sections for progressive disclosure.
1465
+ */
1466
+ export const PRODUCT_SECTIONS = new Set(['overview', 'power', 'interfaces', 'specs', 'all']);
1467
+
1468
+ /**
1469
+ * Normalizes an individual SKU specification with calculated package profile and power readiness.
1470
+ *
1471
+ * @param {Object} s - Raw SKU object
1472
+ * @param {Object} family - Parent product family object
1473
+ * @returns {Object} Normalized SKU record
1474
+ */
1475
+ export function normalizeSkuRecord(s, family = {}) {
1476
+ const specs = s.specs || {};
1477
+ const pwr = specs.power || {};
1478
+ const pkg = specs.package_contents || [];
1479
+ const pkgText = pkg.join(' ').toLowerCase();
1480
+
1481
+ // Require positive evidence of an included operational battery (not just battery_powered capability or RTC battery)
1482
+ const hasIncludedBattery = pkg.some((item) => {
1483
+ if (!/\bbattery\b/i.test(item)) return false;
1484
+ if (/(?:not\s+included|sold\s+separately|cr1220|rtc|coin)/i.test(item)) return false;
1485
+ if (/(?:without|w\/o|no|exclude[ds]?)\s+[^()]*\bbattery/i.test(item)) return false;
1486
+ return true;
1487
+ });
1488
+
1489
+ let adapterIncluded = pwr.adapter_included;
1490
+ if (adapterIncluded === undefined || adapterIncluded === null) {
1491
+ if (s.attrs?.power_adapter === 'not_included' || /w\/?o\s+power/i.test(s.name)) {
1492
+ adapterIncluded = false;
1493
+ } else if (s.attrs?.power_adapter === 'included' || /power\s+adapter/i.test(pkgText)) {
1494
+ adapterIncluded = true;
1495
+ } else {
1496
+ adapterIncluded = hasIncludedBattery ? false : null;
1497
+ }
1498
+ }
1499
+
1500
+ const cordExplicitlyExcluded =
1501
+ s.attrs?.power_adapter === 'included_no_cord' ||
1502
+ s.attrs?.power_cord === 'not_included' ||
1503
+ /without\s+power\s+cord/i.test(pkgText) ||
1504
+ /no\s+power\s+cord/i.test(pkgText) ||
1505
+ /w\/?o\s+power\s+cord/i.test(pkgText) ||
1506
+ /power\s+cord\s+(?:sold\s+separately|not\s+included|optional|excluded)/i.test(pkgText) ||
1507
+ /without\s+power\s+cord/i.test(pwr.adapter_spec || '') ||
1508
+ /without\s+power\s+cord/i.test(s.name || '');
1509
+
1510
+ let powerCordIncluded = null;
1511
+ if (cordExplicitlyExcluded) {
1512
+ powerCordIncluded = false;
1513
+ } else if (s.attrs?.power_cord === 'included') {
1514
+ powerCordIncluded = true;
1515
+ } else if (pkg.length > 0) {
1516
+ const hasExplicitCord = pkg.some((item) => {
1517
+ const itemLower = item.toLowerCase();
1518
+ return /power\s+cord/i.test(itemLower) &&
1519
+ !/(?:without|sold\s+separately|not\s+included|optional|no\s+|w\/o|separately)/i.test(itemLower);
1520
+ });
1521
+ if (hasExplicitCord) {
1522
+ powerCordIncluded = true;
1523
+ } else if (adapterIncluded === true) {
1524
+ const isIntegratedPlug =
1525
+ /universal|wall\s*mount|plug/i.test(pwr.adapter_spec || '') ||
1526
+ /universal\s+power\s+supply/i.test(pkgText) ||
1527
+ /(?:with\s+[^()]*|\b(?:interchangeable|multi-country|global)\s+)plugs?\b/i.test(pkgText);
1528
+ if (isIntegratedPlug) {
1529
+ powerCordIncluded = true;
1530
+ } else {
1531
+ powerCordIncluded = null;
1532
+ }
1533
+ }
1534
+ }
1535
+
1536
+ let outOfTheBoxReady = false;
1537
+ let readinessSummary = '';
1538
+
1539
+ const isDevBoard = /xiao|seeeduino|arduino/i.test(family?.id || '') || /board|development\s+kit/i.test(s.name) || (pwr.input_voltage && /3\.7|5v/i.test(pwr.input_voltage));
1540
+
1541
+ if (hasIncludedBattery) {
1542
+ outOfTheBoxReady = true;
1543
+ readinessSummary = `随附配套电池供电(${pwr.input_connector ? '支持 ' + pwr.input_connector : '支持太阳能/USB充电'}),开箱即可直接上电运行。`;
1544
+ } else if (adapterIncluded === false) {
1545
+ outOfTheBoxReady = false;
1546
+ if (isDevBoard) {
1547
+ readinessSummary = `开发板/套件(仅含主板与配件),需通过 ${pwr.input_connector || 'USB-C'} 供电或自备外接电源/电池。`;
1548
+ } else {
1549
+ readinessSummary = `仅含主机,不随附电源适配器。用户买回去无法直接开机,必须加配 ${pwr.adapter_spec || '合适规格电源适配器'}。`;
1550
+ }
1551
+ } else if (adapterIncluded === true) {
1552
+ if (powerCordIncluded === false) {
1553
+ outOfTheBoxReady = false;
1554
+ readinessSummary = `随附电源适配器(${pwr.adapter_spec || '标配'}),但不随附 AC 电源线,开箱前需自备或加配电源线。`;
1555
+ } else if (powerCordIncluded === true) {
1556
+ outOfTheBoxReady = true;
1557
+ readinessSummary = `随附电源适配器与电源线(或一体式插头),开箱即用。`;
1558
+ } else {
1559
+ outOfTheBoxReady = false;
1560
+ readinessSummary = `随附电源适配器(${pwr.adapter_spec || '标配'}),包装清单未明确电源线随附情况,请核对是否需自备电源线。`;
1561
+ }
1562
+ } else {
1563
+ outOfTheBoxReady = false;
1564
+ readinessSummary = pwr.input_voltage ? `供电规格: ${pwr.input_voltage} ${pwr.input_connector || ''},请核对是否自备电源。` : '电源随附情况未声明。';
1565
+ }
1566
+
1567
+ // Get matching required accessories
1568
+ const requiredAccessories = (family?.required_accessories || []).filter((acc) => {
1569
+ if (!acc.applies_when) return true;
1570
+ const attrVal = s.attrs?.[acc.applies_when.attr];
1571
+ const target = acc.applies_when.value;
1572
+ if (Array.isArray(target)) return target.includes(attrVal);
1573
+ return attrVal === target;
1574
+ }).map((acc) => ({
1575
+ id: acc.id,
1576
+ name: acc.name,
1577
+ sku: acc.sku,
1578
+ reason: acc.reason,
1579
+ url: acc.url,
1580
+ }));
1581
+
1582
+ return {
1583
+ sku: s.sku,
1584
+ model: s.model || null,
1585
+ name: s.name,
1586
+ url: appendUtm(s.url, {
1587
+ source: 'sensecraft_agent',
1588
+ medium: 'bom_recommendation',
1589
+ campaign: family?.id || '',
1590
+ content: `sku_${s.sku}`,
1591
+ }),
1592
+ package_profile: {
1593
+ power_adapter_included: adapterIncluded,
1594
+ power_cord_included: powerCordIncluded,
1595
+ out_of_the_box_ready: outOfTheBoxReady,
1596
+ readiness_summary: readinessSummary,
1597
+ },
1598
+ required_accessories: requiredAccessories,
1599
+ power: {
1600
+ adapter_included: adapterIncluded,
1601
+ adapter_spec: pwr.adapter_spec || null,
1602
+ input_voltage: pwr.input_voltage || null,
1603
+ input_connector: pwr.input_connector || null,
1604
+ },
1605
+ package_contents: pkg,
1606
+ interfaces: specs.connectivity?.interfaces || family?.interfaces || [],
1607
+ io_ports: specs.io_ports || [],
1608
+ expansion_slots: specs.expansion_slots || [],
1609
+ ai_performance: specs.ai_performance || null,
1610
+ };
1611
+ }
1612
+
1613
+ /**
1614
+ * Prunes a product family and member SKU record based on requested section.
1615
+ *
1616
+ * @param {Object} fam - Product family record
1617
+ * @param {string} [section='all'] - Section name ('overview', 'power', 'interfaces', 'specs', 'all')
1618
+ * @returns {Object} Pruned product family record
1619
+ */
1620
+ export function pruneProductRecord(fam, section = 'all') {
1621
+ if (!fam || !section || section === 'all') return fam;
1622
+ const s = String(section).toLowerCase().trim();
1623
+ if (!PRODUCT_SECTIONS.has(s)) {
1624
+ throw new Error(`Unsupported product section "${section}". Supported: ${Array.from(PRODUCT_SECTIONS).join(', ')}.`);
1625
+ }
1626
+ if (s === 'all') return fam;
1627
+
1628
+ const rawSkus = fam.matched_skus || fam.skus || [];
1629
+
1630
+ const base = {
1631
+ family_id: fam.family_id,
1632
+ title: fam.title,
1633
+ summary: fam.summary,
1634
+ default_sku: fam.default_sku,
1635
+ regions: fam.regions || [],
1636
+ physical_spec: fam.physical_spec || null,
1637
+ required_accessories_count: fam.required_accessories_count ?? (fam.required_accessories || []).length,
1638
+ optional_accessories_count: fam.optional_accessories_count ?? (fam.optional_accessories || []).length,
1639
+ };
1640
+
1641
+ if (s === 'overview') {
1642
+ const prunedSkus = rawSkus.map((sku) => ({
1643
+ sku: sku.sku,
1644
+ model: sku.model || null,
1645
+ name: sku.name,
1646
+ readiness_summary: sku.package_profile?.readiness_summary || null,
1647
+ out_of_the_box_ready: sku.package_profile?.out_of_the_box_ready ?? null,
1648
+ url: sku.url,
1649
+ }));
1650
+ return {
1651
+ ...base,
1652
+ skus_count: rawSkus.length || fam.skus_count || 0,
1653
+ matched_skus: prunedSkus,
1654
+ skus: prunedSkus,
1655
+ };
1656
+ }
1657
+
1658
+ if (s === 'power') {
1659
+ const prunedSkus = rawSkus.map((sku) => ({
1660
+ sku: sku.sku,
1661
+ model: sku.model || null,
1662
+ name: sku.name,
1663
+ package_profile: sku.package_profile,
1664
+ power: sku.power,
1665
+ required_accessories: sku.required_accessories || [],
1666
+ package_contents: sku.package_contents || [],
1667
+ url: sku.url,
1668
+ }));
1669
+ return {
1670
+ ...base,
1671
+ matched_skus: prunedSkus,
1672
+ skus: prunedSkus,
1673
+ };
1674
+ }
1675
+
1676
+ if (s === 'interfaces') {
1677
+ const prunedSkus = rawSkus.map((sku) => ({
1678
+ sku: sku.sku,
1679
+ model: sku.model || null,
1680
+ name: sku.name,
1681
+ interfaces: sku.interfaces || [],
1682
+ io_ports: sku.io_ports || [],
1683
+ expansion_slots: sku.expansion_slots || [],
1684
+ url: sku.url,
1685
+ }));
1686
+ return {
1687
+ ...base,
1688
+ interfaces: fam.interfaces || [],
1689
+ protocols: fam.protocols || [],
1690
+ matched_skus: prunedSkus,
1691
+ skus: prunedSkus,
1692
+ };
1693
+ }
1694
+
1695
+ if (s === 'specs') {
1696
+ const prunedSkus = rawSkus.map((sku) => ({
1697
+ sku: sku.sku,
1698
+ model: sku.model || null,
1699
+ name: sku.name,
1700
+ ai_performance: sku.ai_performance || null,
1701
+ power: sku.power,
1702
+ interfaces: sku.interfaces || [],
1703
+ io_ports: sku.io_ports || [],
1704
+ expansion_slots: sku.expansion_slots || [],
1705
+ package_contents: sku.package_contents || [],
1706
+ url: sku.url,
1707
+ }));
1708
+ return {
1709
+ ...base,
1710
+ interfaces: fam.interfaces || [],
1711
+ protocols: fam.protocols || [],
1712
+ matched_skus: prunedSkus,
1713
+ skus: prunedSkus,
1714
+ };
1715
+ }
1716
+
1717
+ return fam;
1718
+ }
1719
+
1099
1720
  /**
1100
1721
  * Queries product family information, SKUs, and official purchase links.
1101
1722
  *
1102
1723
  * @param {string} familyId
1103
1724
  * @param {Object} [options]
1104
1725
  * @param {string} [options.locale='zh']
1726
+ * @param {string} [options.section='all']
1105
1727
  * @returns {Object} Catalog DTO with product details
1106
1728
  */
1107
- export function queryProductFamily(familyId, { locale = 'zh' } = {}) {
1729
+ export function queryProductFamily(familyId, { locale = 'zh', section = 'all' } = {}) {
1108
1730
  const core = loadKnowledgeCore();
1109
1731
  const family = core.product_families?.[familyId];
1110
1732
 
@@ -1117,35 +1739,56 @@ export function queryProductFamily(familyId, { locale = 'zh' } = {}) {
1117
1739
  });
1118
1740
  }
1119
1741
 
1742
+ const normalizedSkus = (family.skus || []).slice(0, 10).map((s) => normalizeSkuRecord(s, family));
1743
+
1744
+ const rawRecord = {
1745
+ family_id: family.id,
1746
+ title: family.title?.[locale] || family.title?.zh || family.title?.en || family.id,
1747
+ summary: family.summary || null,
1748
+ default_sku: family.default_sku,
1749
+ regions: family.regions || [],
1750
+ interfaces: family.interfaces || [],
1751
+ protocols: family.protocols || [],
1752
+ supported_scenarios: family.supported_scenarios || [],
1753
+ skus_count: (family.skus || []).length,
1754
+ skus: normalizedSkus,
1755
+ matched_skus: normalizedSkus,
1756
+ physical_spec: family.physical_spec || null,
1757
+ required_accessories_count: (family.required_accessories || []).length,
1758
+ optional_accessories_count: (family.optional_accessories || []).length,
1759
+ };
1760
+
1120
1761
  return createCatalogDto({
1121
1762
  source: 'bundled_manifest',
1122
1763
  freshness: 'FRESH',
1123
- records: [{
1124
- family_id: family.id,
1125
- title: family.title?.[locale] || family.title?.zh || family.title?.en || family.id,
1126
- summary: family.summary || null,
1127
- default_sku: family.default_sku,
1128
- regions: family.regions || [],
1129
- interfaces: family.interfaces || [],
1130
- protocols: family.protocols || [],
1131
- supported_scenarios: family.supported_scenarios || [],
1132
- skus_count: (family.skus || []).length,
1133
- skus: (family.skus || []).slice(0, 10).map((s) => ({
1134
- ...s,
1135
- url: appendUtm(s.url, {
1136
- source: 'sensecraft_agent',
1137
- medium: 'bom_recommendation',
1138
- campaign: family.id,
1139
- content: `sku_${s.sku}`,
1140
- }),
1141
- })),
1142
- physical_spec: family.physical_spec || null,
1143
- required_accessories_count: (family.required_accessories || []).length,
1144
- optional_accessories_count: (family.optional_accessories || []).length,
1145
- }],
1764
+ records: [pruneProductRecord(rawRecord, section)],
1146
1765
  });
1147
1766
  }
1148
1767
 
1768
+ const PRODUCT_SYNONYMS = {
1769
+ '树莓派': ['raspberry', 'cm4', 'rpi', 'r1000', 'r12', 'r20', 'r21', 'reterminal'],
1770
+ '工控机': ['industrial', 'ipc', 'recomputer', 'reserver'],
1771
+ '边缘计算': ['recomputer', 'edge', 'orin'],
1772
+ '边缘盒子': ['recomputer', 'edge', 'orin'],
1773
+ '激光雷达': ['lidar', 'rplidar'],
1774
+ '相机': ['recamera', 'camera', 'grove vision', 'vision'],
1775
+ '继电器': ['relay'],
1776
+ '土壤': ['soil', 'mtec'],
1777
+ '气象': ['weather', 's200', 's700', 's2120'],
1778
+ '追踪器': ['tracker', 't1000', 't2000'],
1779
+ '网关': ['gateway', 'lorawan', 'm2', 'r1225'],
1780
+ };
1781
+
1782
+ function matchesTokenOrSynonym(haystack, token) {
1783
+ if (haystack.includes(token)) return true;
1784
+ for (const [k, syns] of Object.entries(PRODUCT_SYNONYMS)) {
1785
+ if (token.includes(k) || k.includes(token)) {
1786
+ if (syns.some((s) => haystack.includes(s))) return true;
1787
+ }
1788
+ }
1789
+ return false;
1790
+ }
1791
+
1149
1792
  /**
1150
1793
  * Searches product families and member SKUs by keywords, model name, interface, or SKU number.
1151
1794
  *
@@ -1153,9 +1796,10 @@ export function queryProductFamily(familyId, { locale = 'zh' } = {}) {
1153
1796
  * @param {string} options.query - Free text query (e.g. "J4012 Super", "RS485", "114110314")
1154
1797
  * @param {'zh' | 'en' | 'ja'} [options.locale='zh']
1155
1798
  * @param {number} [options.limit=5]
1799
+ * @param {string} [options.section='all'] - Section name ('overview', 'power', 'interfaces', 'specs', 'all')
1156
1800
  * @returns {Object} Catalog DTO
1157
1801
  */
1158
- export function searchProducts({ query = '', locale = 'zh', limit = 5 } = {}) {
1802
+ export function searchProducts({ query = '', locale = 'zh', limit = 5, section = 'all' } = {}) {
1159
1803
  const core = loadKnowledgeCore();
1160
1804
  const families = Object.values(core.product_families || {});
1161
1805
  const qLower = (query || '').toLowerCase().trim();
@@ -1176,8 +1820,12 @@ export function searchProducts({ query = '', locale = 'zh', limit = 5 } = {}) {
1176
1820
  let score = 0;
1177
1821
  const titleZh = (fam.title?.zh || '').toLowerCase();
1178
1822
  const titleEn = (fam.title?.en || '').toLowerCase();
1823
+ const summaryZh = typeof fam.summary === 'object' ? (fam.summary?.zh || '') : (fam.summary || '');
1824
+ const summaryEn = typeof fam.summary === 'object' ? (fam.summary?.en || '') : '';
1825
+ const series = (fam.series || '').toLowerCase();
1826
+ const pf = (fam.product_family || '').toLowerCase();
1179
1827
  const fid = (fam.id || '').toLowerCase();
1180
- const famHaystack = [fid, titleZh, titleEn].join(' ');
1828
+ const famHaystack = [fid, titleZh, titleEn, summaryZh, summaryEn, series, pf].join(' ').toLowerCase();
1181
1829
 
1182
1830
  // Check SKU matching
1183
1831
  const matchedSkus = [];
@@ -1195,17 +1843,17 @@ export function searchProducts({ query = '', locale = 'zh', limit = 5 } = {}) {
1195
1843
  const skuProtos = (sku.specs?.connectivity?.protocols || sku.protocols || []).map((p) => String(p).toLowerCase());
1196
1844
  const skuIoPorts = (sku.specs?.io_ports || []).map((p) => String(p.type || '').toLowerCase());
1197
1845
 
1198
- const skuHaystack = [sNum, sModel, sNameClean, ...sAttrs, ...skuIfaces, ...skuProtos, ...skuIoPorts].join(' ');
1846
+ const skuHaystack = [sNum, sModel, sNameClean, ...sAttrs, ...skuIfaces, ...skuProtos, ...skuIoPorts].join(' ').toLowerCase();
1199
1847
  const isSingleSkuLookup = tokens.length === 1 && (sNum === tokens[0] || sNum === qLower);
1200
- const skuMatchesAll = tokens.every((t) => skuHaystack.includes(t));
1201
- const singleTokenHit = tokens.length === 1 && ((sModel && sModel.includes(tokens[0])) || skuHaystack.includes(tokens[0]));
1848
+ const skuMatchesAll = tokens.every((t) => matchesTokenOrSynonym(skuHaystack, t));
1849
+ const singleTokenHit = tokens.length === 1 && ((sModel && sModel.includes(tokens[0])) || matchesTokenOrSynonym(skuHaystack, tokens[0]));
1202
1850
 
1203
1851
  if (isSingleSkuLookup || skuMatchesAll) {
1204
1852
  matchedSkus.push(sku);
1205
1853
  } else if (tokens.length > 1) {
1206
- const combinedMatchesAll = tokens.every((t) => (famHaystack + ' ' + skuHaystack).includes(t));
1207
- const nonFamTokens = tokens.filter((t) => !famHaystack.includes(t));
1208
- if (combinedMatchesAll && (nonFamTokens.length === 0 || nonFamTokens.every((t) => skuHaystack.includes(t)))) {
1854
+ const combinedMatchesAll = tokens.every((t) => matchesTokenOrSynonym(famHaystack + ' ' + skuHaystack, t));
1855
+ const nonFamTokens = tokens.filter((t) => !matchesTokenOrSynonym(famHaystack, t));
1856
+ if (combinedMatchesAll && (nonFamTokens.length === 0 || nonFamTokens.every((t) => matchesTokenOrSynonym(skuHaystack, t)))) {
1209
1857
  matchedSkus.push(sku);
1210
1858
  }
1211
1859
  } else if (singleTokenHit) {
@@ -1214,9 +1862,9 @@ export function searchProducts({ query = '', locale = 'zh', limit = 5 } = {}) {
1214
1862
  }
1215
1863
 
1216
1864
  // Require all query tokens to be satisfied either in the family or across member SKUs
1217
- const famMatchesAll = tokens.every((t) => famHaystack.includes(t));
1865
+ const famMatchesAll = tokens.every((t) => matchesTokenOrSynonym(famHaystack, t));
1218
1866
  const allTokensSatisfiedInFamily = tokens.every((t) => {
1219
- return famHaystack.includes(t) || (fam.skus || []).some((s) => {
1867
+ return matchesTokenOrSynonym(famHaystack, t) || (fam.skus || []).some((s) => {
1220
1868
  const sNameClean = String(s.name || '')
1221
1869
  .toLowerCase()
1222
1870
  .replace(/[(\(][^)\)]*not\s+supported[^)\)]*[)\)]?/gi, '')
@@ -1224,7 +1872,7 @@ export function searchProducts({ query = '', locale = 'zh', limit = 5 } = {}) {
1224
1872
  const sIfaces = (s.specs?.connectivity?.interfaces || s.interfaces || []).map((i) => String(i).toLowerCase());
1225
1873
  const sProtos = (s.specs?.connectivity?.protocols || s.protocols || []).map((p) => String(p).toLowerCase());
1226
1874
  const sPorts = (s.specs?.io_ports || []).map((p) => String(p.type || '').toLowerCase());
1227
- return [s.sku, s.model, sNameClean, ...Object.values(s.attrs || {}), ...sIfaces, ...sProtos, ...sPorts].join(' ').toLowerCase().includes(t);
1875
+ return matchesTokenOrSynonym([s.sku, s.model, sNameClean, ...Object.values(s.attrs || {}), ...sIfaces, ...sProtos, ...sPorts].join(' ').toLowerCase(), t);
1228
1876
  });
1229
1877
  });
1230
1878
 
@@ -1253,152 +1901,150 @@ export function searchProducts({ query = '', locale = 'zh', limit = 5 } = {}) {
1253
1901
  interfaces: family.interfaces || [],
1254
1902
  protocols: family.protocols || [],
1255
1903
  supported_scenarios: family.supported_scenarios || [],
1256
- matched_skus: matchedSkus.map((s) => {
1257
- const specs = s.specs || {};
1258
- const pwr = specs.power || {};
1259
- const pkg = specs.package_contents || [];
1260
- const pkgText = pkg.join(' ').toLowerCase();
1261
-
1262
- // Require positive evidence of an included operational battery (not just battery_powered capability or RTC battery)
1263
- const hasIncludedBattery = pkg.some((item) => {
1264
- if (!/\bbattery\b/i.test(item)) return false;
1265
- if (/(?:not\s+included|sold\s+separately|cr1220|rtc|coin)/i.test(item)) return false;
1266
- if (/(?:without|w\/o|no|exclude[ds]?)\s+[^()]*\bbattery/i.test(item)) return false;
1267
- return true;
1268
- });
1904
+ matched_skus: matchedSkus.map((s) => normalizeSkuRecord(s, family)),
1905
+ };
1906
+ });
1269
1907
 
1270
- let adapterIncluded = pwr.adapter_included;
1271
- if (adapterIncluded === undefined || adapterIncluded === null) {
1272
- if (s.attrs?.power_adapter === 'not_included' || /w\/?o\s+power/i.test(s.name)) {
1273
- adapterIncluded = false;
1274
- } else if (s.attrs?.power_adapter === 'included' || /power\s+adapter/i.test(pkgText)) {
1275
- adapterIncluded = true;
1276
- } else {
1277
- adapterIncluded = hasIncludedBattery ? false : null;
1278
- }
1279
- }
1908
+ return createCatalogDto({
1909
+ source: 'bundled_manifest',
1910
+ freshness: 'FRESH',
1911
+ records: records.map((r) => pruneProductRecord(r, section)),
1912
+ warnings: records.length === 0 ? [`未找到与 "${query}" 匹配的硬件产品或 SKU。`] : [],
1913
+ });
1914
+ }
1280
1915
 
1281
- const cordExplicitlyExcluded =
1282
- s.attrs?.power_adapter === 'included_no_cord' ||
1283
- s.attrs?.power_cord === 'not_included' ||
1284
- /without\s+power\s+cord/i.test(pkgText) ||
1285
- /no\s+power\s+cord/i.test(pkgText) ||
1286
- /w\/?o\s+power\s+cord/i.test(pkgText) ||
1287
- /power\s+cord\s+(?:sold\s+separately|not\s+included|optional|excluded)/i.test(pkgText) ||
1288
- /without\s+power\s+cord/i.test(pwr.adapter_spec || '') ||
1289
- /without\s+power\s+cord/i.test(s.name || '');
1290
-
1291
- let powerCordIncluded = null;
1292
- if (cordExplicitlyExcluded) {
1293
- powerCordIncluded = false;
1294
- } else if (s.attrs?.power_cord === 'included') {
1295
- powerCordIncluded = true;
1296
- } else if (pkg.length > 0) {
1297
- const hasExplicitCord = pkg.some((item) => {
1298
- const itemLower = item.toLowerCase();
1299
- return /power\s+cord/i.test(itemLower) &&
1300
- !/(?:without|sold\s+separately|not\s+included|optional|no\s+|w\/o|separately)/i.test(itemLower);
1301
- });
1302
- if (hasExplicitCord) {
1303
- powerCordIncluded = true;
1304
- } else if (adapterIncluded === true) {
1305
- const isIntegratedPlug =
1306
- /universal|wall\s*mount|plug/i.test(pwr.adapter_spec || '') ||
1307
- /universal\s+power\s+supply/i.test(pkgText) ||
1308
- /(?:with\s+[^()]*|\b(?:interchangeable|multi-country|global)\s+)plugs?\b/i.test(pkgText);
1309
- if (isIntegratedPlug) {
1310
- powerCordIncluded = true;
1311
- } else {
1312
- powerCordIncluded = null;
1313
- }
1314
- }
1315
- }
1916
+ /**
1917
+ * Asynchronously searches products with online Typesense purchase_profile fallback.
1918
+ *
1919
+ * @param {Object} options
1920
+ * @param {string} options.query
1921
+ * @param {'zh' | 'en' | 'ja'} [options.locale='zh']
1922
+ * @param {number} [options.limit=5]
1923
+ * @param {string} [options.section='all']
1924
+ * @returns {Promise<Object>}
1925
+ */
1926
+ export async function searchProductsAsync({ query = '', locale = 'zh', limit = 5, section = 'all' } = {}) {
1927
+ const localDto = searchProducts({ query, locale, limit, section });
1928
+ if (localDto.records.length > 0 || process.env.SENSECRAFT_OFFLINE_ONLY) {
1929
+ return localDto;
1930
+ }
1931
+
1932
+ const clientConfig = loadClientConfig();
1933
+ const host = process.env.TYPESENSE_HOST || clientConfig?.typesense?.host || 'https://rel-search.seeedstudio.com';
1934
+ const keyPurchase = process.env.SENSECRAFT_TYPESENSE_PURCHASE_SEARCH_KEY || process.env.TYPESENSE_SEARCH_KEY_PURCHASE_PROFILE || clientConfig?.typesense?.search_key_purchase_profile;
1316
1935
 
1317
- let outOfTheBoxReady = false;
1318
- let readinessSummary = '';
1936
+ if (!keyPurchase) {
1937
+ return localDto;
1938
+ }
1319
1939
 
1320
- const isDevBoard = /xiao|seeeduino|arduino/i.test(family.id) || /board|development\s+kit/i.test(s.name) || (pwr.input_voltage && /3\.7|5v/i.test(pwr.input_voltage));
1940
+ try {
1941
+ const qLower = (query || '').toLowerCase().trim();
1942
+ const tokens = qLower.split(/\s+/).filter(Boolean);
1943
+ const searchUrl = `${host}/collections/purchase_profile/documents/search?q=${encodeURIComponent(query)}&query_by=title,series,product_family&drop_tokens_mode=none&per_page=${limit}`;
1944
+ const data = await fetchJson(searchUrl, { 'X-TYPESENSE-API-KEY': keyPurchase }, 2500);
1945
+ const hits = data?.hits || [];
1946
+ if (hits.length === 0) return localDto;
1947
+
1948
+ const core = loadKnowledgeCore();
1949
+ const candidateFamIds = new Set();
1950
+ for (const hit of hits) {
1951
+ const doc = hit.document || {};
1952
+ const famId = doc.family_id || doc.id;
1953
+ if (famId) candidateFamIds.add(famId);
1954
+ }
1321
1955
 
1322
- if (hasIncludedBattery) {
1323
- outOfTheBoxReady = true;
1324
- readinessSummary = `随附配套电池供电(${pwr.input_connector ? '支持 ' + pwr.input_connector : '支持太阳能/USB充电'}),开箱即可直接上电运行。`;
1325
- } else if (adapterIncluded === false) {
1326
- outOfTheBoxReady = false;
1327
- if (isDevBoard) {
1328
- readinessSummary = `开发板/套件(仅含主板与配件),需通过 ${pwr.input_connector || 'USB-C'} 供电或自备外接电源/电池。`;
1329
- } else {
1330
- readinessSummary = `仅含主机,不随附电源适配器。用户买回去无法直接开机,必须加配 ${pwr.adapter_spec || '合适规格电源适配器'}。`;
1331
- }
1332
- } else if (adapterIncluded === true) {
1333
- if (powerCordIncluded === false) {
1334
- outOfTheBoxReady = false;
1335
- readinessSummary = `随附电源适配器(${pwr.adapter_spec || '标配'}),但不随附 AC 电源线,开箱前需自备或加配电源线。`;
1336
- } else if (powerCordIncluded === true) {
1337
- outOfTheBoxReady = true;
1338
- readinessSummary = `随附电源适配器与电源线(或一体式插头),开箱即用。`;
1339
- } else {
1340
- outOfTheBoxReady = false;
1341
- readinessSummary = `随附电源适配器(${pwr.adapter_spec || '标配'}),包装清单未明确电源线随附情况,请核对是否需自备电源线。`;
1342
- }
1343
- } else {
1344
- outOfTheBoxReady = false;
1345
- readinessSummary = pwr.input_voltage ? `供电规格: ${pwr.input_voltage} ${pwr.input_connector || ''},请核对是否自备电源。` : '电源随附情况未声明。';
1956
+ if (candidateFamIds.size > 0) {
1957
+ const onlineMatches = [];
1958
+ for (const famId of candidateFamIds) {
1959
+ const localFam = core.product_families?.[famId];
1960
+ if (localFam) {
1961
+ const titleZh = (localFam.title?.zh || '').toLowerCase();
1962
+ const titleEn = (localFam.title?.en || '').toLowerCase();
1963
+ const summaryZh = typeof localFam.summary === 'object' ? (localFam.summary?.zh || '') : (localFam.summary || '');
1964
+ const summaryEn = typeof localFam.summary === 'object' ? (localFam.summary?.en || '') : '';
1965
+ const series = (localFam.series || '').toLowerCase();
1966
+ const pf = (localFam.product_family || '').toLowerCase();
1967
+ const fid = (localFam.id || '').toLowerCase();
1968
+ const famHaystack = [fid, titleZh, titleEn, summaryZh, summaryEn, series, pf].join(' ').toLowerCase();
1969
+
1970
+ // Reject false positives: require all query tokens to be satisfied either in the family or across member SKUs
1971
+ const allTokensSatisfied = tokens.every((t) => {
1972
+ return matchesTokenOrSynonym(famHaystack, t) || (localFam.skus || []).some((s) => {
1973
+ const sNameClean = String(s.name || '').toLowerCase();
1974
+ return matchesTokenOrSynonym([s.sku, s.model, sNameClean].join(' ').toLowerCase(), t);
1975
+ });
1976
+ });
1977
+
1978
+ if (!allTokensSatisfied) continue;
1979
+
1980
+ onlineMatches.push({
1981
+ family: localFam,
1982
+ matchedSkus: (localFam.skus || []).slice(0, 3),
1983
+ });
1346
1984
  }
1985
+ }
1347
1986
 
1348
- // Get matching required accessories
1349
- const requiredAccessories = (family.required_accessories || []).filter((acc) => {
1350
- if (!acc.applies_when) return true;
1351
- const attrVal = s.attrs?.[acc.applies_when.attr];
1352
- const target = acc.applies_when.value;
1353
- if (Array.isArray(target)) return target.includes(attrVal);
1354
- return attrVal === target;
1355
- }).map((acc) => ({
1356
- id: acc.id,
1357
- name: acc.name,
1358
- sku: acc.sku,
1359
- reason: acc.reason,
1360
- url: acc.url,
1361
- }));
1987
+ if (onlineMatches.length > 0) {
1988
+ const records = onlineMatches.slice(0, limit).map(({ family, matchedSkus }) => {
1989
+ return {
1990
+ family_id: family.id,
1991
+ title: family.title?.[locale] || family.title?.zh || family.title?.en || family.id,
1992
+ summary: family.summary || null,
1993
+ default_sku: family.default_sku,
1994
+ interfaces: family.interfaces || [],
1995
+ protocols: family.protocols || [],
1996
+ supported_scenarios: family.supported_scenarios || [],
1997
+ matched_skus: matchedSkus.map((s) => {
1998
+ const specs = s.specs || {};
1999
+ const pwr = specs.power || {};
2000
+ const pkg = specs.package_contents || [];
2001
+ return {
2002
+ sku: s.sku,
2003
+ model: s.model || null,
2004
+ name: s.name,
2005
+ url: appendUtm(s.url, {
2006
+ source: 'sensecraft_agent',
2007
+ medium: 'product_search',
2008
+ campaign: family.id,
2009
+ content: `sku_${s.sku}`,
2010
+ }),
2011
+ package_profile: {
2012
+ power_adapter_included: pwr.adapter_included ?? null,
2013
+ power_cord_included: null,
2014
+ out_of_the_box_ready: Boolean(pwr.adapter_included),
2015
+ readiness_summary: pwr.adapter_included ? '随附电源适配器' : '需自备或加配电源供电',
2016
+ },
2017
+ required_accessories: [],
2018
+ power: {
2019
+ adapter_included: pwr.adapter_included ?? null,
2020
+ adapter_spec: pwr.adapter_spec || null,
2021
+ input_voltage: pwr.input_voltage || null,
2022
+ input_connector: pwr.input_connector || null,
2023
+ },
2024
+ package_contents: pkg,
2025
+ interfaces: specs.connectivity?.interfaces || family.interfaces || [],
2026
+ io_ports: specs.io_ports || [],
2027
+ expansion_slots: specs.expansion_slots || [],
2028
+ ai_performance: specs.ai_performance || null,
2029
+ };
2030
+ }),
2031
+ };
2032
+ });
1362
2033
 
1363
- return {
1364
- sku: s.sku,
1365
- model: s.model || null,
1366
- name: s.name,
1367
- url: appendUtm(s.url, {
1368
- source: 'sensecraft_agent',
1369
- medium: 'product_search',
1370
- campaign: family.id,
1371
- content: `sku_${s.sku}`,
1372
- }),
1373
- package_profile: {
1374
- power_adapter_included: adapterIncluded,
1375
- power_cord_included: powerCordIncluded,
1376
- out_of_the_box_ready: outOfTheBoxReady,
1377
- readiness_summary: readinessSummary,
1378
- },
1379
- required_accessories: requiredAccessories,
1380
- power: {
1381
- adapter_included: adapterIncluded,
1382
- adapter_spec: pwr.adapter_spec || null,
1383
- input_voltage: pwr.input_voltage || null,
1384
- input_connector: pwr.input_connector || null,
1385
- },
1386
- package_contents: pkg,
1387
- interfaces: specs.connectivity?.interfaces || family.interfaces || [],
1388
- io_ports: specs.io_ports || [],
1389
- expansion_slots: specs.expansion_slots || [],
1390
- ai_performance: specs.ai_performance || null,
1391
- };
1392
- }),
1393
- };
1394
- });
2034
+ return createCatalogDto({
2035
+ source: 'typesense',
2036
+ freshness: 'FRESH',
2037
+ fieldCompleteness: { spec: true, compatibility: true, offer: true },
2038
+ records: records.map((r) => pruneProductRecord(r, section)),
2039
+ warnings: localDto.warnings || [],
2040
+ });
2041
+ }
2042
+ }
2043
+ } catch (_) {
2044
+ // Graceful fallback
2045
+ }
1395
2046
 
1396
- return createCatalogDto({
1397
- source: 'bundled_manifest',
1398
- freshness: 'FRESH',
1399
- records,
1400
- warnings: records.length === 0 ? [`未找到与 "${query}" 匹配的硬件产品或 SKU。`] : [],
1401
- });
2047
+ return localDto;
1402
2048
  }
1403
2049
 
1404
2050
  /**