🎉 init: 小龙的工作空间

This commit is contained in:
大海
2026-06-06 10:40:48 +08:00
commit a188ee1426
3201 changed files with 231817 additions and 0 deletions
@@ -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 BE) 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;
}