Skip to main content
Glama
label-inference.ts11.5 kB
/** * Label Inference Engine * 标签推断引擎 - 基于 Issue 内容智能推断标签 */ import type { WorkflowConfig } from './workflow-config.js'; import { DEFAULT_LABEL_PREFIXES, getLabelPrefixes, matchLabel } from './workflow-config.js'; // ============ 类型定义 ============ export interface Issue { id: number; number: number; title: string; body: string; labels: Array<{ id: number; name: string }>; created_at: string; updated_at: string; comments?: number; } export interface InferenceResult<T> { value: T; confidence: number; reason: string; } export interface LabelInferenceResult { type: InferenceResult<string> | null; priority: InferenceResult<string> | null; areas: InferenceResult<string>[]; all: Array<{ label: string; confidence: number; reason: string }>; } // ============ 推断引擎 ============ /** * 标签推断引擎 */ export class LabelInferenceEngine { private config: WorkflowConfig; constructor(config: WorkflowConfig) { this.config = config; } /** * 对 Issue 进行完整的标签推断 */ inferAll(issue: Issue): LabelInferenceResult { const typeResult = this.inferType(issue); const priorityResult = this.inferPriority(issue, typeResult?.value); const areaResults = this.inferAreas(issue); // 收集所有推断结果 const all: Array<{ label: string; confidence: number; reason: string }> = []; const prefixes = getLabelPrefixes(this.config); if (typeResult && typeResult.confidence >= this.getConfidenceThreshold()) { all.push({ label: `${prefixes.type}${typeResult.value}`, confidence: typeResult.confidence, reason: typeResult.reason, }); } if (priorityResult && priorityResult.confidence >= this.getConfidenceThreshold()) { all.push({ label: `${prefixes.priority}${priorityResult.value}`, confidence: priorityResult.confidence, reason: priorityResult.reason, }); } for (const area of areaResults) { if (area.confidence >= this.getConfidenceThreshold()) { all.push({ label: `${prefixes.area}${area.value}`, confidence: area.confidence, reason: area.reason, }); } } return { type: typeResult, priority: priorityResult, areas: areaResults, all, }; } /** * 推断 Issue 类型 */ inferType(issue: Issue): InferenceResult<string> | null { const text = this.normalizeText(issue.title + ' ' + issue.body); const keywords = this.config.automation.label_inference.type_keywords; let bestMatch: InferenceResult<string> | null = null; let maxScore = 0; for (const [type, typeKeywords] of Object.entries(keywords)) { const { score, matchedKeywords } = this.calculateKeywordScore(text, typeKeywords); if (score > maxScore) { maxScore = score; bestMatch = { value: type, confidence: Math.min(score, 1.0), reason: `匹配关键词: ${matchedKeywords.join(', ')}`, }; } } // 如果没有匹配到,尝试从标题前缀推断 if (!bestMatch || maxScore < 0.3) { const prefixMatch = this.inferTypeFromPrefix(issue.title); if (prefixMatch) { return prefixMatch; } } return bestMatch; } /** * 推断 Issue 优先级 */ inferPriority(issue: Issue, inferredType?: string): InferenceResult<string> | null { const text = this.normalizeText(issue.title + ' ' + issue.body); const keywords = this.config.automation.label_inference.priority_keywords; // 安全问题强制 P0 if (inferredType === 'security') { return { value: 'P0', confidence: 1.0, reason: '安全问题自动标记为紧急', }; } // 根据关键词匹配 for (const [priority, priorityKeywords] of Object.entries(keywords)) { const { score, matchedKeywords } = this.calculateKeywordScore(text, priorityKeywords); if (score >= 0.3) { return { value: priority, confidence: Math.min(score + 0.2, 1.0), reason: `匹配关键词: ${matchedKeywords.join(', ')}`, }; } } // 默认优先级 const defaultPriority = this.config.automation.new_issue_defaults.default_priority; return { value: defaultPriority, confidence: 0.5, reason: '使用默认优先级', }; } /** * 推断领域标签 */ inferAreas(issue: Issue): InferenceResult<string>[] { const results: InferenceResult<string>[] = []; const text = this.normalizeText(issue.title + ' ' + issue.body); const patterns = this.config.automation.label_inference.area_patterns; if (!patterns) { return results; } for (const [area, areaPatterns] of Object.entries(patterns)) { for (const pattern of areaPatterns) { if (text.includes(pattern.toLowerCase())) { results.push({ value: area, confidence: 0.8, reason: `匹配路径模式: ${pattern}`, }); break; // 每个领域只匹配一次 } } } // 根据关键词推断领域 const areaKeywords: Record<string, string[]> = { api: ['api', 'endpoint', '接口', 'rest', 'graphql', 'grpc'], database: ['database', 'db', '数据库', 'sql', 'migration', 'model', '模型'], auth: ['auth', 'authentication', 'authorization', '认证', '授权', 'login', 'token', 'jwt'], ui: ['ui', 'component', '组件', 'button', 'form', 'modal', '界面'], performance: ['performance', '性能', 'slow', '慢', 'optimize', '优化', 'memory', 'cpu'], }; for (const [area, keywords] of Object.entries(areaKeywords)) { // 只推断配置中存在的领域 if (!this.config.labels.area || !this.config.labels.area[area]) { continue; } const { score, matchedKeywords } = this.calculateKeywordScore(text, keywords); if (score >= 0.3 && !results.some((r) => r.value === area)) { results.push({ value: area, confidence: score, reason: `匹配关键词: ${matchedKeywords.join(', ')}`, }); } } return results; } /** * 检查 Issue 是否缺少必要标签 */ checkMissingLabels(issue: Issue): string[] { const missing: string[] = []; const existingLabels = issue.labels.map((l) => l.name); const prefixes = getLabelPrefixes(this.config); // 检查类型标签 const hasTypeLabel = existingLabels.some((l) => matchLabel(prefixes.type, l) !== null); if (this.config.automation.new_issue_defaults.require_type_label && !hasTypeLabel) { missing.push('type'); } // 检查优先级标签 const hasPriorityLabel = existingLabels.some((l) => matchLabel(prefixes.priority, l) !== null); if (this.config.automation.new_issue_defaults.require_priority_label && !hasPriorityLabel) { missing.push('priority'); } // 检查状态标签 const hasStatusLabel = existingLabels.some((l) => matchLabel(prefixes.status, l) !== null); if (!hasStatusLabel) { missing.push('status'); } return missing; } /** * 获取推荐添加的标签 */ getRecommendedLabels(issue: Issue): Array<{ label: string; confidence: number; reason: string }> { const existingLabels = new Set(issue.labels.map((l) => l.name)); const inference = this.inferAll(issue); return inference.all.filter((item) => !existingLabels.has(item.label)); } // ============ 私有方法 ============ private normalizeText(text: string): string { return text.toLowerCase().trim(); } private getConfidenceThreshold(): number { return this.config.automation.label_inference.confidence_threshold; } private calculateKeywordScore( text: string, keywords: string[] ): { score: number; matchedKeywords: string[] } { const matchedKeywords: string[] = []; let matchCount = 0; for (const keyword of keywords) { const normalizedKeyword = keyword.toLowerCase(); // 使用词边界匹配,避免部分匹配 const regex = new RegExp(`\\b${this.escapeRegex(normalizedKeyword)}\\b|${this.escapeRegex(normalizedKeyword)}`, 'gi'); const matches = text.match(regex); if (matches) { matchCount += matches.length; matchedKeywords.push(keyword); } } // 计算分数:匹配数量 / 关键词数量,加权处理 const baseScore = matchedKeywords.length / keywords.length; const bonusScore = Math.min(matchCount * 0.1, 0.3); // 多次匹配加分 return { score: Math.min(baseScore + bonusScore, 1.0), matchedKeywords, }; } private escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } private inferTypeFromPrefix(title: string): InferenceResult<string> | null { const prefixPatterns: Array<{ pattern: RegExp; type: string }> = [ { pattern: /^(fix|bug|修复|修正)[\s::]/i, type: 'bug' }, { pattern: /^(feat|feature|新增|添加|实现)[\s::]/i, type: 'feature' }, { pattern: /^(docs|文档|doc)[\s::]/i, type: 'docs' }, { pattern: /^(refactor|重构)[\s::]/i, type: 'refactor' }, { pattern: /^(test|测试)[\s::]/i, type: 'test' }, { pattern: /^(security|安全)[\s::]/i, type: 'security' }, { pattern: /^(chore|杂项)[\s::]/i, type: 'refactor' }, { pattern: /^(perf|性能)[\s::]/i, type: 'refactor' }, ]; for (const { pattern, type } of prefixPatterns) { if (pattern.test(title)) { return { value: type, confidence: 0.9, reason: `标题前缀匹配: ${pattern.source}`, }; } } return null; } } // ============ 辅助函数 ============ /** * 计算 Issue 年龄(天数) */ export function calculateIssueAgeDays(issue: Issue): number { const created = new Date(issue.created_at); const now = new Date(); return Math.floor((now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24)); } /** * 计算距离上次更新的小时数 */ export function calculateHoursSinceUpdate(issue: Issue): number { const updated = new Date(issue.updated_at); const now = new Date(); return Math.floor((now.getTime() - updated.getTime()) / (1000 * 60 * 60)); } /** * 获取 Issue 的优先级标签 */ export function getIssuePriority(issue: Issue, prefixes = DEFAULT_LABEL_PREFIXES): string | null { const priorityLabel = issue.labels.find((l) => matchLabel(prefixes.priority, l.name) !== null); return priorityLabel ? matchLabel(prefixes.priority, priorityLabel.name) : null; } /** * 获取 Issue 的类型标签 */ export function getIssueType(issue: Issue, prefixes = DEFAULT_LABEL_PREFIXES): string | null { const typeLabel = issue.labels.find((l) => matchLabel(prefixes.type, l.name) !== null); return typeLabel ? matchLabel(prefixes.type, typeLabel.name) : null; } /** * 获取 Issue 的状态标签 */ export function getIssueStatus(issue: Issue, prefixes = DEFAULT_LABEL_PREFIXES): string | null { const statusLabel = issue.labels.find((l) => matchLabel(prefixes.status, l.name) !== null); return statusLabel ? matchLabel(prefixes.status, statusLabel.name) : null; } /** * 检查 Issue 是否有特定标签 */ export function hasLabel(issue: Issue, labelName: string): boolean { return issue.labels.some((l) => l.name === labelName); }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/SupenBysz/gitea-mcp-tool'

If you have feedback or need assistance with the MCP directory API, please join our Discord server