/** * PR-37 Part B — Domain Classifier * * Classifies product strategy descriptions into primary/secondary domains * by matching keywords against the domain registry. * * @module pr-37-domain-intelligence/domain-classifier * @since PR-37 */ import { getDomain, getDomainRegistry, searchDomains, } from "./domain-registry.mjs"; // ══════════════════════════════════════════════════════════ // Constants // ══════════════════════════════════════════════════════════ /** Score bonus when the domain name appears directly in input. */ const NAME_MATCH_BONUS = 10; /** Score per keyword matched. */ const KEYWORD_SCORE = 3; /** Score per alias matched. */ const ALIAS_SCORE = 5; /** Score per core-process phrase matched. */ const PROCESS_SCORE = 4; /** * Known cross-domain edges for specific Chinese phrases. * * When these phrases appear in input, they trigger additional domain-specific * boosts so that commonly-expected secondary domains surface correctly even * when no direct keyword match exists. * * @type {object>} */ const CROSS_MATCH_MAP = Object.freeze({ // 跨境电商 → E-Commerce primary, SCM secondary "跨境电商": [ { domain: "E-Commerce", boost: 20, reason: "Cross-border e-commerce" }, { domain: "SCM", boost: 18, reason: "Cross-border logistics / supply chain" }, ], "跨境": [ { domain: "E-Commerce", boost: 12, reason: "Cross-border trade" }, { domain: "SCM", boost: 14, reason: "Cross-border supply chain" }, ], // 工业AI质检 → MES primary, AI Platform secondary "工业ai质检": [ { domain: "MES", boost: 20, reason: "Industrial AI QA on factory floor" }, { domain: "AI Platform", boost: 14, reason: "AI/ML inspection component" }, ], "工业ai": [ { domain: "MES", boost: 12, reason: "Industrial AI / smart manufacturing" }, { domain: "AI Platform", boost: 8, reason: "AI capability" }, ], "智能质检": [ { domain: "MES", boost: 18, reason: "Smart QA on factory floor" }, { domain: "AI Platform", boost: 12, reason: "AI-powered inspection" }, ], "ai质检": [ { domain: "MES", boost: 12, reason: "AI quality inspection on production line" }, { domain: "AI Platform", boost: 10, reason: "AI vision / ML detection" }, ], }); /** * Terms that are too generic (e.g. "平台", "系统", "管理") and should be * excluded from keyword scoring to avoid inflating scores for unrelated domains. * * @type {Set} */ const STOPWORDS = new Set([ "平台", "系统", "管理", "服务", "引擎", "引擎", "平台", "在线", "智能", "数字", "自动", ]); /** * Category affinity matrix (lower is closer). * 1 = same category, 2 = related, 3 = unrelated (penalty). */ const CATEGORY_AFFINITY = Object.freeze({ "enterprise-software": { "enterprise-software": 1, industrial: 2, commerce: 2, platform: 2, intelligence: 2, engineering: 3, management: 2 }, industrial: { "enterprise-software": 2, industrial: 1, commerce: 3, platform: 3, intelligence: 2, engineering: 2, management: 3 }, commerce: { "enterprise-software": 2, industrial: 3, commerce: 1, platform: 2, intelligence: 2, engineering: 3, management: 2 }, platform: { "enterprise-software": 2, industrial: 3, commerce: 2, platform: 1, intelligence: 2, engineering: 3, management: 2 }, intelligence: { "enterprise-software": 2, industrial: 2, commerce: 2, platform: 2, intelligence: 1, engineering: 3, management: 2 }, engineering: { "enterprise-software": 3, industrial: 2, commerce: 3, platform: 3, intelligence: 3, engineering: 1, management: 3 }, management: { "enterprise-software": 2, industrial: 3, commerce: 2, platform: 2, intelligence: 2, engineering: 3, management: 1 }, }); // ══════════════════════════════════════════════════════════ // Internal helpers // ══════════════════════════════════════════════════════════ /** * Tokenize a string into lowercase keyword tokens. * * @param {string} text - Input text * @returns {{ tokens: string[], rawLower: string }} Tokenization result */ function tokenize(text) { if (!text || typeof text !== "string") return { tokens: [], rawLower: "" }; const rawLower = text.toLowerCase(); // Split on whitespace, punctuation const rawTokens = rawLower.split(/[\s,,。、;:;!?!?()()【】\[\]{}"''":·/\\\-–—]+/).filter(Boolean); const result = new Set(rawTokens); // Extract CJK bigrams for Chinese compound matching for (const tok of rawTokens) { if (/[\u4e00-\u9fff]/.test(tok) && tok.length >= 2) { result.add(tok); // Generate overlapping 2-char windows for compounds like "跨境电商" for (let i = 0; i <= tok.length - 2; i++) { const bigram = tok.substring(i, i + 2); // Only add meaningful CJK bigrams, not common stopwords if (!STOPWORDS.has(bigram)) result.add(bigram); } } } return { tokens: [...result], rawLower }; } /** * Check if a term is a stopword (too generic to match on). * * @param {string} term - The term to check * @returns {boolean} */ function isStopword(term) { const t = term.replace(/^(keyword:|alias:|domain:|process:)/, "").trim(); return STOPWORDS.has(t); } /** * Score a single domain against tokenized input. * * @param {object} domain - Domain definition object * @param {string[]} tokens - Tokenized input tokens * @param {string} rawLower - Original input text (lowered) * @returns {{ score: number, matchedKeywords: string[] }} */ function scoreDomain(domain, tokens, rawLower) { const matched = new Set(); /** Check if a term exists in the raw text (case-insensitive). */ const matchIfFound = (term) => { const t = typeof term === "string" ? term.toLowerCase() : ""; if (!t || isStopword(t)) return false; if (rawLower.includes(t)) return true; // Partial CJK match (e.g., keyword "电商" matches token "跨境电商") if (/[\u4e00-\u9fff]/.test(t)) { return tokens.some((tok) => tok.includes(t) || t.includes(tok)); } // English token check (e.g., "SCM") const normTok = t.replace(/[\s-]/g, ""); return tokens.some((tok) => tok === normTok || tok.includes(normTok) || normTok.includes(tok)); }; // 1. Name / displayName direct match if (rawLower.includes(domain.name.toLowerCase())) { matched.add(`domain:${domain.name}`); } // 2. Aliases for (const alias of domain.aliases) { if (matchIfFound(alias)) matched.add(`alias:${alias}`); } // 3. Keywords for (const kw of domain.keywords) { if (matchIfFound(kw)) matched.add(`keyword:${kw}`); } // 4. Core process phrases for (const proc of domain.coreProcesses) { const procPhrase = proc.replace(/\(.*?\)/g, "").trim().toLowerCase(); if (rawLower.includes(procPhrase)) { matched.add(`process:${procPhrase.substring(0, 20)}`); } else { for (const tok of tokens) { if (tok.length >= 2 && procPhrase.includes(tok)) { matched.add(`process:${procPhrase.substring(0, 20)}`); break; } } } } // Compute score let score = 0; for (const m of matched) { if (isStopword(m)) continue; if (m.startsWith("domain:")) score += NAME_MATCH_BONUS; else if (m.startsWith("alias:")) score += ALIAS_SCORE; else if (m.startsWith("keyword:")) score += KEYWORD_SCORE; else if (m.startsWith("process:")) score += PROCESS_SCORE; } return { score, matchedKeywords: [...matched].filter((m) => !isStopword(m)) }; } /** * Apply cross-match map boosts for well-known phrases. * Mutates scored array in-place. * * @param {Array<{name: string, score: number, matchedKeywords: string[], category: string}>} scored * @param {string} rawLower - Raw lowered input */ function applyCrossMatchBoosts(scored, rawLower) { const shortened = rawLower.replace(/[\s-]/g, ""); for (const [phrase, crossRefs] of Object.entries(CROSS_MATCH_MAP)) { const phraseNorm = phrase.toLowerCase().replace(/[\s-]/g, ""); if (shortened.includes(phraseNorm)) { for (const ref of crossRefs) { const existing = scored.find((s) => s.name === ref.domain); if (existing) { existing.score += ref.boost; existing.matchedKeywords.push(`cross-ref:${ref.reason}`); } else { // Auto-create entry for this domain if it doesn't exist yet scored.push({ name: ref.domain, score: ref.boost, matchedKeywords: [`cross-ref:${ref.reason}`], category: "cross-reference", }); } } } } } // ══════════════════════════════════════════════════════════ // Public API // ══════════════════════════════════════════════════════════ /** * Classify a product strategy into primary and secondary domains. * * @param {object|string} productInput - Product strategy package containing * `productName` / `description`, or a plain product name string * @returns {object} Classification result * @returns {string|null} .primaryDomain - Best-fit domain name * @returns {string|null} .secondaryDomain - Runner-up domain name (or null) * @returns {number} .confidenceScore - 0-1 confidence value * @returns {string[]} .matchedKeywords - All matched keyword references * @returns {string} .classificationReason - Human-readable explanation * * @example * classifyProduct("跨境电商平台") * // => { primaryDomain: "E-Commerce", secondaryDomain: "SCM", confidenceScore: 0.92, ... } * * @example * classifyProduct({ productName: "工业AI质检系统", description: "基于机器学习的AOI" }) * // => { primaryDomain: "MES", secondaryDomain: "AI Platform", ... } */ export function classifyProduct(productInput) { // Normalize input if (!productInput) { return { primaryDomain: null, secondaryDomain: null, confidenceScore: 0, matchedKeywords: [], classificationReason: "Empty input — no classification possible", }; } const productName = typeof productInput === "string" ? productInput : productInput.productName || ""; const description = typeof productInput === "string" ? productInput : productInput.description || productInput.productDescription || ""; const rawText = `${productName} ${description}`.trim(); if (!rawText) { return { primaryDomain: null, secondaryDomain: null, confidenceScore: 0, matchedKeywords: [], classificationReason: "No product name or description provided", }; } const { tokens, rawLower } = tokenize(rawText); const registry = getDomainRegistry(); const scored = []; for (const [name, domain] of Object.entries(registry)) { const { score, matchedKeywords } = scoreDomain(domain, tokens, rawLower); if (score > 0) { scored.push({ name, score, matchedKeywords, category: domain.category }); } } // Apply cross-match boosts for well-known edge cases applyCrossMatchBoosts(scored, rawLower); // Sort by score descending scored.sort((a, b) => b.score - a.score); if (scored.length === 0) { return { primaryDomain: null, secondaryDomain: null, confidenceScore: 0, matchedKeywords: [], classificationReason: "No domain matched the provided product description", }; } const primary = scored[0]; const secondaryCandidate = scored[1]; // Calculate confidence: normalize to 0-1 const maxObservedScore = scored[0].score; const maxExpectedScore = 60; const rawConfidence = Math.min(maxObservedScore / maxExpectedScore, 1.0); // If there's a clear gap between #1 and #2, boost confidence let confidence = rawConfidence; if ( scored.length >= 2 && primary.score > secondaryCandidate.score * 2 ) { confidence = Math.max(confidence, 0.75); } confidence = Math.round(Math.min(Math.max(confidence, 0), 1) * 100) / 100; // Secondary domain: only if score is positive and not the same as primary const secondary = secondaryCandidate && secondaryCandidate.score > 0 ? secondaryCandidate.name : null; // Build matched keywords (all unique) const allMatched = [...new Set(scored.flatMap((s) => s.matchedKeywords))]; // Build reason const reason = [ `Primary: ${primary.name} (score=${primary.score})`, secondary ? `Secondary: ${secondary} (score=${secondaryCandidate.score})` : null, `Confidence: ${confidence}`, `Matched: ${ allMatched.length > 0 ? allMatched.slice(0, 8).join(", ") : "none" }`, ...(allMatched.length > 8 ? [`... and ${allMatched.length - 8} more`] : []), ] .filter(Boolean) .join(" | "); return { primaryDomain: primary.name, secondaryDomain: secondary, confidenceScore: confidence, matchedKeywords: allMatched, classificationReason: reason, }; } /** * Classify and return only the domain name (convenience shortcut). * * @param {object|string} productInput - Product strategy input * @returns {string|null} Primary domain name or null */ export function classifyDomain(productInput) { return classifyProduct(productInput).primaryDomain; } /** * List all supported domain names. * * @returns {string[]} */ export function listSupportedDomains() { return Object.keys(getDomainRegistry()); } // ══════════════════════════════════════════════════════════ // Fuzzy classifier (lightweight n-gram matching) // ══════════════════════════════════════════════════════════ /** * Soft-match a phrase against domain descriptions using n-gram overlap. * * @param {string} text - Input text * @param {number} [threshold=0.15] - Minimum match ratio (lower = more permissive) * @returns {object[]} Sorted domain matches with relevance score */ export function fuzzyClassify(text, threshold = 0.15) { if (!text || typeof text !== "string" || !text.trim()) return []; const { tokens, rawLower } = tokenize(text.trim()); if (tokens.length === 0) return []; const tokensSet = new Set(tokens); const registry = getDomainRegistry(); const results = []; for (const [name, domain] of Object.entries(registry)) { const combinedText = [ domain.name, domain.displayName, ...domain.keywords, ...domain.aliases, domain.description, ] .join(" ") .toLowerCase(); const domainTokens = new Set( combinedText .split(/[\s,,。、;:;!?!?()()【】\[\]{}"''":·/\\\-–—]+/) .filter(Boolean) ); // Compute overlap: how many input tokens partially match domain tokens let overlap = 0; for (const tok of tokens) { if (tok.length < 2) continue; for (const dt of domainTokens) { const dtNorm = dt.replace(/[\s-]/g, ""); const tokNorm = tok.replace(/[\s-]/g, ""); if (dtNorm.includes(tokNorm) || tokNorm.includes(dtNorm)) { overlap++; break; } } } const union = new Set([...tokensSet, ...domainTokens]); const ratio = union.size > 0 ? overlap / union.size : 0; if (ratio >= threshold && overlap > 0) { results.push({ domain: name, relevance: Math.round(ratio * 100) / 100 }); } } return results.sort((a, b) => b.relevance - a.relevance); }