| import {writeFileSync, readFileSync, existsSync, mkdirSync} from 'fs' |
| import {join} from 'path' |
| import {minimatch} from 'minimatch' |
| import { |
| PatternFeedback, |
| PatternStats, |
| TeamPatternInsights, |
| FeedbackQuery, |
| FeedbackSummary, |
| FeedbackType, |
| PatternCategory |
| } from './feedback-types' |
|
|
| const info = console.log |
|
|
| function getPersistBaseDir(): string { |
| if (process.env.PRIX_DATA_DIR) return process.env.PRIX_DATA_DIR |
| if (process.env.HF_HOME) return join(process.env.HF_HOME, 'prix-data') |
| if (process.env.HUGGING_FACE_HUB_TOKEN) return '/tmp/prix-hf' |
| return '/tmp/prix' |
| } |
|
|
| export class FeedbackTracker { |
| private feedbackStore: PatternFeedback[] = [] |
| private teamInsights: Map<string, TeamPatternInsights> = new Map() |
| private readonly persistPath: string |
| private readonly maxStoreSize = 10000 |
| private initialized = false |
|
|
| constructor(persistDir?: string) { |
| const baseDir = persistDir || getPersistBaseDir() |
| this.persistPath = join(baseDir, 'feedback') |
| this.ensureStorageDir() |
| this.loadPersisted() |
| } |
|
|
| private ensureStorageDir(): void { |
| try { |
| if (!existsSync(this.persistPath)) { |
| mkdirSync(this.persistPath, {recursive: true}) |
| } |
| } catch (e) { |
| info(`Failed to create feedback storage dir: ${e}`) |
| } |
| } |
|
|
| private loadPersisted(): void { |
| try { |
| const feedbackFile = join(this.persistPath, 'feedback.json') |
| if (existsSync(feedbackFile)) { |
| const data = JSON.parse(readFileSync(feedbackFile, 'utf8')) |
| if (Array.isArray(data)) { |
| this.feedbackStore = data.slice(-this.maxStoreSize) |
| this.rebuildTeamInsights() |
| this.initialized = true |
| info(`Loaded ${this.feedbackStore.length} feedback entries`) |
| } |
| } |
| } catch (e) { |
| info(`Failed to load feedback data: ${e}`) |
| } |
| } |
|
|
| private persist(): void { |
| try { |
| const feedbackFile = join(this.persistPath, 'feedback.json') |
| writeFileSync( |
| feedbackFile, |
| JSON.stringify(this.feedbackStore.slice(-this.maxStoreSize)) |
| ) |
| } catch (e) { |
| info(`Failed to persist feedback: ${e}`) |
| } |
| } |
|
|
| private rebuildTeamInsights(): void { |
| const teamMap = new Map<string, PatternFeedback[]>() |
|
|
| for (const fb of this.feedbackStore) { |
| const key = `${fb.teamId}:${fb.repoSlug}` |
| if (!teamMap.has(key)) { |
| teamMap.set(key, []) |
| } |
| teamMap.get(key)!.push(fb) |
| } |
|
|
| for (const [key, feedbacks] of teamMap) { |
| const [teamId, repoSlug] = key.split(':') |
| const insights = this.computeInsights(teamId, repoSlug, feedbacks) |
| this.teamInsights.set(key, insights) |
| } |
| } |
|
|
| private computeInsights( |
| teamId: string, |
| repoSlug: string, |
| feedbacks: PatternFeedback[] |
| ): TeamPatternInsights { |
| const patternMap = new Map<PatternCategory, PatternFeedback[]>() |
|
|
| for (const fb of feedbacks) { |
| if (!patternMap.has(fb.patternType)) { |
| patternMap.set(fb.patternType, []) |
| } |
| patternMap.get(fb.patternType)!.push(fb) |
| } |
|
|
| const patternStats = new Map<PatternCategory, PatternStats>() |
|
|
| for (const [patternType, pFeedbacks] of patternMap) { |
| const accepted = pFeedbacks.filter( |
| f => f.feedbackType === 'accepted' |
| ).length |
| const rejected = pFeedbacks.filter( |
| f => f.feedbackType === 'rejected' |
| ).length |
| const ignored = pFeedbacks.filter( |
| f => f.feedbackType === 'ignored' |
| ).length |
| const total = pFeedbacks.length |
| const avgConfidence = |
| pFeedbacks.reduce((sum, f) => sum + f.confidence, 0) / total |
|
|
| patternStats.set(patternType, { |
| patternType, |
| total, |
| accepted, |
| rejected, |
| ignored, |
| acceptanceRate: total > 0 ? accepted / total : 0, |
| avgConfidence |
| }) |
| } |
|
|
| const allAccepted = feedbacks.filter( |
| f => f.feedbackType === 'accepted' |
| ).length |
| const overallAcceptanceRate = |
| feedbacks.length > 0 ? allAccepted / feedbacks.length : 0 |
|
|
| return { |
| teamId, |
| repoSlug, |
| patternStats, |
| overallAcceptanceRate, |
| lastUpdated: Date.now() |
| } |
| } |
|
|
| recordFeedback(feedback: Omit<PatternFeedback, 'id' | 'timestamp'>): string { |
| const id = `fb_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` |
|
|
| const fullFeedback: PatternFeedback = { |
| ...feedback, |
| id, |
| timestamp: Date.now() |
| } |
|
|
| this.feedbackStore.push(fullFeedback) |
|
|
| if (this.feedbackStore.length > this.maxStoreSize) { |
| this.feedbackStore = this.feedbackStore.slice(-this.maxStoreSize) |
| } |
|
|
| const key = `${feedback.teamId}:${feedback.repoSlug}` |
| const existing = this.teamInsights.get(key) |
| if (existing) { |
| const updatedFeedbacks = [ |
| ...this.feedbackStore.filter( |
| f => f.teamId === feedback.teamId && f.repoSlug === feedback.repoSlug |
| ) |
| ] |
| const insights = this.computeInsights( |
| feedback.teamId, |
| feedback.repoSlug, |
| updatedFeedbacks |
| ) |
| this.teamInsights.set(key, insights) |
| } |
|
|
| this.persist() |
| return id |
| } |
|
|
| recordAcceptance( |
| patternType: PatternCategory, |
| severity: 'critical' | 'major' | 'minor' | 'info', |
| filename: string, |
| repoSlug: string, |
| teamId: string, |
| confidence: number, |
| commentId?: string, |
| reviewId?: string |
| ): string { |
| return this.recordFeedback({ |
| patternType, |
| severity, |
| filenamePattern: filename, |
| repoSlug, |
| teamId, |
| confidence, |
| feedbackType: 'accepted', |
| commentId, |
| reviewId |
| }) |
| } |
|
|
| recordRejection( |
| patternType: PatternCategory, |
| severity: 'critical' | 'major' | 'minor' | 'info', |
| filename: string, |
| repoSlug: string, |
| teamId: string, |
| confidence: number, |
| commentId?: string, |
| reviewId?: string |
| ): string { |
| return this.recordFeedback({ |
| patternType, |
| severity, |
| filenamePattern: filename, |
| repoSlug, |
| teamId, |
| confidence, |
| feedbackType: 'rejected', |
| commentId, |
| reviewId |
| }) |
| } |
|
|
| recordIgnored( |
| patternType: PatternCategory, |
| severity: 'critical' | 'major' | 'minor' | 'info', |
| filename: string, |
| repoSlug: string, |
| teamId: string, |
| confidence: number, |
| commentId?: string, |
| reviewId?: string |
| ): string { |
| return this.recordFeedback({ |
| patternType, |
| severity, |
| filenamePattern: filename, |
| repoSlug, |
| teamId, |
| confidence, |
| feedbackType: 'ignored', |
| commentId, |
| reviewId |
| }) |
| } |
|
|
| getPatternAcceptanceRate( |
| patternType: PatternCategory, |
| teamId: string, |
| repoSlug: string |
| ): number { |
| const key = `${teamId}:${repoSlug}` |
| const insights = this.teamInsights.get(key) |
| if (!insights) return 0.5 |
|
|
| const stats = insights.patternStats.get(patternType) |
| if (!stats) return 0.5 |
|
|
| if (stats.total < 5) return 0.5 |
|
|
| return stats.acceptanceRate |
| } |
|
|
| getFilenameAcceptanceRate( |
| filenamePattern: string, |
| teamId: string, |
| repoSlug: string |
| ): number { |
| const feedbacks = this.feedbackStore.filter( |
| f => |
| f.teamId === teamId && |
| f.repoSlug === repoSlug && |
| f.feedbackType !== 'ignored' |
| ) |
|
|
| if (feedbacks.length === 0) return 0.5 |
|
|
| const matching = feedbacks.filter( |
| f => |
| minimatch(f.filenamePattern, filenamePattern) || |
| minimatch(filenamePattern, f.filenamePattern) |
| ) |
| if (matching.length === 0) return 0.5 |
|
|
| const accepted = matching.filter(f => f.feedbackType === 'accepted').length |
| return accepted / matching.length |
| } |
|
|
| getTeamInsights( |
| teamId: string, |
| repoSlug: string |
| ): TeamPatternInsights | null { |
| const key = `${teamId}:${repoSlug}` |
| return this.teamInsights.get(key) || null |
| } |
|
|
| getAdjustedConfidence( |
| baseConfidence: number, |
| patternType: PatternCategory, |
| filename: string, |
| teamId: string, |
| repoSlug: string |
| ): number { |
| const patternRate = this.getPatternAcceptanceRate( |
| patternType, |
| teamId, |
| repoSlug |
| ) |
| const filenameRate = this.getFilenameAcceptanceRate( |
| filename, |
| teamId, |
| repoSlug |
| ) |
|
|
| const feedbackWeight = 0.3 |
| const teamRate = patternRate * 0.6 + filenameRate * 0.4 |
|
|
| const adjustedConfidence = |
| baseConfidence * (1 - feedbackWeight) + teamRate * 100 * feedbackWeight |
|
|
| return Math.max(0, Math.min(100, adjustedConfidence)) |
| } |
|
|
| shouldSuppressPattern( |
| patternType: PatternCategory, |
| teamId: string, |
| repoSlug: string, |
| threshold = 0.2, |
| minSamples = 10 |
| ): boolean { |
| const key = `${teamId}:${repoSlug}` |
| const insights = this.teamInsights.get(key) |
| if (!insights) return false |
|
|
| const stats = insights.patternStats.get(patternType) |
| if (!stats || stats.total < minSamples) return false |
|
|
| return stats.acceptanceRate < threshold |
| } |
|
|
| getQuery(query: FeedbackQuery): PatternFeedback[] { |
| let results = [...this.feedbackStore] |
|
|
| if (query.teamId) { |
| results = results.filter(f => f.teamId === query.teamId) |
| } |
| if (query.repoSlug) { |
| results = results.filter(f => f.repoSlug === query.repoSlug) |
| } |
| if (query.patternType) { |
| results = results.filter(f => f.patternType === query.patternType) |
| } |
| if (query.filenamePattern) { |
| results = results.filter(f => |
| minimatch(f.filenamePattern, query.filenamePattern!) |
| ) |
| } |
| if (query.since) { |
| results = results.filter(f => f.timestamp >= query.since!) |
| } |
|
|
| if (query.limit) { |
| results = results.slice(-query.limit) |
| } |
|
|
| return results |
| } |
|
|
| getSummary(teamId?: string, repoSlug?: string): FeedbackSummary { |
| let feedbacks = [...this.feedbackStore] |
|
|
| if (teamId) { |
| feedbacks = feedbacks.filter(f => f.teamId === teamId) |
| } |
| if (repoSlug) { |
| feedbacks = feedbacks.filter(f => f.repoSlug === repoSlug) |
| } |
|
|
| const patternMap = new Map<PatternCategory, PatternFeedback[]>() |
| const repoMap = new Map<string, number>() |
| const teamMap = new Map<string, number>() |
|
|
| for (const fb of feedbacks) { |
| if (!patternMap.has(fb.patternType)) { |
| patternMap.set(fb.patternType, []) |
| } |
| patternMap.get(fb.patternType)!.push(fb) |
|
|
| repoMap.set(fb.repoSlug, (repoMap.get(fb.repoSlug) || 0) + 1) |
| teamMap.set(fb.teamId, (teamMap.get(fb.teamId) || 0) + 1) |
| } |
|
|
| const byPattern = new Map<PatternCategory, PatternStats>() |
| for (const [patternType, pFeedbacks] of patternMap) { |
| const accepted = pFeedbacks.filter( |
| f => f.feedbackType === 'accepted' |
| ).length |
| const rejected = pFeedbacks.filter( |
| f => f.feedbackType === 'rejected' |
| ).length |
| const ignored = pFeedbacks.filter( |
| f => f.feedbackType === 'ignored' |
| ).length |
| const total = pFeedbacks.length |
| const avgConfidence = |
| pFeedbacks.reduce((sum, f) => sum + f.confidence, 0) / total |
|
|
| byPattern.set(patternType, { |
| patternType, |
| total, |
| accepted, |
| rejected, |
| ignored, |
| acceptanceRate: total > 0 ? accepted / total : 0, |
| avgConfidence |
| }) |
| } |
|
|
| const recentWindow = 30 * 24 * 60 * 60 * 1000 |
| const recentFeedbacks = feedbacks.filter( |
| f => f.timestamp > Date.now() - recentWindow |
| ) |
| const olderFeedbacks = feedbacks.filter( |
| f => f.timestamp <= Date.now() - recentWindow |
| ) |
|
|
| const recentAcceptance = |
| recentFeedbacks.filter(f => f.feedbackType === 'accepted').length / |
| (recentFeedbacks.length || 1) |
| const olderAcceptance = |
| olderFeedbacks.filter(f => f.feedbackType === 'accepted').length / |
| (olderFeedbacks.length || 1) |
|
|
| let recentTrend: 'improving' | 'declining' | 'stable' = 'stable' |
| if (recentAcceptance > olderAcceptance + 0.05) { |
| recentTrend = 'improving' |
| } else if (recentAcceptance < olderAcceptance - 0.05) { |
| recentTrend = 'declining' |
| } |
|
|
| return { |
| totalFeedback: feedbacks.length, |
| byPattern, |
| byRepo: repoMap, |
| byTeam: teamMap, |
| recentTrend |
| } |
| } |
|
|
| exportForTraining(): Array<{ |
| patternType: PatternCategory |
| severity: 'critical' | 'major' | 'minor' | 'info' |
| filenamePattern: string |
| confidence: number |
| accepted: boolean |
| repoSlug: string |
| }> { |
| return this.feedbackStore |
| .filter(f => f.feedbackType !== 'ignored') |
| .map(f => ({ |
| patternType: f.patternType, |
| severity: f.severity, |
| filenamePattern: f.filenamePattern, |
| confidence: f.confidence, |
| accepted: f.feedbackType === 'accepted', |
| repoSlug: f.repoSlug |
| })) |
| } |
| } |
|
|
| export const feedbackTracker = new FeedbackTracker() |
|
|