import fs from "node:fs"; import path from "node:path"; import { logger } from "./logger"; import { ingestUpvotedAnswer } from "./guide"; const DATA_DIR = path.resolve(process.cwd(), "data"); const FEEDBACK_FILE = path.join(DATA_DIR, "feedback.jsonl"); export interface FeedbackEntry { ts: number; query: string; rating: "up" | "down"; sources: string[]; answer?: string; } const sourceScore: Map = new Map(); const queryTermAffinity: Map> = new Map(); let totalUp = 0; let totalDown = 0; let loaded = false; function tokenize(q: string): string[] { return q .toLowerCase() .split(/[^\p{L}\p{N}]+/u) .filter((t) => t.length >= 3) .slice(0, 20); } function applyEntry(e: FeedbackEntry) { const isUp = e.rating === "up"; if (isUp) totalUp++; else totalDown++; for (const s of e.sources ?? []) { if (!s || typeof s !== "string") continue; const cur = sourceScore.get(s) ?? { up: 0, down: 0 }; if (isUp) cur.up++; else cur.down++; sourceScore.set(s, cur); } const delta = isUp ? 1 : -1; for (const term of tokenize(e.query)) { const m = queryTermAffinity.get(term) ?? new Map(); for (const s of e.sources ?? []) { if (!s) continue; m.set(s, (m.get(s) ?? 0) + delta); } queryTermAffinity.set(term, m); } } export function loadFeedback(): void { if (loaded) return; loaded = true; try { fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(FEEDBACK_FILE)) { logger.info("Feedback log empty (no prior ratings)"); return; } const text = fs.readFileSync(FEEDBACK_FILE, "utf-8"); let n = 0; for (const line of text.split("\n")) { if (!line.trim()) continue; try { const e = JSON.parse(line) as FeedbackEntry; applyEntry(e); n++; } catch { // skip malformed line } } logger.info( { entries: n, sources: sourceScore.size, up: totalUp, down: totalDown }, "Feedback log loaded", ); } catch (err) { logger.warn({ err }, "Failed to load feedback log"); } } export function recordFeedback( e: Omit, ): FeedbackEntry | null { const entry: FeedbackEntry = { ts: Date.now(), ...e }; try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.appendFileSync(FEEDBACK_FILE, JSON.stringify(entry) + "\n", "utf-8"); } catch (err) { logger.error({ err }, "Failed to persist feedback; rejecting"); return null; } applyEntry(entry); if (entry.rating === "up" && entry.answer) { try { ingestUpvotedAnswer(entry.answer); } catch (err) { logger.warn({ err }, "guide ingest failed"); } } return entry; } export function getSourceMultiplier( source: string, queryTerms: string[] = [], ): number { const s = sourceScore.get(source); let mul = 1.0; if (s) { const total = s.up + s.down; if (total > 0) { const raw = (s.up - s.down) / Math.max(total, 4); mul = 1.0 + 0.4 * raw; } } const normTerms = queryTerms .flatMap((t) => tokenize(String(t))) .slice(0, 20); let affinity = 0; for (const t of normTerms) { const m = queryTermAffinity.get(t); if (m) affinity += m.get(source) ?? 0; } if (affinity !== 0) { mul += Math.tanh(affinity / 5) * 0.2; } return Math.max(0.5, Math.min(1.6, mul)); } export function getStats() { const sources = Array.from(sourceScore.entries()) .map(([name, s]) => ({ source: name, up: s.up, down: s.down, multiplier: Number(getSourceMultiplier(name).toFixed(3)), })) .sort((a, b) => b.up - b.down - (a.up - a.down)); return { totalFeedback: totalUp + totalDown, totalUp, totalDown, sources, }; }