cyclecad 3.12.0 → 3.13.0

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,689 @@
1
+ /* AI Engineering Analyst — RAG v1.0
2
+ *
3
+ * Retrieval-augmented generation scaffold for citing machine-element references
4
+ * in AI Engineering Analyst responses. Browser-only, no server, no npm deps.
5
+ *
6
+ * Architecture:
7
+ * - Embeddings: @xenova/transformers (MiniLM-L6-v2, 384-dim), loaded from CDN on demand.
8
+ * - Storage: IndexedDB (db 'cyclecad_engineer_rag'), falls back to in-memory.
9
+ * - Retrieval: cosine similarity over normalized Float32Array embeddings.
10
+ * - Citations: MecAgent-style "Open Document" footnote list.
11
+ *
12
+ * Seed corpus: ~10 machine-element fundamentals written in plain language
13
+ * (no copyrighted text). User can replace placeholder URLs via addDocument().
14
+ *
15
+ * Public API: window.CycleCAD.AIEngineerRAG = { init, addDocument, query,
16
+ * listDocuments, clearAll, buildCitationUI, isReady, getModelLoadProgress }
17
+ */
18
+ (function(){
19
+ 'use strict';
20
+ window.CycleCAD = window.CycleCAD || {};
21
+
22
+ // ===================================================================
23
+ // CONFIG
24
+ // ===================================================================
25
+ const DB_NAME = 'cyclecad_engineer_rag';
26
+ const DB_VERSION = 1;
27
+ const STORE_CHUNKS = 'chunks';
28
+ const STORE_DOCS = 'documents';
29
+
30
+ const MODEL_CDN = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.1';
31
+ const MODEL_ID = 'Xenova/all-MiniLM-L6-v2';
32
+ const EMBEDDING_DIM = 384;
33
+
34
+ const CHUNK_SIZE = 512;
35
+ const CHUNK_OVERLAP = 50; // ~10%
36
+
37
+ // ===================================================================
38
+ // STATE
39
+ // ===================================================================
40
+ let _db = null; // IDBDatabase or null (fallback to in-memory)
41
+ let _inMemoryChunks = []; // fallback store
42
+ let _inMemoryDocs = [];
43
+ let _usingIDB = false;
44
+
45
+ let _extractor = null; // pipeline instance
46
+ let _modelLoading = false;
47
+ let _modelLoadProgress = 0;
48
+ let _modelReady = false;
49
+
50
+ let _initCalled = false;
51
+ let _initPromise = null;
52
+
53
+ // ===================================================================
54
+ // UTILITIES
55
+ // ===================================================================
56
+
57
+ /**
58
+ * Split text into overlapping chunks. Tries sentence boundaries first.
59
+ * @param {string} text
60
+ * @param {number} size target char count
61
+ * @param {number} overlap char count to re-use at chunk boundary
62
+ * @returns {string[]}
63
+ */
64
+ function chunkText(text, size, overlap) {
65
+ size = size || CHUNK_SIZE;
66
+ overlap = overlap || CHUNK_OVERLAP;
67
+
68
+ const cleaned = String(text || '').replace(/\s+/g, ' ').trim();
69
+ if (cleaned.length === 0) return [];
70
+ if (cleaned.length <= size) return [cleaned];
71
+
72
+ const chunks = [];
73
+ let i = 0;
74
+ while (i < cleaned.length) {
75
+ let end = Math.min(i + size, cleaned.length);
76
+ if (end < cleaned.length) {
77
+ // try to break at a sentence boundary within the last 20% of the window
78
+ const windowStart = Math.max(i + Math.floor(size * 0.8), i + 1);
79
+ const slice = cleaned.slice(windowStart, end);
80
+ const sentenceBreak = slice.search(/[.!?](\s|$)/);
81
+ if (sentenceBreak > -1) {
82
+ end = windowStart + sentenceBreak + 1;
83
+ } else {
84
+ // fall back to word boundary
85
+ const wordBreak = cleaned.lastIndexOf(' ', end);
86
+ if (wordBreak > i) end = wordBreak;
87
+ }
88
+ }
89
+ chunks.push(cleaned.slice(i, end).trim());
90
+ if (end >= cleaned.length) break;
91
+ i = Math.max(end - overlap, i + 1);
92
+ }
93
+ return chunks.filter(function(c){ return c.length > 0; });
94
+ }
95
+
96
+ /**
97
+ * L2-normalize a Float32Array in place. Returns the same array.
98
+ */
99
+ function normalize(vec) {
100
+ let norm = 0;
101
+ for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i];
102
+ norm = Math.sqrt(norm);
103
+ if (norm > 0) {
104
+ for (let i = 0; i < vec.length; i++) vec[i] /= norm;
105
+ }
106
+ return vec;
107
+ }
108
+
109
+ /**
110
+ * Cosine similarity of two normalized Float32Arrays = dot product.
111
+ */
112
+ function cosineSimilarity(a, b) {
113
+ if (!a || !b || a.length !== b.length) return 0;
114
+ let dot = 0;
115
+ for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
116
+ return dot;
117
+ }
118
+
119
+ // ===================================================================
120
+ // INDEXEDDB LAYER
121
+ // ===================================================================
122
+
123
+ function openDB() {
124
+ return new Promise(function(resolve, reject){
125
+ if (typeof indexedDB === 'undefined') {
126
+ reject(new Error('IndexedDB not available'));
127
+ return;
128
+ }
129
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
130
+ req.onupgradeneeded = function(ev){
131
+ const db = ev.target.result;
132
+ if (!db.objectStoreNames.contains(STORE_CHUNKS)) {
133
+ db.createObjectStore(STORE_CHUNKS, { keyPath: 'id' });
134
+ }
135
+ if (!db.objectStoreNames.contains(STORE_DOCS)) {
136
+ db.createObjectStore(STORE_DOCS, { keyPath: 'id' });
137
+ }
138
+ };
139
+ req.onsuccess = function(){ resolve(req.result); };
140
+ req.onerror = function(){ reject(req.error); };
141
+ });
142
+ }
143
+
144
+ function idbPut(storeName, value) {
145
+ return new Promise(function(resolve, reject){
146
+ const tx = _db.transaction([storeName], 'readwrite');
147
+ const store = tx.objectStore(storeName);
148
+ const req = store.put(value);
149
+ req.onsuccess = function(){ resolve(); };
150
+ req.onerror = function(){ reject(req.error); };
151
+ });
152
+ }
153
+
154
+ function idbGetAll(storeName) {
155
+ return new Promise(function(resolve, reject){
156
+ const tx = _db.transaction([storeName], 'readonly');
157
+ const store = tx.objectStore(storeName);
158
+ const req = store.getAll();
159
+ req.onsuccess = function(){ resolve(req.result || []); };
160
+ req.onerror = function(){ reject(req.error); };
161
+ });
162
+ }
163
+
164
+ function idbClear(storeName) {
165
+ return new Promise(function(resolve, reject){
166
+ const tx = _db.transaction([storeName], 'readwrite');
167
+ const store = tx.objectStore(storeName);
168
+ const req = store.clear();
169
+ req.onsuccess = function(){ resolve(); };
170
+ req.onerror = function(){ reject(req.error); };
171
+ });
172
+ }
173
+
174
+ // ===================================================================
175
+ // STORAGE ABSTRACTION (IDB or in-memory)
176
+ // ===================================================================
177
+
178
+ async function putChunk(chunk) {
179
+ if (_usingIDB) return idbPut(STORE_CHUNKS, chunk);
180
+ // replace if same id
181
+ _inMemoryChunks = _inMemoryChunks.filter(function(c){ return c.id !== chunk.id; });
182
+ _inMemoryChunks.push(chunk);
183
+ }
184
+
185
+ async function putDocument(doc) {
186
+ if (_usingIDB) return idbPut(STORE_DOCS, doc);
187
+ _inMemoryDocs = _inMemoryDocs.filter(function(d){ return d.id !== doc.id; });
188
+ _inMemoryDocs.push(doc);
189
+ }
190
+
191
+ async function getAllChunks() {
192
+ if (_usingIDB) return idbGetAll(STORE_CHUNKS);
193
+ return _inMemoryChunks.slice();
194
+ }
195
+
196
+ async function getAllDocuments() {
197
+ if (_usingIDB) return idbGetAll(STORE_DOCS);
198
+ return _inMemoryDocs.slice();
199
+ }
200
+
201
+ async function clearStores() {
202
+ if (_usingIDB) {
203
+ await idbClear(STORE_CHUNKS);
204
+ await idbClear(STORE_DOCS);
205
+ } else {
206
+ _inMemoryChunks = [];
207
+ _inMemoryDocs = [];
208
+ }
209
+ }
210
+
211
+ // ===================================================================
212
+ // MODEL LOADING
213
+ // ===================================================================
214
+
215
+ async function loadModel() {
216
+ if (_modelReady) return _extractor;
217
+ if (_modelLoading) {
218
+ // wait for current load
219
+ while (_modelLoading) {
220
+ await new Promise(function(r){ setTimeout(r, 100); });
221
+ }
222
+ return _extractor;
223
+ }
224
+ _modelLoading = true;
225
+ _modelLoadProgress = 0;
226
+ try {
227
+ const mod = await import(/* @vite-ignore */ MODEL_CDN);
228
+ if (!mod || typeof mod.pipeline !== 'function') {
229
+ throw new Error('transformers module did not expose pipeline()');
230
+ }
231
+ if (mod.env) {
232
+ // allow remote model download from HF hub
233
+ mod.env.allowRemoteModels = true;
234
+ }
235
+ _extractor = await mod.pipeline('feature-extraction', MODEL_ID, {
236
+ progress_callback: function(ev){
237
+ if (ev && typeof ev.progress === 'number') {
238
+ _modelLoadProgress = Math.max(_modelLoadProgress, ev.progress / 100);
239
+ } else if (ev && ev.status === 'ready') {
240
+ _modelLoadProgress = 1;
241
+ }
242
+ }
243
+ });
244
+ _modelLoadProgress = 1;
245
+ _modelReady = true;
246
+ return _extractor;
247
+ } catch (err) {
248
+ console.warn('[AIEngineerRAG] model load failed, embeddings disabled:', err && err.message);
249
+ _modelReady = false;
250
+ throw err;
251
+ } finally {
252
+ _modelLoading = false;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Embed a single string → Float32Array(384), normalized.
258
+ */
259
+ async function embedText(text) {
260
+ const extractor = await loadModel();
261
+ const out = await extractor(text, { pooling: 'mean', normalize: true });
262
+ // out.data is Float32Array of length 384 when pooled
263
+ const src = out && out.data ? out.data : out;
264
+ const vec = new Float32Array(EMBEDDING_DIM);
265
+ const len = Math.min(src.length, EMBEDDING_DIM);
266
+ for (let i = 0; i < len; i++) vec[i] = src[i];
267
+ // transformers returned normalized already, but enforce for safety
268
+ return normalize(vec);
269
+ }
270
+
271
+ // ===================================================================
272
+ // SEED CORPUS — plain-language machine-element fundamentals
273
+ // All URLs are placeholders marked with example.com. Users can replace
274
+ // via addDocument() with real PDF URLs or textbook citations.
275
+ // ===================================================================
276
+ const SEED_CORPUS = Object.freeze([
277
+ {
278
+ id: 'shigley-ch8-bolted-preload',
279
+ title: 'Shigley\'s Mechanical Engineering Design — Ch. 8, Bolted Joints',
280
+ url: 'https://example.com/shigley-ch8',
281
+ text: 'A preloaded bolted joint carries transverse shear through friction at the faying surface. '
282
+ + 'The slip-resistance capacity equals the number of bolts times the per-bolt clamp load times '
283
+ + 'the friction coefficient. Required clamp force is the applied shear times the target safety '
284
+ + 'factor against slipping (typically 1.25 to 1.5). When the joint is properly preloaded, external '
285
+ + 'axial load is shared between the bolt and the clamped members according to the joint-stiffness '
286
+ + 'ratio, so only a fraction of the external force increases bolt tension. Rule of thumb for steel '
287
+ + 'on steel with clean dry contact: friction coefficient in the range 0.14 to 0.18.'
288
+ },
289
+ {
290
+ id: 'vdi-2230-moment-distribution',
291
+ title: 'VDI 2230 Part 1 — Systematic Calculation of High-Duty Bolted Joints',
292
+ url: 'https://example.com/vdi-2230',
293
+ text: 'When an in-plane moment acts on a bolt pattern arranged on a circle, each bolt carries a '
294
+ + 'tangential share proportional to its distance from the center of rotation. For uniformly '
295
+ + 'spaced bolts on a bolt circle of radius r, the most loaded bolt sees a tangential force equal '
296
+ + 'to M·r divided by the sum of r-squared terms, which simplifies to M divided by z times r. '
297
+ + 'Add to this the axial share from any external tensile force, divided equally across z bolts. '
298
+ + 'The maximum bolt tension after preload is the preload plus this external share multiplied by '
299
+ + 'the load factor Phi, which is typically between 0.1 and 0.3 for rigid joints without gaskets.'
300
+ },
301
+ {
302
+ id: 'shigley-ch5-combined-stress',
303
+ title: 'Shigley\'s — Ch. 5, Failures Resulting from Static Loading',
304
+ url: 'https://example.com/shigley-ch5',
305
+ text: 'The distortion-energy (von Mises) criterion combines normal and shear stresses into an '
306
+ + 'equivalent tensile stress. For a bolt under axial tension sigma and transverse shear tau, the '
307
+ + 'equivalent stress is the square root of sigma-squared plus three times tau-squared. The bolt '
308
+ + 'is safe under static load when the equivalent stress stays below the proof strength R_p0.2 '
309
+ + 'divided by the chosen safety factor. For ISO 898-1 class 10.9 this proof strength is 830 MPa, '
310
+ + 'for 8.8 it is 580 MPa, and for 12.9 it is 970 MPa. Always divide applied force by the stress '
311
+ + 'cross-section area A_s from DIN 13, not by the nominal bolt area.'
312
+ },
313
+ {
314
+ id: 'agma-bending-spur-gear',
315
+ title: 'AGMA 2001 — Fundamental Rating Factors and Calculation Methods for Spur Gears',
316
+ url: 'https://example.com/agma-2001-bending',
317
+ text: 'Spur-gear tooth bending fatigue strength in US customary units follows the linear fit '
318
+ + 'S_t = 77 HB + 12800 psi for through-hardened steels up to about 400 HB. The applied bending '
319
+ + 'stress in the tooth root is computed with the Lewis form factor adjusted for stress '
320
+ + 'concentration (the J-factor) and amplified by load-distribution, dynamic, and size factors. '
321
+ + 'Finite-life safety factor against bending is the allowable stress times life-factor K_L '
322
+ + 'divided by the applied stress times K_T (temperature) and K_R (reliability).'
323
+ },
324
+ {
325
+ id: 'agma-contact-spur-gear',
326
+ title: 'AGMA 2001 — Pitting Resistance (Contact Stress) for Spur Gears',
327
+ url: 'https://example.com/agma-2001-contact',
328
+ text: 'Surface-durability pitting strength for through-hardened steel follows S_c = 322 HB + 29100 psi '
329
+ + 'as a linear fit up to about 400 HB. The applied contact stress uses the Hertzian contact '
330
+ + 'formula with an elastic coefficient C_p that depends on both materials. Tangential load W_t, '
331
+ + 'pitch diameter, face width, and geometry factor I enter the stress. Pitting safety factor '
332
+ + 'against surface fatigue is the allowable contact stress divided by the applied contact stress, '
333
+ + 'both adjusted for reliability, temperature, and lubrication factors.'
334
+ },
335
+ {
336
+ id: 'shigley-ch6-goodman',
337
+ title: 'Shigley\'s — Ch. 6, Fatigue Failure Resulting from Variable Loading',
338
+ url: 'https://example.com/shigley-ch6',
339
+ text: 'The Goodman criterion evaluates shaft fatigue under combined mean and alternating stress. '
340
+ + 'For a steel shaft with mean normal stress sigma_m and alternating normal stress sigma_a, the '
341
+ + 'infinite-life safety factor is one divided by the quantity sigma_a over S_e plus sigma_m over '
342
+ + 'S_ut. S_e is the endurance limit corrected by surface, size, loading, temperature, and '
343
+ + 'reliability factors (the Marin factors). S_ut is the ultimate tensile strength. For shafts '
344
+ + 'under combined bending and torsion, use von Mises equivalent alternating and mean stresses '
345
+ + 'derived from the DE-Goodman equation in Shigley table 6-6.'
346
+ },
347
+ {
348
+ id: 'shigley-ch6-soderberg',
349
+ title: 'Shigley\'s — Ch. 6, Soderberg Criterion (Conservative Yield-Based)',
350
+ url: 'https://example.com/shigley-ch6-soderberg',
351
+ text: 'The Soderberg fatigue criterion is more conservative than Goodman because it compares the '
352
+ + 'mean stress against the yield strength S_y rather than ultimate strength S_ut. The infinite-'
353
+ + 'life safety factor is one over the quantity sigma_a over S_e plus sigma_m over S_y. Soderberg '
354
+ + 'is often preferred when avoiding yielding is at least as important as avoiding fatigue, for '
355
+ + 'example in precision machine elements or safety-critical shafts. Goodman and Soderberg both '
356
+ + 'assume ductile material and fully reversed loading shifted by a mean offset.'
357
+ },
358
+ {
359
+ id: 'iso-281-bearing-life',
360
+ title: 'ISO 281 — Rolling Bearings, Dynamic Load Ratings and Rating Life',
361
+ url: 'https://example.com/iso-281',
362
+ text: 'The basic L10 life of a rolling bearing in millions of revolutions equals the dynamic load '
363
+ + 'rating C divided by the equivalent dynamic load P, raised to the power p. The exponent p is '
364
+ + '3 for ball bearings and 10 over 3 for roller bearings. Convert to operating hours by dividing '
365
+ + 'L10 by the shaft speed in revolutions per minute, then multiplying by one million over sixty. '
366
+ + 'For combined radial F_r and axial F_a loads, P equals X times F_r plus Y times F_a, where '
367
+ + 'X and Y come from the bearing\'s load-direction factors table (function of e and F_a over F_r).'
368
+ },
369
+ {
370
+ id: 'aws-d1-1-fillet-weld',
371
+ title: 'AWS D1.1 — Structural Welding Code for Fillet Welds',
372
+ url: 'https://example.com/aws-d1-1',
373
+ text: 'A fillet weld is sized by its throat dimension t, which for a 45-degree equal-leg weld equals '
374
+ + 'the leg length divided by the square root of two (about 0.707 times the leg). The shear '
375
+ + 'stress on the throat for an applied load F along the weld length L is F divided by the product '
376
+ + 'of t and L. Most codes compare this throat shear to an allowable equal to 0.3 times the '
377
+ + 'electrode ultimate strength for static loads. AWS D1.1 additionally requires the base metal '
378
+ + 'shear check at the fusion face, which uses the leg length (not the throat) and 0.4 S_y of the '
379
+ + 'base metal as the allowable.'
380
+ },
381
+ {
382
+ id: 'aws-electrodes',
383
+ title: 'AWS A5.1 — Carbon Steel Covered Electrodes',
384
+ url: 'https://example.com/aws-a5-1',
385
+ text: 'Common mild-steel welding electrodes are designated E60xx and E70xx in the AWS system, where '
386
+ + 'the first two digits indicate the electrode\'s minimum tensile strength in thousands of psi '
387
+ + '(60 ksi and 70 ksi, respectively, which is 414 MPa and 483 MPa). Allowable shear stress on the '
388
+ + 'weld throat is typically 18 ksi for E60 and 21 ksi for E70 under static loading. For fatigue '
389
+ + 'loading, apply the AISC or Eurocode detail category reductions. Common practice: match '
390
+ + 'electrode strength to or slightly exceed the weaker base metal to avoid overmatched welds '
391
+ + 'that concentrate stress at the heat-affected zone.'
392
+ }
393
+ ]);
394
+
395
+ // ===================================================================
396
+ // CORE OPERATIONS
397
+ // ===================================================================
398
+
399
+ /**
400
+ * @param {object} doc {id, title, url, text}
401
+ * @returns {Promise<{chunkCount:number}>}
402
+ */
403
+ async function addDocument(doc) {
404
+ if (!doc || !doc.id || !doc.text) {
405
+ throw new Error('addDocument requires {id, text} (title and url optional)');
406
+ }
407
+ const pieces = chunkText(doc.text, CHUNK_SIZE, CHUNK_OVERLAP);
408
+ if (pieces.length === 0) {
409
+ return { chunkCount: 0 };
410
+ }
411
+
412
+ for (let i = 0; i < pieces.length; i++) {
413
+ const piece = pieces[i];
414
+ let embedding = null;
415
+ try {
416
+ embedding = await embedText(piece);
417
+ } catch (err) {
418
+ // degrade to zero-vector so the chunk is still retrievable by listing
419
+ embedding = new Float32Array(EMBEDDING_DIM);
420
+ }
421
+ await putChunk({
422
+ id: doc.id + '#' + i,
423
+ docId: doc.id,
424
+ title: doc.title || doc.id,
425
+ url: doc.url || '',
426
+ text: piece,
427
+ embedding: embedding,
428
+ passageIndex: i,
429
+ total: pieces.length
430
+ });
431
+ }
432
+
433
+ await putDocument({
434
+ id: doc.id,
435
+ title: doc.title || doc.id,
436
+ url: doc.url || '',
437
+ addedAt: Date.now(),
438
+ chunkCount: pieces.length
439
+ });
440
+
441
+ return { chunkCount: pieces.length };
442
+ }
443
+
444
+ /**
445
+ * @param {string} text
446
+ * @param {object} [opts] {topK}
447
+ * @returns {Promise<Array<{chunk, score, docId, title, url, passageIndex}>>}
448
+ */
449
+ async function query(text, opts) {
450
+ opts = opts || {};
451
+ const topK = Math.max(1, Math.min(20, opts.topK || 3));
452
+
453
+ if (!text || typeof text !== 'string' || text.trim() === '') return [];
454
+
455
+ let queryVec;
456
+ try {
457
+ queryVec = await embedText(text);
458
+ } catch (err) {
459
+ console.warn('[AIEngineerRAG] query embedding failed, returning []');
460
+ return [];
461
+ }
462
+
463
+ const chunks = await getAllChunks();
464
+ if (chunks.length === 0) return [];
465
+
466
+ const scored = [];
467
+ for (let i = 0; i < chunks.length; i++) {
468
+ const c = chunks[i];
469
+ const emb = c.embedding instanceof Float32Array
470
+ ? c.embedding
471
+ : new Float32Array(c.embedding || []);
472
+ const score = cosineSimilarity(queryVec, emb);
473
+ scored.push({
474
+ chunk: c.text,
475
+ score: score,
476
+ docId: c.docId,
477
+ title: c.title,
478
+ url: c.url,
479
+ passageIndex: c.passageIndex
480
+ });
481
+ }
482
+
483
+ scored.sort(function(a, b){ return b.score - a.score; });
484
+ return scored.slice(0, topK);
485
+ }
486
+
487
+ async function listDocuments() {
488
+ const docs = await getAllDocuments();
489
+ return docs.map(function(d){
490
+ return {
491
+ id: d.id,
492
+ title: d.title,
493
+ url: d.url,
494
+ chunkCount: d.chunkCount || 0
495
+ };
496
+ });
497
+ }
498
+
499
+ async function clearAll() {
500
+ await clearStores();
501
+ }
502
+
503
+ // ===================================================================
504
+ // SEEDING
505
+ // ===================================================================
506
+
507
+ async function seedIfEmpty() {
508
+ const docs = await getAllDocuments();
509
+ if (docs.length > 0) return false;
510
+ for (let i = 0; i < SEED_CORPUS.length; i++) {
511
+ try {
512
+ await addDocument(SEED_CORPUS[i]);
513
+ } catch (err) {
514
+ console.warn('[AIEngineerRAG] seed failed for', SEED_CORPUS[i].id, err && err.message);
515
+ }
516
+ }
517
+ return true;
518
+ }
519
+
520
+ // ===================================================================
521
+ // INIT
522
+ // ===================================================================
523
+
524
+ async function init() {
525
+ if (_initPromise) return _initPromise;
526
+ _initCalled = true;
527
+ _initPromise = (async function(){
528
+ // 1. open IDB (optional — fall back to memory)
529
+ try {
530
+ _db = await openDB();
531
+ _usingIDB = true;
532
+ } catch (err) {
533
+ console.warn('[AIEngineerRAG] IndexedDB unavailable, using in-memory store:', err && err.message);
534
+ _db = null;
535
+ _usingIDB = false;
536
+ }
537
+
538
+ // 2. kick off model load in background — don't block init
539
+ loadModel().catch(function(){ /* already warned inside loadModel */ });
540
+
541
+ // 3. wait for model, then seed if empty (seeding needs embeddings)
542
+ try {
543
+ await loadModel();
544
+ await seedIfEmpty();
545
+ } catch (err) {
546
+ // seed with zero-vector embeddings as fallback so at least listDocuments works
547
+ const docs = await getAllDocuments();
548
+ if (docs.length === 0) {
549
+ for (let i = 0; i < SEED_CORPUS.length; i++) {
550
+ try { await addDocument(SEED_CORPUS[i]); } catch (e) {/* no-op */}
551
+ }
552
+ }
553
+ }
554
+
555
+ return true;
556
+ })();
557
+ return _initPromise;
558
+ }
559
+
560
+ function isReady() {
561
+ return _initCalled && _modelReady;
562
+ }
563
+
564
+ function getModelLoadProgress() {
565
+ return _modelLoadProgress;
566
+ }
567
+
568
+ // ===================================================================
569
+ // CITATION UI
570
+ // ===================================================================
571
+
572
+ /**
573
+ * Build a footnote-style citation block from query() results.
574
+ * @param {Array<{chunk, score, docId, title, url, passageIndex}>} results
575
+ * @returns {HTMLElement}
576
+ */
577
+ function buildCitationUI(results) {
578
+ const wrap = document.createElement('div');
579
+ wrap.className = 'cc-rag-citations';
580
+ wrap.style.cssText = [
581
+ 'margin-top:12px',
582
+ 'padding:12px 14px',
583
+ 'background:#0f1419',
584
+ 'border:1px solid #1f2a33',
585
+ 'border-left:3px solid #10b981',
586
+ 'border-radius:6px',
587
+ 'color:#d1d5db',
588
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
589
+ 'font-size:12.5px',
590
+ 'line-height:1.55'
591
+ ].join(';');
592
+
593
+ const header = document.createElement('div');
594
+ header.textContent = 'Sources (' + (results ? results.length : 0) + ')';
595
+ header.style.cssText = [
596
+ 'font-family:"SF Mono",Menlo,monospace',
597
+ 'font-size:11px',
598
+ 'font-weight:600',
599
+ 'color:#10b981',
600
+ 'text-transform:uppercase',
601
+ 'letter-spacing:0.08em',
602
+ 'margin-bottom:8px'
603
+ ].join(';');
604
+ wrap.appendChild(header);
605
+
606
+ if (!results || results.length === 0) {
607
+ const empty = document.createElement('div');
608
+ empty.textContent = 'No sources found.';
609
+ empty.style.cssText = 'color:#6b7280;font-style:italic';
610
+ wrap.appendChild(empty);
611
+ return wrap;
612
+ }
613
+
614
+ const list = document.createElement('ol');
615
+ list.style.cssText = 'margin:0;padding-left:18px;display:flex;flex-direction:column;gap:8px';
616
+
617
+ for (let i = 0; i < results.length; i++) {
618
+ const r = results[i];
619
+ const item = document.createElement('li');
620
+ item.style.cssText = 'color:#d1d5db';
621
+
622
+ const title = document.createElement('div');
623
+ title.style.cssText = 'display:flex;align-items:baseline;gap:8px;flex-wrap:wrap';
624
+
625
+ const titleText = document.createElement('span');
626
+ titleText.textContent = r.title || r.docId;
627
+ titleText.style.cssText = 'font-weight:600;color:#e5e7eb';
628
+ title.appendChild(titleText);
629
+
630
+ if (r.url) {
631
+ const link = document.createElement('a');
632
+ link.href = r.url;
633
+ link.target = '_blank';
634
+ link.rel = 'noopener noreferrer';
635
+ link.textContent = 'Open Document \u2197';
636
+ link.style.cssText = [
637
+ 'color:#10b981',
638
+ 'text-decoration:none',
639
+ 'font-family:"SF Mono",Menlo,monospace',
640
+ 'font-size:11px',
641
+ 'padding:2px 6px',
642
+ 'border:1px solid #1f3a2e',
643
+ 'border-radius:3px',
644
+ 'background:#0a1814'
645
+ ].join(';');
646
+ title.appendChild(link);
647
+ }
648
+
649
+ const score = document.createElement('span');
650
+ score.textContent = 'score ' + (r.score != null ? r.score.toFixed(3) : '0.000');
651
+ score.style.cssText = 'font-family:"SF Mono",Menlo,monospace;font-size:10.5px;color:#6b7280';
652
+ title.appendChild(score);
653
+
654
+ item.appendChild(title);
655
+
656
+ const snippet = document.createElement('div');
657
+ const text = r.chunk || '';
658
+ snippet.textContent = text.length > 120 ? text.slice(0, 120).trim() + '...' : text;
659
+ snippet.style.cssText = 'margin-top:4px;color:#9ca3af;font-size:12px;line-height:1.5';
660
+ item.appendChild(snippet);
661
+
662
+ list.appendChild(item);
663
+ }
664
+
665
+ wrap.appendChild(list);
666
+ return wrap;
667
+ }
668
+
669
+ // ===================================================================
670
+ // PUBLIC API
671
+ // ===================================================================
672
+ window.CycleCAD.AIEngineerRAG = {
673
+ init: init,
674
+ addDocument: addDocument,
675
+ query: query,
676
+ listDocuments: listDocuments,
677
+ clearAll: clearAll,
678
+ buildCitationUI: buildCitationUI,
679
+ isReady: isReady,
680
+ getModelLoadProgress: getModelLoadProgress,
681
+ // internals exposed for testing (not part of the stable contract)
682
+ _chunkText: chunkText,
683
+ _cosineSimilarity: cosineSimilarity,
684
+ _normalize: normalize,
685
+ _SEED_CORPUS: SEED_CORPUS
686
+ };
687
+
688
+ console.log('[AIEngineerRAG] RAG v1 loaded');
689
+ })();