🎉 init: 小龙的工作空间
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* PR-37 Part E — Domain Validator
|
||||
*
|
||||
* Validates a requirement package (epics, features, userStories) against
|
||||
* a domain from the registry, producing coverage, consistency, and
|
||||
* completeness scores, plus actionable recommendations.
|
||||
*
|
||||
* @module pr-37-domain-intelligence/domain-validator
|
||||
* @since PR-37
|
||||
*/
|
||||
|
||||
import { getDomain, listDomains } from "./domain-registry.mjs";
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Types / typedefs (JSDoc only)
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* @typedef {object} ValidationReport
|
||||
* @property {number} coverageScore - 0-100: % of domain epics covered by generated epics
|
||||
* @property {number} consistencyScore - 0-100: % of features matching domain expectations
|
||||
* @property {number} completenessScore - 0-100: % of domain coreEpics present
|
||||
* @property {string[]} missingEpics - Domain epics NOT present in generated set
|
||||
* @property {string[]} extraEpics - Generated epics NOT found in domain
|
||||
* @property {string[]} recommendations - Actionable suggestions
|
||||
* @property {"pass"|"conditional"|"fail"} overallVerdict - Final verdict
|
||||
*/
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Internal helpers
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Normalize text for fuzzy comparison.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalize(text) {
|
||||
return text.trim().toLowerCase().replace(/[\s\u3000]+/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a generated epic title matches a domain epic title.
|
||||
*
|
||||
* @param {string} generatedTitle - Title from generated epics
|
||||
* @param {object[]} domainEpics - Domain coreEpics array
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function matchesDomainEpic(generatedTitle, domainEpics) {
|
||||
const g = normalize(generatedTitle);
|
||||
return domainEpics.some(
|
||||
(de) =>
|
||||
normalize(de.title) === g ||
|
||||
normalize(de.title).includes(g) ||
|
||||
g.includes(normalize(de.title))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a generated feature title/name matches a domain feature name.
|
||||
*
|
||||
* @param {string} generatedFeatureTitle - Generated feature name
|
||||
* @param {object} domainFeatures - Domain coreFeatures object
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function matchesDomainFeature(generatedFeatureTitle, domainFeatures) {
|
||||
const g = normalize(generatedFeatureTitle);
|
||||
const allDomainFeatureNames = Object.values(domainFeatures).flat();
|
||||
return allDomainFeatureNames.some(
|
||||
(dfn) =>
|
||||
normalize(dfn) === g ||
|
||||
normalize(dfn).includes(g) ||
|
||||
g.includes(normalize(dfn))
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Public API
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Validate a requirement package against a target domain.
|
||||
*
|
||||
* @param {object} requirementPackage - The requirement package to validate
|
||||
* @param {object[]} [requirementPackage.epics] - Generated epics
|
||||
* @param {object[]} [requirementPackage.features] - Generated features
|
||||
* @param {object[]} [requirementPackage.userStories] - Generated user stories
|
||||
* @param {string} domainName - Target domain name for validation
|
||||
* @returns {ValidationReport}
|
||||
*
|
||||
* @example
|
||||
* const report = validateRequirements({ epics, features, userStories }, "MES");
|
||||
* // => { coverageScore: 80, consistencyScore: 75, completenessScore: 70, ... }
|
||||
*/
|
||||
export function validateRequirements(requirementPackage, domainName) {
|
||||
// ── Default empty result ──────────────────────────────
|
||||
const emptyResult = {
|
||||
coverageScore: 0,
|
||||
consistencyScore: 0,
|
||||
completenessScore: 0,
|
||||
missingEpics: [],
|
||||
extraEpics: [],
|
||||
recommendations: [],
|
||||
overallVerdict: "fail",
|
||||
};
|
||||
|
||||
if (!requirementPackage || typeof requirementPackage !== "object") {
|
||||
return {
|
||||
...emptyResult,
|
||||
recommendations: ["Requirement package is empty or invalid"],
|
||||
};
|
||||
}
|
||||
|
||||
if (!domainName) {
|
||||
return {
|
||||
...emptyResult,
|
||||
recommendations: ["No domain specified for validation"],
|
||||
};
|
||||
}
|
||||
|
||||
const domain = getDomain(domainName);
|
||||
if (!domain) {
|
||||
return {
|
||||
...emptyResult,
|
||||
recommendations: [
|
||||
`Domain "${domainName}" not found in registry. Available: ${listDomains().join(", ")}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Extract inputs with defaults
|
||||
const generatedEpics = requirementPackage.epics || [];
|
||||
const generatedFeatures = requirementPackage.features || [];
|
||||
const generatedUserStories = requirementPackage.userStories || [];
|
||||
|
||||
const domainEpics = domain.coreEpics || [];
|
||||
const domainFeatures = domain.coreFeatures || {};
|
||||
|
||||
// ══ 1. Completeness: % of domain coreEpics present ═══
|
||||
const coveredDomainTitles = domainEpics.filter((de) =>
|
||||
matchesDomainEpic(de.title, generatedEpics)
|
||||
);
|
||||
const missingEpics = domainEpics
|
||||
.filter((de) => !matchesDomainEpic(de.title, generatedEpics))
|
||||
.map((de) => de.title);
|
||||
|
||||
const completenessScore =
|
||||
domainEpics.length > 0
|
||||
? Math.round((coveredDomainTitles.length / domainEpics.length) * 100)
|
||||
: 0;
|
||||
|
||||
// ══ 2. Coverage: % of generated epics matching domain ═══
|
||||
const coveredGeneratedEpics = generatedEpics.filter((ge) =>
|
||||
matchesDomainEpic(ge.title, domainEpics)
|
||||
);
|
||||
const extraEpics = generatedEpics
|
||||
.filter((ge) => !matchesDomainEpic(ge.title, domainEpics))
|
||||
.map((ge) => ge.title);
|
||||
|
||||
const coverageScore =
|
||||
generatedEpics.length > 0
|
||||
? Math.round((coveredGeneratedEpics.length / generatedEpics.length) * 100)
|
||||
: 0;
|
||||
|
||||
// ══ 3. Consistency: features matching domain ════════
|
||||
const consistentFeatures = generatedFeatures.filter((gf) =>
|
||||
matchesDomainFeature(gf.title, domainFeatures)
|
||||
);
|
||||
const consistencyScore =
|
||||
generatedFeatures.length > 0
|
||||
? Math.round((consistentFeatures.length / generatedFeatures.length) * 100)
|
||||
: 100; // If no features, consider it consistent (trivially)
|
||||
|
||||
// ══ 4. Generate recommendations ═══════════════════════
|
||||
const recommendations = [];
|
||||
|
||||
if (missingEpics.length > 0) {
|
||||
recommendations.push(
|
||||
`Add missing epics from ${domain.displayName}: ${missingEpics.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (extraEpics.length > 0) {
|
||||
recommendations.push(
|
||||
`Remove or align extra epics not in ${domain.displayName}: ${extraEpics.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (consistencyScore < 70 && generatedFeatures.length > 0) {
|
||||
recommendations.push(
|
||||
`Feature consistency is low (${consistencyScore}%). Review feature titles against ${domain.displayName} core features.`
|
||||
);
|
||||
}
|
||||
|
||||
if (completenessScore < 50) {
|
||||
recommendations.push(
|
||||
`Completeness is critically low (${completenessScore}%). Consider increasing domain epic coverage for ${domain.displayName}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Recommendations for user stories (if available)
|
||||
if (generatedUserStories.length > 0 && generatedEpics.length > 0) {
|
||||
const storyCountPerEpic = generatedUserStories.filter((s) => s.epicId).length;
|
||||
if (storyCountPerEpic < generatedEpics.length) {
|
||||
recommendations.push(
|
||||
"Ensure each epic has at least one associated user story."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedEpics.length === 0) {
|
||||
recommendations.push(
|
||||
`No epics generated. Start by adding epics for ${domain.displayName}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!recommendations.length) {
|
||||
recommendations.push(
|
||||
`${domain.displayName} requirement package looks well-aligned with domain expectations.`
|
||||
);
|
||||
}
|
||||
|
||||
// ══ 5. Overall verdict ═══════════════════════════════
|
||||
const overallVerdict = computeVerdict(completenessScore, coverageScore, consistencyScore, missingEpics.length);
|
||||
|
||||
return {
|
||||
coverageScore,
|
||||
consistencyScore,
|
||||
completenessScore,
|
||||
missingEpics,
|
||||
extraEpics,
|
||||
recommendations,
|
||||
overallVerdict,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the overall verdict based on component scores.
|
||||
*
|
||||
* @param {number} completeness - Completeness score 0-100
|
||||
* @param {number} coverage - Coverage score 0-100
|
||||
* @param {number} consistency - Consistency score 0-100
|
||||
* @param {number} missingCount - Number of missing epics
|
||||
* @returns {"pass"|"conditional"|"fail"}
|
||||
*/
|
||||
function computeVerdict(completeness, coverage, consistency, missingCount) {
|
||||
// Fail: critically missing domain content
|
||||
if (completeness < 30 || missingCount >= 5) return "fail";
|
||||
|
||||
// Pass: all scores high
|
||||
if (completeness >= 80 && coverage >= 80 && consistency >= 80) return "pass";
|
||||
|
||||
// Conditional: some gaps but not critical
|
||||
if (completeness >= 30) return "conditional";
|
||||
|
||||
return "fail";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an epic array alone against a domain (lightweight).
|
||||
*
|
||||
* @param {object[]} epics - Generated epics
|
||||
* @param {string} domainName - Target domain
|
||||
* @returns {ValidationReport}
|
||||
*/
|
||||
export function validateEpics(epics, domainName) {
|
||||
return validateRequirements({ epics, features: [], userStories: [] }, domainName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate features alone against a domain (lightweight).
|
||||
*
|
||||
* @param {object[]} features - Generated features
|
||||
* @param {string} domainName - Target domain
|
||||
* @returns {object} Partial validation result with consistencyScore
|
||||
*/
|
||||
export function validateFeatures(features, domainName) {
|
||||
const result = validateRequirements(
|
||||
{ epics: [], features, userStories: [] },
|
||||
domainName
|
||||
);
|
||||
return {
|
||||
consistencyScore: result.consistencyScore,
|
||||
consistencyDetails: `Feature consistency with ${domainName}: ${result.consistencyScore}%`,
|
||||
recommendations: result.recommendations.filter((r) =>
|
||||
r.toLowerCase().includes("feature") || r.toLowerCase().includes("consistency")
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* PR-37 Domain Intelligence — Barrel Export
|
||||
*
|
||||
* Re-exports all modules (Parts B–E) and provides the `createDomainIntelligenceFactory`
|
||||
* factory function for pipeline integration.
|
||||
*
|
||||
* @module pr-37-domain-intelligence
|
||||
* @since PR-37
|
||||
*/
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Imports for factory function
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
import { classifyProduct } from "./domain-classifier.mjs";
|
||||
import { generateIndustryEpics } from "./industry-epic-generator.mjs";
|
||||
import { generateIndustryFeatures } from "./industry-feature-generator.mjs";
|
||||
import { validateRequirements } from "./domain-validator.mjs";
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Re-exports from Part B — Domain Classifier
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
export {
|
||||
classifyProduct,
|
||||
classifyDomain,
|
||||
listSupportedDomains,
|
||||
fuzzyClassify,
|
||||
} from "./domain-classifier.mjs";
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Re-exports from Part C — Industry Epic Generator
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
export {
|
||||
generateIndustryEpics,
|
||||
resetEpicCounter as resetEpicIdCounter,
|
||||
describeDomain,
|
||||
listDomainEpics,
|
||||
} from "./industry-epic-generator.mjs";
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Re-exports from Part D — Industry Feature Generator
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
export {
|
||||
generateIndustryFeatures,
|
||||
resetFeatureCounter as resetFeatureIdCounter,
|
||||
detectFeatureType,
|
||||
FEATURE_TYPE,
|
||||
} from "./industry-feature-generator.mjs";
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Re-exports from Part E — Domain Validator
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
export {
|
||||
validateRequirements,
|
||||
validateEpics,
|
||||
validateFeatures,
|
||||
} from "./domain-validator.mjs";
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Factory Function
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Factory output shape.
|
||||
*
|
||||
* @typedef {object} FactoryOutput
|
||||
* @property {object} classification - Result from classifyProduct
|
||||
* @property {object[]} epics - Generated epics
|
||||
* @property {object[]} features - Generated features
|
||||
* @property {object} validation - Validation report
|
||||
* @property {string[]} warnings - Any warnings encountered
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a domain intelligence factory that runs the full pipeline:
|
||||
* classification → epic generation → feature generation → validation.
|
||||
*
|
||||
* The returned object is compatible with the FactoryOutput contract used
|
||||
* by PLM pipeline orchestration.
|
||||
*
|
||||
* @param {object|string} strategyPackage - Product strategy (object or string)
|
||||
* @param {object} [options={}] - Additional options
|
||||
* @param {string} [options.domainOverride] - Skip classification and use this domain explicitly
|
||||
* @param {boolean} [options.includeValidation=true] - Whether to run validation
|
||||
* @returns {FactoryOutput} Complete domain intelligence output
|
||||
*
|
||||
* @example
|
||||
* const factory = createDomainIntelligenceFactory({ productName: "智能仓储WMS", description: "自动化仓库" });
|
||||
* console.log(factory.classification.primaryDomain); // "WMS"
|
||||
* console.log(factory.epics.length); // 7
|
||||
* console.log(factory.validation.overallVerdict); // "pass" | "conditional" | "fail"
|
||||
*
|
||||
* @example
|
||||
* // With explicit domain override
|
||||
* const factory = createDomainIntelligenceFactory({ productName: "My Product" }, { domainOverride: "ERP" });
|
||||
*/
|
||||
export function createDomainIntelligenceFactory(strategyPackage, options = {}) {
|
||||
const {
|
||||
domainOverride = null,
|
||||
includeValidation = true,
|
||||
} = options || {};
|
||||
|
||||
const warnings = [];
|
||||
|
||||
// ── 1. Classification ───────────────────────────────
|
||||
let classification;
|
||||
let domainName;
|
||||
|
||||
if (domainOverride) {
|
||||
domainName = domainOverride;
|
||||
classification = {
|
||||
primaryDomain: domainName,
|
||||
secondaryDomain: null,
|
||||
confidenceScore: 1.0,
|
||||
matchedKeywords: [],
|
||||
classificationReason: `Domain manually overridden to "${domainName}"`,
|
||||
};
|
||||
} else {
|
||||
classification = classifyProduct(strategyPackage);
|
||||
domainName = classification.primaryDomain;
|
||||
|
||||
if (!domainName) {
|
||||
return {
|
||||
classification,
|
||||
epics: [],
|
||||
features: [],
|
||||
validation: null,
|
||||
warnings: [
|
||||
"Could not classify product into any known domain. Provide a domainOverride or more specific product description.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (classification.confidenceScore < 0.4) {
|
||||
warnings.push(
|
||||
`Low confidence (${classification.confidenceScore}) for domain "${domainName}". Consider manual review or domainOverride.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Epic Generation ───────────────────────────────
|
||||
const epics = generateIndustryEpics(domainName, strategyPackage);
|
||||
|
||||
if (epics.length === 0) {
|
||||
warnings.push(
|
||||
`No epics generated for domain "${domainName}".`
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Feature Generation ────────────────────────────
|
||||
const features = generateIndustryFeatures(domainName, epics);
|
||||
|
||||
if (features.length === 0) {
|
||||
warnings.push(
|
||||
`No features generated for domain "${domainName}".`
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Validation ────────────────────────────────────
|
||||
let validation = null;
|
||||
if (includeValidation) {
|
||||
validation = validateRequirements(
|
||||
{ epics, features, userStories: [] },
|
||||
domainName
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
classification,
|
||||
epics,
|
||||
features,
|
||||
validation,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* PR-37 Part C — Industry Epic Generator
|
||||
*
|
||||
* Generates domain-specific epics for a given strategy package.
|
||||
* Replaces the generic epic generation in SF-02's strategy-to-requirement.mjs
|
||||
* with domain-aware templates from the registry.
|
||||
*
|
||||
* @module pr-37-domain-intelligence/industry-epic-generator
|
||||
* @since PR-37
|
||||
*/
|
||||
|
||||
import { getDomain, listDomains } from "./domain-registry.mjs";
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Internal: ID counter
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
let _epicCounter = 0;
|
||||
|
||||
/**
|
||||
* Reset the internal epic ID counter (useful in tests).
|
||||
*/
|
||||
export function resetEpicCounter() {
|
||||
_epicCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the next sequential epic ID.
|
||||
*
|
||||
* @returns {string} ID like "ep-001"
|
||||
*/
|
||||
function nextEpicId() {
|
||||
_epicCounter++;
|
||||
return `ep-${String(_epicCounter).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Generic fallback template
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Default generic epics used when no domain is found or for unknown domains.
|
||||
*
|
||||
* @type {object[]}
|
||||
*/
|
||||
const GENERIC_EPIC_TEMPLATES = Object.freeze([
|
||||
{
|
||||
title: "系统管理",
|
||||
category: "系统管理",
|
||||
objective: "基础系统配置与用户管理",
|
||||
successMetric: "系统可配置率 ≥ 90%",
|
||||
},
|
||||
{
|
||||
title: "数据管理",
|
||||
category: "数据管理",
|
||||
objective: "核心数据维护与管理",
|
||||
successMetric: "数据完整率 ≥ 95%",
|
||||
},
|
||||
{
|
||||
title: "用户管理",
|
||||
category: "用户管理",
|
||||
objective: "用户身份与权限管控",
|
||||
successMetric: "用户管理效率提升 50%",
|
||||
},
|
||||
{
|
||||
title: "流程管理",
|
||||
category: "流程管理",
|
||||
objective: "核心业务流程数字化",
|
||||
successMetric: "流程处理时间缩短 40%",
|
||||
},
|
||||
{
|
||||
title: "报表分析",
|
||||
category: "报表分析",
|
||||
objective: "多维度数据分析与报表",
|
||||
successMetric: "报表生成时间 < 30s",
|
||||
},
|
||||
{
|
||||
title: "安全管理",
|
||||
category: "安全管理",
|
||||
objective: "数据安全与合规管控",
|
||||
successMetric: "安全事件为零",
|
||||
},
|
||||
{
|
||||
title: "接口集成",
|
||||
category: "接口集成",
|
||||
objective: "系统间数据集成与同步",
|
||||
successMetric: "集成成功率 ≥ 99.5%",
|
||||
},
|
||||
{
|
||||
title: "运维管理",
|
||||
category: "运维管理",
|
||||
objective: "系统运维与监控",
|
||||
successMetric: "系统可用性 ≥ 99.9%",
|
||||
},
|
||||
]);
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Public API
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Generate industry-specific epics from a strategy package for a given domain.
|
||||
*
|
||||
* @param {string} domainName - Target domain (e.g., "ERP", "MES", "CRM")
|
||||
* @param {object|string} strategyPackage - Strategy context; can be:
|
||||
* - An object with `productName`, `description`, and/or `goals`
|
||||
* - A plain string used as product name
|
||||
* @returns {object[]} Array of epic objects, each with:
|
||||
* - id (string): "ep-xxx"
|
||||
* - title (string): Epic title in Chinese
|
||||
* - description (string): Context-enriched description
|
||||
* - category (string): Category grouping
|
||||
* - objective (string): What this epic aims to achieve
|
||||
* - successMetric (string): Measurable success criterion
|
||||
*
|
||||
* @example
|
||||
* generateIndustryEpics("MES", { productName: "智能车间MES", description: "半导体封测", goals: ["良率提升"] })
|
||||
* // => [ { id: "ep-001", title: "生产执行", ... }, ... ]
|
||||
*/
|
||||
export function generateIndustryEpics(domainName, strategyPackage) {
|
||||
// Normalize strategyPackage
|
||||
const strategyName =
|
||||
typeof strategyPackage === "string"
|
||||
? strategyPackage
|
||||
: strategyPackage?.productName || "";
|
||||
|
||||
const strategyDesc =
|
||||
typeof strategyPackage === "string"
|
||||
? strategyPackage
|
||||
: strategyPackage?.description || "";
|
||||
|
||||
const strategyGoals =
|
||||
typeof strategyPackage === "object" && !Array.isArray(strategyPackage)
|
||||
? strategyPackage.goals || []
|
||||
: [];
|
||||
|
||||
const domain = getDomain(domainName);
|
||||
|
||||
if (!domain) {
|
||||
// Fall back to generic template
|
||||
return generateGenericEpics(domainName, strategyName, strategyDesc, strategyGoals);
|
||||
}
|
||||
|
||||
// Use domain's coreEpics as templates
|
||||
return domain.coreEpics.map((template) => {
|
||||
const enriched = enrichEpic(template, domain, strategyName, strategyDesc, strategyGoals);
|
||||
return {
|
||||
id: nextEpicId(),
|
||||
...enriched,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Internal helpers
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Enrich a template epic with strategy context.
|
||||
*
|
||||
* @param {object} template - Core epic template { title, category, objective, successMetric }
|
||||
* @param {object} domain - Domain definition
|
||||
* @param {string} strategyName - Product strategy name
|
||||
* @param {string} strategyDesc - Strategy description
|
||||
* @param {string[]} strategyGoals - Strategy goals
|
||||
* @returns {object} Enriched epic
|
||||
*/
|
||||
function enrichEpic(template, domain, strategyName, strategyDesc, strategyGoals) {
|
||||
const title = template.title;
|
||||
const category = template.category;
|
||||
|
||||
// Build a contextual description
|
||||
const contextParts = [
|
||||
`基于 ${domain.displayName} (${domain.name}) 领域`,
|
||||
strategyName ? `为 "${strategyName}" 产品` : "",
|
||||
`实现 ${template.objective}`,
|
||||
];
|
||||
|
||||
// Add strategy goals context if applicable
|
||||
if (strategyGoals.length > 0) {
|
||||
contextParts.push(`目标: ${strategyGoals.join("、")}`);
|
||||
}
|
||||
|
||||
// Add any relevant process context
|
||||
const relatedProcesses = domain.coreProcesses.filter((p) =>
|
||||
p.toLowerCase().includes(title.slice(0, 4).toLowerCase())
|
||||
);
|
||||
if (relatedProcesses.length > 0) {
|
||||
contextParts.push(`相关流程: ${relatedProcesses.join("; ")}`);
|
||||
}
|
||||
|
||||
const description = contextParts.filter(Boolean).join("。") + "。";
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
objective: template.objective,
|
||||
successMetric: strategyGoals.length > 0 ? enrichMetric(template.successMetric, strategyGoals) : template.successMetric,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally enrich a success metric with strategy goals.
|
||||
*
|
||||
* @param {string} metric - Original success metric
|
||||
* @param {string[]} goals - Strategy goals
|
||||
* @returns {string}
|
||||
*/
|
||||
function enrichMetric(metric, goals) {
|
||||
const extraGoals = goals
|
||||
.filter((g) => !metric.toLowerCase().includes(g.toLowerCase()))
|
||||
.slice(0, 2);
|
||||
if (extraGoals.length === 0) return metric;
|
||||
return `${metric},同时支撑 ${extraGoals.join("、")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate generic epics for unknown domains.
|
||||
*
|
||||
* @param {string} domainName - Original domain name (may be null/unknown)
|
||||
* @param {string} strategyName - Product strategy name
|
||||
* @param {string} strategyDesc - Strategy description
|
||||
* @param {string[]} strategyGoals - Strategy goals
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function generateGenericEpics(domainName, strategyName, strategyDesc, strategyGoals) {
|
||||
const domainLabel = domainName || "通用系统";
|
||||
const prefix = strategyName ? `${strategyName} ` : "";
|
||||
|
||||
return GENERIC_EPIC_TEMPLATES.map((template) => ({
|
||||
id: nextEpicId(),
|
||||
title: template.title,
|
||||
description: `${prefix}${domainLabel}领域 — ${template.objective}。${
|
||||
strategyDesc ? `背景: ${strategyDesc}。` : ""
|
||||
}${strategyGoals.length > 0 ? `目标: ${strategyGoals.join("、")}。` : ""}`,
|
||||
category: template.category,
|
||||
objective: template.objective,
|
||||
successMetric: template.successMetric,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a domain briefly (utility function).
|
||||
*
|
||||
* @param {string} domainName - Domain name
|
||||
* @returns {string} Brief domain description
|
||||
*/
|
||||
export function describeDomain(domainName) {
|
||||
const domain = getDomain(domainName);
|
||||
if (!domain) return `${domainName || "Unknown"}: domain not found in registry`;
|
||||
return `${domain.displayName} (${domain.name}): ${domain.description}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* List epic titles available for a given domain.
|
||||
*
|
||||
* @param {string} domainName - Domain name
|
||||
* @returns {string[]} Epic title array, or generic titles if domain unknown
|
||||
*/
|
||||
export function listDomainEpics(domainName) {
|
||||
const domain = getDomain(domainName);
|
||||
if (!domain) return GENERIC_EPIC_TEMPLATES.map((e) => e.title);
|
||||
return domain.coreEpics.map((e) => e.title);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* PR-37 Part D — Industry Feature Generator
|
||||
*
|
||||
* Generates domain-specific Feature objects for each epic, using the
|
||||
* domain registry's `coreFeatures` as a template. Falls back to generic
|
||||
* feature generation for unknown domains or epics without registered features.
|
||||
*
|
||||
* @module pr-37-domain-intelligence/industry-feature-generator
|
||||
* @since PR-37
|
||||
*/
|
||||
|
||||
import { getDomain } from "./domain-registry.mjs";
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Internal: ID counter
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
let _featureCounter = 0;
|
||||
|
||||
/**
|
||||
* Reset the internal feature ID counter (useful in tests).
|
||||
*/
|
||||
export function resetFeatureCounter() {
|
||||
_featureCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the next sequential feature ID.
|
||||
*
|
||||
* @returns {string} ID like "ft-001"
|
||||
*/
|
||||
function nextFeatureId() {
|
||||
_featureCounter++;
|
||||
return `ft-${String(_featureCounter).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Feature type auto-detection
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Feature type constants.
|
||||
*
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const FEATURE_TYPE = Object.freeze({
|
||||
CRUD: "CRUD",
|
||||
WORKFLOW: "workflow",
|
||||
ANALYTICS: "analytics",
|
||||
CONFIGURATION: "configuration",
|
||||
INTEGRATION: "integration",
|
||||
SEARCH: "search",
|
||||
NOTIFICATION: "notification",
|
||||
});
|
||||
|
||||
/**
|
||||
* Keywords that hint at each feature type.
|
||||
*
|
||||
* @type {object<string, RegExp[]>}
|
||||
*/
|
||||
const TYPE_PATTERNS = Object.freeze({
|
||||
[FEATURE_TYPE.ANALYTICS]: [/分析/i, /报表/i, /看板/i, /统计/i, /趋势/i, /洞察/i, /仪表盘/i, /report/i, /dashboard/i, /analysis/i],
|
||||
[FEATURE_TYPE.CONFIGURATION]: [/配置/i, /参数/i, /设置/i, /规则/i, /策略/i, /模板/i, /setting/i, /config/i],
|
||||
[FEATURE_TYPE.INTEGRATION]: [/集成/i, /接口/i, /对接/i, /同步/i, /API/i, /导入/i, /导出/i, /import/i, /export/i, /webhook/i],
|
||||
[FEATURE_TYPE.SEARCH]: [/搜索/i, /检索/i, /查询/i, /查找/i, /search/i, /query/i],
|
||||
[FEATURE_TYPE.NOTIFICATION]: [/通知/i, /消息/i, /告警/i, /提醒/i, /预警/i, /notification/i, /alert/i, /remind/i],
|
||||
[FEATURE_TYPE.WORKFLOW]: [/流程/i, /审批/i, /审核/i, /流转/i, /工单/i, /workflow/i, /approval/i, /process/i],
|
||||
[FEATURE_TYPE.CRUD]: [/管理/i, /维护/i, /档案/i, /台账/i, /登记/i, /录入/i, /编辑/i, /创建/i],
|
||||
});
|
||||
|
||||
/**
|
||||
* Auto-detect the feature type based on the feature title/description.
|
||||
*
|
||||
* @param {string} title - Feature title
|
||||
* @param {string} [description=""] - Feature description
|
||||
* @returns {string} Detected feature type
|
||||
*/
|
||||
export function detectFeatureType(title, description = "") {
|
||||
const text = `${title} ${description}`;
|
||||
|
||||
// Check patterns in priority order (CRUD last as fallback)
|
||||
const ordered = [
|
||||
FEATURE_TYPE.ANALYTICS,
|
||||
FEATURE_TYPE.CONFIGURATION,
|
||||
FEATURE_TYPE.INTEGRATION,
|
||||
FEATURE_TYPE.SEARCH,
|
||||
FEATURE_TYPE.NOTIFICATION,
|
||||
FEATURE_TYPE.WORKFLOW,
|
||||
FEATURE_TYPE.CRUD,
|
||||
];
|
||||
|
||||
for (const type of ordered) {
|
||||
const patterns = TYPE_PATTERNS[type];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(text)) return type;
|
||||
}
|
||||
}
|
||||
|
||||
// Default
|
||||
return FEATURE_TYPE.CRUD;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Public API
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Generate features for a set of epics within a given domain.
|
||||
*
|
||||
* @param {string} domainName - Target domain (e.g., "ERP", "MES", "CRM")
|
||||
* @param {object[]} epics - Array of epic objects (each must have at least `title`)
|
||||
* @returns {object[]} Array of feature objects, each with:
|
||||
* - id (string): "ft-xxx"
|
||||
* - title (string): Feature title
|
||||
* - description (string): Feature description
|
||||
* - epicId (string): ID of the parent epic
|
||||
* - featureType (string): Auto-detected feature type
|
||||
*
|
||||
* @example
|
||||
* const epics = generateIndustryEpics("WMS", "智能仓储");
|
||||
* const features = generateIndustryFeatures("WMS", epics);
|
||||
* // => [ { id: "ft-001", title: "ASN收货", description: "...", epicId: "ep-001", featureType: "workflow" }, ... ]
|
||||
*/
|
||||
export function generateIndustryFeatures(domainName, epics) {
|
||||
if (!domainName || !epics || !Array.isArray(epics) || epics.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const domain = getDomain(domainName);
|
||||
const domainFeatures = domain?.coreFeatures || {};
|
||||
const allFeatures = [];
|
||||
|
||||
for (const epic of epics) {
|
||||
const { id: epicId, title: epicTitle } = epic;
|
||||
|
||||
// Look up domain-specific features for this epic title
|
||||
const featureNames = domainFeatures[epicTitle];
|
||||
|
||||
if (featureNames && Array.isArray(featureNames) && featureNames.length > 0) {
|
||||
// Domain-specific features
|
||||
for (const name of featureNames) {
|
||||
allFeatures.push({
|
||||
id: nextFeatureId(),
|
||||
title: name,
|
||||
description: buildFeatureDescription(name, epicTitle, domain),
|
||||
epicId,
|
||||
featureType: detectFeatureType(name, epicTitle),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback: generate generic features for this epic
|
||||
const generic = generateGenericFeaturesForEpic(epicTitle, epicId, domain);
|
||||
allFeatures.push(...generic);
|
||||
}
|
||||
}
|
||||
|
||||
return allFeatures;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Internal helpers
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Build a meaningful description for a domain-specific feature.
|
||||
*
|
||||
* @param {string} featureName - Feature name
|
||||
* @param {string} epicTitle - Parent epic title
|
||||
* @param {object|null} domain - Domain definition (may be null)
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildFeatureDescription(featureName, epicTitle, domain) {
|
||||
const domainLabel = domain
|
||||
? `${domain.displayName} (${domain.name})`
|
||||
: "系统";
|
||||
return `${domainLabel} — ${epicTitle} 模块下的「${featureName}」功能,支持相关业务操作与数据管理。`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate generic features for an epic when no domain-specific features exist.
|
||||
*
|
||||
* @param {string} epicTitle - Epic title
|
||||
* @param {string} epicId - Epic ID
|
||||
* @param {object|null} domain - Domain definition (may be null)
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function generateGenericFeaturesForEpic(epicTitle, epicId, domain) {
|
||||
const domainLabel = domain
|
||||
? `${domain.displayName} (${domain.name})`
|
||||
: "系统";
|
||||
|
||||
// Build generic CRUD + basic features
|
||||
const genericNames = detectGenericFeatures(epicTitle);
|
||||
|
||||
return genericNames.map((name) => {
|
||||
const type = detectFeatureType(name, epicTitle);
|
||||
return {
|
||||
id: nextFeatureId(),
|
||||
title: name,
|
||||
description: `${domainLabel} — ${epicTitle} 模块提供「${name}」功能,支持相关业务数据的维护与管理。`,
|
||||
epicId,
|
||||
featureType: type,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect sensible generic feature names for an epic based on its title.
|
||||
*
|
||||
* @param {string} epicTitle - Epic title
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function detectGenericFeatures(epicTitle) {
|
||||
const titleLower = epicTitle.toLowerCase();
|
||||
|
||||
const featureMap = {
|
||||
// Match based on common patterns
|
||||
default: [
|
||||
`${epicTitle}数据维护`,
|
||||
`${epicTitle}配置管理`,
|
||||
`${epicTitle}报表`,
|
||||
],
|
||||
};
|
||||
|
||||
// Add analytics for most epics
|
||||
const hasAnalytics = /分析|报表|统计|看板|dashboard|report/i.test(titleLower);
|
||||
if (hasAnalytics) {
|
||||
return [
|
||||
`${epicTitle}配置`,
|
||||
`${epicTitle}监控`,
|
||||
`${epicTitle}趋势分析`,
|
||||
`${epicTitle}导出`,
|
||||
];
|
||||
}
|
||||
|
||||
return featureMap.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add domain-specific features for an epic by keyword matching.
|
||||
* This is a fallback enrichment when coreFeatures doesn't have a direct entry
|
||||
* but the epic title matches domain terminology.
|
||||
*
|
||||
* @param {string} title - Epic title
|
||||
* @param {object} domain - Domain definition
|
||||
* @returns {string[]|null} Matched feature names or null
|
||||
*/
|
||||
export function matchDomainFeaturesByKeyword(title, domain) {
|
||||
if (!domain || !domain.coreFeatures) return null;
|
||||
|
||||
// Try partial title matching against registered epic titles
|
||||
const matchedEpicKey = Object.keys(domain.coreFeatures).find((key) =>
|
||||
title.toLowerCase().includes(key.toLowerCase()) ||
|
||||
key.toLowerCase().includes(title.toLowerCase())
|
||||
);
|
||||
|
||||
if (matchedEpicKey) {
|
||||
return domain.coreFeatures[matchedEpicKey];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user