@adia-ai/a2ui-retrieval 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Feedback Analyzer
3
+ *
4
+ * Reads JSONL feedback files, aggregates by intent category,
5
+ * and surfaces weak intents, promotion candidates, and pattern gaps.
6
+ *
7
+ * Usage:
8
+ * import { FeedbackAnalyzer } from './feedback-analyzer.js';
9
+ * const analyzer = new FeedbackAnalyzer();
10
+ * const entries = await analyzer.readRange(30);
11
+ * const aggregated = analyzer.aggregateByIntent(entries);
12
+ * const weak = analyzer.findWeakIntents(aggregated);
13
+ */
14
+
15
+ import { feedbackStore } from './feedback-store.js';
16
+ import { categorizeIntent } from './intent-categorizer.js';
17
+
18
+ let fs, path;
19
+ const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
20
+ if (IS_NODE) {
21
+ try {
22
+ fs = await import(/* @vite-ignore */ 'node:fs/promises');
23
+ path = await import(/* @vite-ignore */ 'node:path');
24
+ } catch {
25
+ // Node builtins unavailable
26
+ }
27
+ }
28
+
29
+ const FEEDBACK_DIR = path
30
+ ? path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'a2ui/corpus', 'feedback')
31
+ : null;
32
+
33
+ export class FeedbackAnalyzer {
34
+ /**
35
+ * Read JSONL feedback files for the last N days.
36
+ *
37
+ * @param {number} days — Number of days to look back (default 30)
38
+ * @returns {Promise<object[]>} — Array of parsed feedback entries
39
+ */
40
+ async readRange(days = 30) {
41
+ if (!fs || !FEEDBACK_DIR) return [];
42
+
43
+ const entries = [];
44
+ const now = new Date();
45
+
46
+ // Build set of date strings we want
47
+ const dateStrings = new Set();
48
+ for (let i = 0; i < days; i++) {
49
+ const d = new Date(now);
50
+ d.setDate(d.getDate() - i);
51
+ dateStrings.add(d.toISOString().slice(0, 10));
52
+ }
53
+
54
+ try {
55
+ const files = await fs.readdir(FEEDBACK_DIR);
56
+ const jsonlFiles = files
57
+ .filter(f => f.endsWith('.jsonl'))
58
+ .filter(f => {
59
+ const dateStr = f.replace('.jsonl', '');
60
+ return dateStrings.has(dateStr);
61
+ })
62
+ .sort();
63
+
64
+ for (const file of jsonlFiles) {
65
+ try {
66
+ const content = await fs.readFile(path.join(FEEDBACK_DIR, file), 'utf8');
67
+ const lines = content.trim().split('\n').filter(Boolean);
68
+ for (const line of lines) {
69
+ try {
70
+ const entry = JSON.parse(line);
71
+ entry._file = file;
72
+ entries.push(entry);
73
+ } catch {
74
+ // Skip malformed lines
75
+ }
76
+ }
77
+ } catch {
78
+ // Skip unreadable files
79
+ }
80
+ }
81
+ } catch {
82
+ // Feedback dir doesn't exist yet
83
+ }
84
+
85
+ return entries;
86
+ }
87
+
88
+ /**
89
+ * Aggregate feedback entries by intent category.
90
+ *
91
+ * @param {object[]} entries — Array of feedback entries
92
+ * @returns {Map<string, { count: number, avgScore: number, avgRating: number, patternMatchRate: number, entries: object[] }>}
93
+ */
94
+ aggregateByIntent(entries) {
95
+ const buckets = new Map();
96
+
97
+ // First pass: group executions by category
98
+ const executions = entries.filter(e => e.type === 'execution');
99
+ const ratings = entries.filter(e => e.type === 'rating');
100
+
101
+ // Index ratings by executionId for fast lookup
102
+ const ratingsByExecId = new Map();
103
+ for (const r of ratings) {
104
+ if (!ratingsByExecId.has(r.executionId)) {
105
+ ratingsByExecId.set(r.executionId, []);
106
+ }
107
+ ratingsByExecId.get(r.executionId).push(r);
108
+ }
109
+
110
+ for (const exec of executions) {
111
+ const { category } = categorizeIntent(exec.intent);
112
+
113
+ if (!buckets.has(category)) {
114
+ buckets.set(category, {
115
+ count: 0,
116
+ totalScore: 0,
117
+ totalRating: 0,
118
+ ratingCount: 0,
119
+ patternMatchCount: 0,
120
+ entries: [],
121
+ sampleIntents: [],
122
+ });
123
+ }
124
+
125
+ const bucket = buckets.get(category);
126
+ bucket.count++;
127
+ bucket.totalScore += exec.score || 0;
128
+ bucket.entries.push(exec);
129
+
130
+ if (exec.patternMatch) {
131
+ bucket.patternMatchCount++;
132
+ }
133
+
134
+ // Collect unique sample intents (up to 5)
135
+ if (bucket.sampleIntents.length < 5 && exec.intent) {
136
+ const intentLower = exec.intent.toLowerCase();
137
+ if (!bucket.sampleIntents.some(s => s.toLowerCase() === intentLower)) {
138
+ bucket.sampleIntents.push(exec.intent);
139
+ }
140
+ }
141
+
142
+ // Attach ratings
143
+ const execRatings = ratingsByExecId.get(exec.executionId) || [];
144
+ for (const r of execRatings) {
145
+ bucket.totalRating += r.rating;
146
+ bucket.ratingCount++;
147
+ }
148
+ }
149
+
150
+ // Compute averages
151
+ const result = new Map();
152
+ for (const [category, bucket] of buckets) {
153
+ result.set(category, {
154
+ count: bucket.count,
155
+ avgScore: bucket.count > 0 ? Math.round(bucket.totalScore / bucket.count) : 0,
156
+ avgRating: bucket.ratingCount > 0 ? Math.round((bucket.totalRating / bucket.ratingCount) * 10) / 10 : 0,
157
+ patternMatchRate: bucket.count > 0 ? Math.round((bucket.patternMatchCount / bucket.count) * 100) : 0,
158
+ sampleIntents: bucket.sampleIntents,
159
+ entries: bucket.entries,
160
+ });
161
+ }
162
+
163
+ return result;
164
+ }
165
+
166
+ /**
167
+ * Find intent categories with weak performance.
168
+ *
169
+ * @param {Map} aggregated — Output of aggregateByIntent
170
+ * @param {number} threshold — Score threshold (default 60)
171
+ * @returns {Array<{ category: string, count: number, avgScore: number, avgRating: number, sampleIntents: string[] }>}
172
+ */
173
+ findWeakIntents(aggregated, threshold = 60) {
174
+ const weak = [];
175
+ for (const [category, data] of aggregated) {
176
+ if (data.avgScore < threshold) {
177
+ weak.push({
178
+ category,
179
+ count: data.count,
180
+ avgScore: data.avgScore,
181
+ avgRating: data.avgRating,
182
+ sampleIntents: data.sampleIntents,
183
+ });
184
+ }
185
+ }
186
+ return weak.sort((a, b) => a.avgScore - b.avgScore);
187
+ }
188
+
189
+ /**
190
+ * Find intent categories ready for pattern promotion.
191
+ * Criteria: avgScore >= 95, avgRating >= 4, count >= 3
192
+ *
193
+ * @param {Map} aggregated — Output of aggregateByIntent
194
+ * @returns {Array<{ category: string, count: number, avgScore: number, avgRating: number, sampleIntents: string[] }>}
195
+ */
196
+ findPromotionCandidates(aggregated) {
197
+ const candidates = [];
198
+ for (const [category, data] of aggregated) {
199
+ if (data.avgScore >= 95 && data.avgRating >= 4 && data.count >= 3) {
200
+ candidates.push({
201
+ category,
202
+ count: data.count,
203
+ avgScore: data.avgScore,
204
+ avgRating: data.avgRating,
205
+ patternMatchRate: data.patternMatchRate,
206
+ sampleIntents: data.sampleIntents,
207
+ });
208
+ }
209
+ }
210
+ return candidates.sort((a, b) => b.avgScore - a.avgScore);
211
+ }
212
+
213
+ /**
214
+ * Find intent categories with no pattern match AND low scores — gaps in pattern coverage.
215
+ *
216
+ * @param {Map} aggregated — Output of aggregateByIntent
217
+ * @returns {Array<{ category: string, count: number, avgScore: number, patternMatchRate: number, sampleIntents: string[] }>}
218
+ */
219
+ findPatternGaps(aggregated) {
220
+ const gaps = [];
221
+ for (const [category, data] of aggregated) {
222
+ if (data.patternMatchRate === 0 && data.avgScore < 70) {
223
+ gaps.push({
224
+ category,
225
+ count: data.count,
226
+ avgScore: data.avgScore,
227
+ avgRating: data.avgRating,
228
+ patternMatchRate: data.patternMatchRate,
229
+ sampleIntents: data.sampleIntents,
230
+ });
231
+ }
232
+ }
233
+ return gaps.sort((a, b) => a.avgScore - b.avgScore);
234
+ }
235
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Persistent Feedback Store
3
+ *
4
+ * Writes execution metadata, ratings, LLM self-critique, and gap signals
5
+ * to JSONL files on disk. One file per day. Browser-safe (no-ops if no fs).
6
+ *
7
+ * Usage:
8
+ * import { feedbackStore } from './feedback-store.js';
9
+ * feedbackStore.logExecution({ executionId, intent, model, domain, ... });
10
+ * feedbackStore.logRating({ executionId, rating, ... });
11
+ * feedbackStore.logGap({ type: 'pattern', description: '...' });
12
+ * const recent = await feedbackStore.readRecent(50);
13
+ */
14
+
15
+ let fs, path;
16
+ const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
17
+ if (IS_NODE) {
18
+ try {
19
+ fs = await import(/* @vite-ignore */ 'node:fs/promises');
20
+ path = await import(/* @vite-ignore */ 'node:path');
21
+ } catch {
22
+ // Node builtins unavailable
23
+ }
24
+ }
25
+
26
+ const FEEDBACK_DIR = path
27
+ ? path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'a2ui/corpus', 'feedback')
28
+ : null;
29
+
30
+ function todayFile() {
31
+ const d = new Date().toISOString().slice(0, 10);
32
+ return path ? path.join(FEEDBACK_DIR, `${d}.jsonl`) : null;
33
+ }
34
+
35
+ async function append(entry) {
36
+ if (!fs || !FEEDBACK_DIR) return;
37
+ try {
38
+ await fs.mkdir(FEEDBACK_DIR, { recursive: true });
39
+ await fs.appendFile(todayFile(), JSON.stringify(entry) + '\n');
40
+ } catch (e) {
41
+ console.warn('FeedbackStore: write failed', e.message);
42
+ }
43
+ }
44
+
45
+ export const feedbackStore = {
46
+ /**
47
+ * Log a completed generation execution.
48
+ */
49
+ async logExecution({
50
+ executionId, intent, model, domain, mode,
51
+ patternMatch, patternConfidence,
52
+ score, componentCount, tokenCount,
53
+ meta, // LLM self-critique
54
+ messages, // A2UI output (optional — can be large)
55
+ }) {
56
+ await append({
57
+ type: 'execution',
58
+ timestamp: new Date().toISOString(),
59
+ executionId, intent, model, domain, mode,
60
+ patternMatch, patternConfidence,
61
+ score, componentCount, tokenCount,
62
+ meta: meta || null,
63
+ });
64
+ },
65
+
66
+ /**
67
+ * Log a user rating (👍/👎).
68
+ */
69
+ async logRating({ executionId, rating, intent }) {
70
+ await append({
71
+ type: 'rating',
72
+ timestamp: new Date().toISOString(),
73
+ executionId, rating, intent,
74
+ });
75
+ },
76
+
77
+ /**
78
+ * Log a pattern save action.
79
+ */
80
+ async logPatternSave({ executionId, patternName, intent }) {
81
+ await append({
82
+ type: 'pattern_save',
83
+ timestamp: new Date().toISOString(),
84
+ executionId, patternName, intent,
85
+ });
86
+ },
87
+
88
+ /**
89
+ * Log a training gap identified by LLM meta or heuristics.
90
+ */
91
+ async logGap({ type, description, source, executionId }) {
92
+ await append({
93
+ type: 'gap',
94
+ gapType: type, // 'pattern' | 'domain' | 'component' | 'prompt'
95
+ timestamp: new Date().toISOString(),
96
+ description, source, executionId,
97
+ });
98
+ },
99
+
100
+ /**
101
+ * Read recent feedback entries (Node only).
102
+ */
103
+ async readRecent(limit = 100) {
104
+ if (!fs || !FEEDBACK_DIR) return [];
105
+ try {
106
+ const files = (await fs.readdir(FEEDBACK_DIR))
107
+ .filter(f => f.endsWith('.jsonl'))
108
+ .sort()
109
+ .reverse();
110
+
111
+ const entries = [];
112
+ for (const file of files) {
113
+ if (entries.length >= limit) break;
114
+ const content = await fs.readFile(path.join(FEEDBACK_DIR, file), 'utf8');
115
+ const lines = content.trim().split('\n').filter(Boolean).reverse();
116
+ for (const line of lines) {
117
+ if (entries.length >= limit) break;
118
+ try { entries.push(JSON.parse(line)); } catch {}
119
+ }
120
+ }
121
+ return entries;
122
+ } catch { return []; }
123
+ },
124
+
125
+ /**
126
+ * Get gap summary — aggregate gap signals for training improvement.
127
+ */
128
+ async getGapSummary() {
129
+ const entries = await this.readRecent(500);
130
+ const gaps = entries.filter(e => e.type === 'gap');
131
+ const byType = {};
132
+ for (const g of gaps) {
133
+ byType[g.gapType] = byType[g.gapType] || [];
134
+ byType[g.gapType].push(g.description);
135
+ }
136
+ return byType;
137
+ },
138
+
139
+ /**
140
+ * Get quality metrics — aggregate from recent executions.
141
+ */
142
+ async getQualityMetrics() {
143
+ const entries = await this.readRecent(500);
144
+ const executions = entries.filter(e => e.type === 'execution');
145
+ const ratings = entries.filter(e => e.type === 'rating');
146
+
147
+ if (executions.length === 0) return { executions: 0, avgScore: 0, avgTokens: 0, thumbUpRate: 0 };
148
+
149
+ const avgScore = executions.reduce((s, e) => s + (e.score || 0), 0) / executions.length;
150
+ const avgTokens = executions.reduce((s, e) => s + (e.tokenCount || 0), 0) / executions.length;
151
+ const thumbsUp = ratings.filter(r => r.rating >= 4).length;
152
+ const thumbsDown = ratings.filter(r => r.rating < 4).length;
153
+ const thumbUpRate = (thumbsUp + thumbsDown) > 0 ? thumbsUp / (thumbsUp + thumbsDown) : 0;
154
+
155
+ // Per-domain breakdown
156
+ const byDomain = {};
157
+ for (const e of executions) {
158
+ const d = e.domain || 'unknown';
159
+ if (!byDomain[d]) byDomain[d] = { count: 0, totalScore: 0 };
160
+ byDomain[d].count++;
161
+ byDomain[d].totalScore += e.score || 0;
162
+ }
163
+
164
+ return {
165
+ executions: executions.length,
166
+ avgScore: Math.round(avgScore),
167
+ avgTokens: Math.round(avgTokens),
168
+ thumbUpRate: Math.round(thumbUpRate * 100),
169
+ byDomain: Object.fromEntries(
170
+ Object.entries(byDomain).map(([d, v]) => [d, { count: v.count, avgScore: Math.round(v.totalScore / v.count) }])
171
+ ),
172
+ gaps: await this.getGapSummary(),
173
+ };
174
+ },
175
+ };
package/feedback.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * FeedbackCollector — Structured feedback for the evolution engine.
3
+ *
4
+ * Captures per-generation feedback across multiple dimensions:
5
+ * - Overall rating (1-5)
6
+ * - Intent alignment, visual quality, component choice (1-5 each)
7
+ * - Whether the user edited the output
8
+ * - Pattern promotion signals ("this should become a pattern")
9
+ *
10
+ * Exports as a structured JSON log for the training cycle.
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} FeedbackEntry
15
+ * @property {string} executionId
16
+ * @property {string} intent
17
+ * @property {string} domain
18
+ * @property {string} mode
19
+ * @property {number} timestamp
20
+ * @property {object} generation
21
+ * @property {number} generation.componentCount
22
+ * @property {string[]} generation.componentTypes
23
+ * @property {number} generation.score
24
+ * @property {{ name: string, passed: boolean }[]} generation.validationChecks
25
+ * @property {{ structural: number, completeness: number, idiomatic: number, minimal: number }} generation.qualityDimensions
26
+ * @property {object} feedback
27
+ * @property {number} [feedback.rating]
28
+ * @property {number} [feedback.intentAlignment]
29
+ * @property {number} [feedback.visualQuality]
30
+ * @property {number} [feedback.componentChoice]
31
+ * @property {boolean} [feedback.userEdited]
32
+ * @property {string} [feedback.editSummary]
33
+ * @property {string} [feedback.notes]
34
+ * @property {object} patterns
35
+ * @property {string} [patterns.patternUsed]
36
+ * @property {boolean} [patterns.shouldBePattern]
37
+ * @property {string} [patterns.suggestedName]
38
+ */
39
+
40
+ export class FeedbackCollector {
41
+ /** @type {Map<string, FeedbackEntry>} */
42
+ #entries = new Map();
43
+
44
+ /**
45
+ * Initialize a feedback entry from generation results.
46
+ * Called automatically after each generation completes.
47
+ *
48
+ * @param {string} executionId
49
+ * @param {object} data
50
+ * @param {string} data.intent
51
+ * @param {string} data.domain
52
+ * @param {string} data.mode
53
+ * @param {object[]} data.messages
54
+ * @param {object} data.validation
55
+ */
56
+ initFromGeneration(executionId, { intent, domain, mode, messages, validation }) {
57
+ const components = messages?.[0]?.components || [];
58
+ const checks = validation?.checks || [];
59
+
60
+ // Compute quality dimensions (same logic as score_quality in mcp-tools.js)
61
+ const failedChecks = checks.filter(c => !c.passed);
62
+ const structural = failedChecks.some(c =>
63
+ ['hasRootComponent', 'noOrphanedChildren', 'flatAdjacency'].includes(c.name)
64
+ ) ? 0.5 : 1;
65
+ const completeness = Math.max(0, 1 - (
66
+ failedChecks.filter(c => ['textContentSet', 'allTypesRegistered'].includes(c.name)).length * 0.1
67
+ ));
68
+ const idiomatic = failedChecks.some(c =>
69
+ ['noBareDivs', 'noBareInputs', 'cardStructure'].includes(c.name)
70
+ ) ? 0.5 : 1;
71
+ const minimal = failedChecks.some(c =>
72
+ ['noHardcodedColors', 'noInlineLayout'].includes(c.name)
73
+ ) ? 0.5 : 1;
74
+
75
+ this.#entries.set(executionId, {
76
+ executionId,
77
+ intent: intent || '',
78
+ domain: domain || '',
79
+ mode: mode || 'instant',
80
+ timestamp: Date.now(),
81
+ generation: {
82
+ componentCount: components.length,
83
+ componentTypes: [...new Set(components.map(c => c.component).filter(Boolean))],
84
+ score: validation?.score ?? 0,
85
+ validationChecks: checks.map(c => ({ name: c.name, passed: c.passed })),
86
+ qualityDimensions: { structural, completeness, idiomatic, minimal },
87
+ },
88
+ feedback: {},
89
+ patterns: {},
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Collect user feedback for an execution.
95
+ *
96
+ * @param {string} executionId
97
+ * @param {object} feedback
98
+ * @param {number} [feedback.rating] — 1-5
99
+ * @param {number} [feedback.intentAlignment] — 1-5
100
+ * @param {number} [feedback.visualQuality] — 1-5
101
+ * @param {number} [feedback.componentChoice] — 1-5
102
+ * @param {boolean} [feedback.userEdited]
103
+ * @param {string} [feedback.editSummary]
104
+ * @param {string} [feedback.notes]
105
+ */
106
+ collectFeedback(executionId, feedback) {
107
+ const entry = this.#entries.get(executionId);
108
+ if (!entry) {
109
+ // Create a minimal entry if init wasn't called
110
+ this.#entries.set(executionId, {
111
+ executionId,
112
+ intent: '', domain: '', mode: '', timestamp: Date.now(),
113
+ generation: { componentCount: 0, componentTypes: [], score: 0, validationChecks: [], qualityDimensions: {} },
114
+ feedback: {},
115
+ patterns: {},
116
+ });
117
+ }
118
+ const e = this.#entries.get(executionId);
119
+ e.feedback = { ...e.feedback, ...feedback };
120
+ }
121
+
122
+ /**
123
+ * Collect pattern-related feedback.
124
+ *
125
+ * @param {string} executionId
126
+ * @param {object} patternFeedback
127
+ * @param {string} [patternFeedback.patternUsed]
128
+ * @param {boolean} [patternFeedback.shouldBePattern]
129
+ * @param {string} [patternFeedback.suggestedName]
130
+ */
131
+ collectPatternFeedback(executionId, patternFeedback) {
132
+ const entry = this.#entries.get(executionId);
133
+ if (!entry) return;
134
+ entry.patterns = { ...entry.patterns, ...patternFeedback };
135
+ }
136
+
137
+ /**
138
+ * Get a single feedback entry.
139
+ * @param {string} executionId
140
+ * @returns {FeedbackEntry|null}
141
+ */
142
+ get(executionId) {
143
+ return this.#entries.get(executionId) ?? null;
144
+ }
145
+
146
+ /**
147
+ * Get all feedback entries.
148
+ * @returns {FeedbackEntry[]}
149
+ */
150
+ getAll() {
151
+ return [...this.#entries.values()];
152
+ }
153
+
154
+ /** Number of entries. */
155
+ get size() {
156
+ return this.#entries.size;
157
+ }
158
+
159
+ /**
160
+ * Export all feedback as structured JSON.
161
+ * In browser: triggers a file download.
162
+ * In Node: returns the JSON string.
163
+ *
164
+ * @returns {string} — JSON string of all entries
165
+ */
166
+ exportFeedback() {
167
+ const data = {
168
+ exportedAt: new Date().toISOString(),
169
+ entryCount: this.#entries.size,
170
+ entries: this.getAll(),
171
+ };
172
+ const json = JSON.stringify(data, null, 2);
173
+
174
+ // Browser download
175
+ if (typeof document !== 'undefined') {
176
+ const date = new Date().toISOString().slice(0, 10);
177
+ const blob = new Blob([json], { type: 'application/json' });
178
+ const url = URL.createObjectURL(blob);
179
+ const a = document.createElement('a');
180
+ a.href = url;
181
+ a.download = `gen-ui-feedback-${date}.json`;
182
+ a.style.display = 'none';
183
+ // Prevent SPA router from intercepting the blob URL click
184
+ a.addEventListener('click', (e) => e.stopPropagation());
185
+ document.body.appendChild(a);
186
+ a.click();
187
+ document.body.removeChild(a);
188
+ URL.revokeObjectURL(url);
189
+ }
190
+
191
+ return json;
192
+ }
193
+
194
+ /** Clear all entries. */
195
+ clear() {
196
+ this.#entries.clear();
197
+ }
198
+ }