@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.
- package/CHANGELOG.md +50 -0
- package/README.md +40 -0
- package/anti-patterns.js +148 -0
- package/catalog.js +215 -0
- package/clarity.js +207 -0
- package/component-entry.js +80 -0
- package/concept-mapper.js +127 -0
- package/context-assembler.js +168 -0
- package/decomposer.js +216 -0
- package/dialog-recorder.js +179 -0
- package/domain-router.js +172 -0
- package/embedding-provider.js +108 -0
- package/embedding-retriever.js +120 -0
- package/feedback-analyzer.js +235 -0
- package/feedback-store.js +175 -0
- package/feedback.js +198 -0
- package/gap-registry.js +121 -0
- package/index.js +16 -0
- package/intent-alignment.js +243 -0
- package/intent-categorizer.js +97 -0
- package/intent-gate.js +155 -0
- package/package.json +29 -0
- package/pattern-library.js +659 -0
- package/pattern-promotion.js +135 -0
- package/prompt-analyzer.js +211 -0
- package/synthetic-data.js +446 -0
- package/web-research.js +186 -0
- package/wiring-catalog.js +195 -0
|
@@ -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
|
+
}
|