Files
2026-06-06 10:40:48 +08:00

457 lines
16 KiB
JavaScript

/**
* 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<string, Array<{domain: string, boost: number, reason: string}>>}
*/
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<string>}
*/
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);
}