mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(cctv): resolve verbose registry model strings via token fallback
Some catalogs publish a full product title rather than the bare model, e.g. "AXIS Q6155-E PTZ Dome Network Camera" — which the exact model/alias match misses. Add a fallback: after the exact match, scan for a record whose DISTINCTIVE token (a >=4-char model/alias word containing a digit or hyphen, so "dome"/"network"/"ptz"/"hd" never qualify) appears as a whole word in the query. The most specific (longest) matched token wins; if two records tie on the longest matched token the result is ambiguous and returns null, so the lookup never guesses. Exact matches are unchanged and still take precedence.
This commit is contained in:
1 parent
97118f4781
commit
0533f78be4
3 files changed
+105
-4
No files matched your search
@@ -1079,6 +1079,7 @@
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/modelSpecs.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
@@ -1496,6 +1497,7 @@
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/modelSpecs.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
@@ -2136,6 +2138,7 @@
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/modelSpecs.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
|
||||
@@ -297,21 +297,82 @@ const RECORDS = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Lowercase + collapse whitespace, the shared normal form for matching. */
|
||||
function normalize(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
const BY_MODEL = new Map();
|
||||
for (const record of RECORDS) {
|
||||
BY_MODEL.set(record.model.toLowerCase(), record);
|
||||
BY_MODEL.set(normalize(record.model), record);
|
||||
for (const alias of record.aliases || [])
|
||||
BY_MODEL.set(alias.toLowerCase(), record);
|
||||
BY_MODEL.set(normalize(alias), record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a spec record by model string (case-insensitive; aliases match too).
|
||||
* DISTINCTIVE tokens of a record — the whitespace words of its model and
|
||||
* aliases that identify hardware rather than describe it. A token qualifies
|
||||
* only when it is >= 4 chars AND contains a digit or hyphen, so generic words
|
||||
* ("dome", "network", "camera", "series", "ptz") are never distinctive and a
|
||||
* verbose registry string cannot match on them alone. Surrounding punctuation
|
||||
* is trimmed so "(NDP-7512-Z30)" indexes as "ndp-7512-z30".
|
||||
* @param {Object} record
|
||||
* @returns {string[]} Unique distinctive tokens, lowercased.
|
||||
*/
|
||||
function distinctiveTokens(record) {
|
||||
const out = new Set();
|
||||
for (const name of [record.model, ...(record.aliases || [])]) {
|
||||
for (const raw of normalize(name).split(' ')) {
|
||||
const token = raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '');
|
||||
if (token.length >= 4 && /[0-9-]/.test(token)) out.add(token);
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* Token index for the substring fallback: one precompiled whole-word matcher
|
||||
* per distinctive token. Word boundaries are any non-alphanumeric char (or the
|
||||
* string ends), so hyphenated/dotted tokens like "q6155-e" or "h.264" match as
|
||||
* whole words while "700" never matches inside "7000".
|
||||
*/
|
||||
const TOKEN_INDEX = RECORDS.flatMap((record) =>
|
||||
distinctiveTokens(record).map((token) => ({
|
||||
token,
|
||||
record,
|
||||
re: new RegExp(
|
||||
`(?:^|[^a-z0-9])${token.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9]|$)`,
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Looks up a spec record for a hardware-model string.
|
||||
*
|
||||
* Exact match first (case-insensitive, whitespace-collapsed) on the model and
|
||||
* every alias. When that misses, a token fallback lets a VERBOSE registry
|
||||
* string resolve — e.g. "AXIS Q6155-E PTZ Dome Network Camera" finds the
|
||||
* Q6155-E via its distinctive "q6155-e" token. The most specific (longest)
|
||||
* matched token wins; if two different records tie on the longest matched
|
||||
* token the result is ambiguous and returns null (never guesses).
|
||||
* @param {string} model - Hardware model as named by a source pack or seed.
|
||||
* @returns {Object|null} The verbatim spec record, or null when unknown.
|
||||
*/
|
||||
export function lookupModelSpec(model) {
|
||||
if (!model) return null;
|
||||
return BY_MODEL.get(String(model).trim().toLowerCase()) || null;
|
||||
const query = normalize(model);
|
||||
if (!query) return null;
|
||||
const exact = BY_MODEL.get(query);
|
||||
if (exact) return exact;
|
||||
const matches = TOKEN_INDEX.filter((entry) => entry.re.test(query));
|
||||
if (!matches.length) return null;
|
||||
const maxLen = Math.max(...matches.map((m) => m.token.length));
|
||||
const top = matches.filter((m) => m.token.length === maxLen);
|
||||
const records = new Set(top.map((m) => m.record));
|
||||
return records.size === 1 ? top[0].record : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,43 @@ test('vendored table covers the integrated catalogs published hardware', () => {
|
||||
assert.equal(horizontalFovDeg(axis), 66.7);
|
||||
});
|
||||
|
||||
test('verbose registry strings resolve via distinctive-token fallback', () => {
|
||||
// Sioux Falls publishes the full product title, not the bare model.
|
||||
assert.equal(
|
||||
lookupModelSpec('AXIS Q6155-E PTZ Dome Network Camera')?.model,
|
||||
'Q6155-E',
|
||||
);
|
||||
assert.equal(lookupModelSpec('AXIS Q6000-E Mk II')?.model, 'Q6000-E Mk II');
|
||||
assert.equal(
|
||||
lookupModelSpec('AUTODOME IP starlight 7000i camera')?.id,
|
||||
'bosch-ndp-7512-z30',
|
||||
);
|
||||
// Punctuation around the model token doesn't defeat the match.
|
||||
assert.equal(
|
||||
lookupModelSpec('camera (P3707-PE), outdoor')?.model,
|
||||
'P3707-PE',
|
||||
);
|
||||
});
|
||||
|
||||
test('token fallback never matches on generic words or wrong models', () => {
|
||||
// Only generic descriptors — no distinctive hardware token — stays null.
|
||||
assert.equal(lookupModelSpec('PTZ Dome Camera'), null);
|
||||
assert.equal(lookupModelSpec('HD Network Camera'), null);
|
||||
assert.equal(lookupModelSpec('Outdoor Dome Camera'), null);
|
||||
// A real-looking but unknown model resolves to nothing, not a near neighbour.
|
||||
assert.equal(lookupModelSpec('AXIS FooBar-9 Network Camera'), null);
|
||||
// A generation token alone must not collide with a similar one: "7000"
|
||||
// belongs to two records (dynamic / starlight 7000 HD), so a bare
|
||||
// "AUTODOME 7000" is ambiguous → null rather than an arbitrary pick.
|
||||
assert.equal(lookupModelSpec('AUTODOME 7000'), null);
|
||||
});
|
||||
|
||||
test('exact match still wins over the token fallback', () => {
|
||||
// "3965" is an alias of the 3960; exact alias match, not a token scan.
|
||||
assert.equal(lookupModelSpec('3965')?.id, 'cohu-3960');
|
||||
assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30');
|
||||
});
|
||||
|
||||
test('lookupModelSpec returns null for unknown or empty input — never guesses', () => {
|
||||
assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null);
|
||||
assert.equal(lookupModelSpec(''), null);
|
||||
|
||||
Reference in new issue
Block a user