@seeed-studio/solution-cli 0.4.5 → 0.4.7
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/CHANGELOG.md +32 -0
- package/bin/cli.js +215 -64
- package/catalog/client-config.json +2 -1
- package/lib/knowledge.js +820 -174
- package/package.json +3 -2
- package/skills/solution-cli/SKILL.md +11 -0
- package/skills/solution-cli/references/consult-and-select.md +19 -0
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
|
|
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
|
|
1201
|
-
const singleTokenHit = tokens.length === 1 && ((sModel && sModel.includes(tokens[0])) || skuHaystack
|
|
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
|
|
1207
|
-
const nonFamTokens = tokens.filter((t) => !famHaystack
|
|
1208
|
-
if (combinedMatchesAll && (nonFamTokens.length === 0 || nonFamTokens.every((t) => skuHaystack
|
|
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
|
|
1865
|
+
const famMatchesAll = tokens.every((t) => matchesTokenOrSynonym(famHaystack, t));
|
|
1218
1866
|
const allTokensSatisfiedInFamily = tokens.every((t) => {
|
|
1219
|
-
return famHaystack
|
|
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()
|
|
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
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
-
|
|
1318
|
-
|
|
1936
|
+
if (!keyPurchase) {
|
|
1937
|
+
return localDto;
|
|
1938
|
+
}
|
|
1319
1939
|
|
|
1320
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
1349
|
-
const
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
|
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
|
/**
|