File size: 7,355 Bytes
cc11e77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
// S795 — SemanticSearch.ts: retrieval semantico con embedding pre-calcolati (P31-RI)
//
// ARCHITETTURA:
//   Dev-time: scripts/gen-repo-embeddings.mjs genera public/repo-embeddings.json
//             usando @xenova/transformers + all-MiniLM-L6-v2 (Node.js).
//   Runtime:  SemanticSearch.ts carica il JSON statico + embeds la query con
//             Transformers.js (WebAssembly, browser-native, ~23MB download cached IDB).
//             Fallback: Jaccard se embedding non disponibile o timeout.
//
// Integrazione: esportare `semanticGetRelevantFiles` come drop-in per `GetRelevantFilesFn`
// in relevantFilesLoader.ts e agentLoop.ts.

import type { RelevantFileHit } from "./relevantFilesLoader";

/** Struttura di un'entry nel JSON pre-calcolato */
export interface RepoEmbeddingEntry {
  /** path relativo alla root del repo */
  path:        string;
  /** descrizione sintetica del file (da commento header) */
  description: string;
  /** keywords per fallback Jaccard */
  keywords:    string[];
  /** embedding float32 all-MiniLM-L6-v2 (dim=384), omesso se not computed */
  embedding?:  number[];
}

// ── Cache in-memory ────────────────────────────────────────────────────────────
let _entries: RepoEmbeddingEntry[] | null = null;
let _embedderP: Promise<((text: string) => Promise<number[]>) | null> | null = null;

// ── Carica embeddings JSON dal bundle statico ─────────────────────────────────
const EMBEDDINGS_URL = "/repo-embeddings.json";

async function _loadEntries(): Promise<RepoEmbeddingEntry[]> {
  if (_entries) return _entries;
  try {
    const r = await fetch(EMBEDDINGS_URL, { cache: "force-cache" });
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    _entries = await r.json() as RepoEmbeddingEntry[];
    return _entries;
  } catch (e) {
    console.warn("[SemanticSearch] repo-embeddings.json non disponibile:", e);
    _entries = [];
    return [];
  }
}

// ── Carica l'embedder Transformers.js (lazy, singleton) ──────────────────────
async function _getEmbedder(): Promise<((text: string) => Promise<number[]>) | null> {
  if (_embedderP) return _embedderP;
  _embedderP = (async () => {
    try {
      // @xenova/transformers funziona nel browser via WebAssembly (ONNX Runtime).
      // Il modello all-MiniLM-L6-v2 (quantized) è ~23MB, cached in IndexedDB dopo il primo download.
      // @ts-ignore — CDN URL import, no type declarations available
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      const { pipeline } = await import(/* webpackIgnore: true */ "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js" as any) as any;
      const extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", {
        quantized: true,
        progress_callback: undefined,
      });
      return async (text: string): Promise<number[]> => {
        const out = await extractor(text, { pooling: "mean", normalize: true });
        return Array.from(out.data as Float32Array);
      };
    } catch (e) {
      console.warn("[SemanticSearch] Transformers.js non disponibile:", e);
      return null;
    }
  })();
  return _embedderP;
}

// ── Cosine similarity ─────────────────────────────────────────────────────────
function _cosine(a: number[], b: number[]): number {
  let dot = 0, na = 0, nb = 0;
  const len = Math.min(a.length, b.length);
  for (let i = 0; i < len; i++) { dot += a[i] * b[i]; na += a[i] ** 2; nb += b[i] ** 2; }
  if (na === 0 || nb === 0) return 0;
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
}

// ── Fallback Jaccard (keyword overlap) ───────────────────────────────────────
function _jaccard(query: string, entry: RepoEmbeddingEntry): number {
  const qTokens = new Set(query.toLowerCase().split(/\W+/).filter(t => t.length > 2));
  if (qTokens.size === 0) return 0;
  const eTokens = new Set([
    ...entry.keywords,
    ...entry.path.toLowerCase().split(/[/._-]+/),
    ...entry.description.toLowerCase().split(/\W+/).filter(t => t.length > 2),
  ]);
  let inter = 0;
  qTokens.forEach(t => { if (eTokens.has(t)) inter++; });
  return inter / (qTokens.size + eTokens.size - inter);
}

// ── API pubblica ──────────────────────────────────────────────────────────────

/**
 * Cerca i file più semanticamente rilevanti alla query.
 * Drop-in per `GetRelevantFilesFn` di relevantFilesLoader.ts.
 *
 * @param query      testo della query utente
 * @param topK       numero max di risultati (default 6)
 * @param minScore   soglia minima di score (default 0.15)
 * @param timeoutMs  timeout per embedding (default 3000ms, poi fallback Jaccard)
 */
export async function semanticGetRelevantFiles(
  query:    string,
  topK      = 6,
  minScore  = 0.15,
  timeoutMs = 3000,
): Promise<RelevantFileHit[]> {
  const entries = await _loadEntries();
  if (entries.length === 0) return [];

  // Controlla se ci sono entries con embedding
  const hasEmbeddings = entries.some(e => e.embedding && e.embedding.length > 0);

  let scores: Array<{ entry: RepoEmbeddingEntry; score: number }>;

  if (hasEmbeddings) {
    // Tenta embedding della query con timeout
    const embedder = await Promise.race([
      _getEmbedder(),
      new Promise<null>(r => setTimeout(() => r(null), timeoutMs)),
    ]);

    if (embedder) {
      // Embedding semantico
      const qEmb = await embedder(query);
      scores = entries
        .filter(e => e.embedding && e.embedding.length > 0)
        .map(e => ({ entry: e, score: _cosine(qEmb, e.embedding!) }));
    } else {
      // Timeout → fallback Jaccard sull'embedding delle keywords
      console.info("[SemanticSearch] timeout embedder → fallback Jaccard");
      scores = entries.map(e => ({ entry: e, score: _jaccard(query, e) }));
    }
  } else {
    // JSON senza embedding → Jaccard puro (modalità keyword-only)
    scores = entries.map(e => ({ entry: e, score: _jaccard(query, e) }));
  }

  return scores
    .filter(s => s.score >= minScore)
    .sort((a, b) => b.score - a.score)
    .slice(0, topK)
    .map(s => ({
      path:    s.entry.path,
      content: s.entry.description,  // placeholder — il contenuto vero arriva da VFS
      score:   s.score,
    }));
}

/**
 * Precaricare l'embedder in background (chiama subito dopo il mount dell'app
 * per non pagare il cold-start durante la prima query utente).
 */
export function warmupEmbedder(): void {
  _getEmbedder().catch(() => { /* non-blocking */ });
}

/**
 * Stato del sistema embedding per diagnostica.
 */
export function getSemanticSearchStatus(): {
  entriesLoaded: number;
  hasEmbeddings: boolean;
  embedderReady: boolean;
} {
  return {
    entriesLoaded: _entries?.length ?? 0,
    hasEmbeddings: !!(_entries?.some(e => e.embedding && e.embedding.length > 0)),
    embedderReady: _embedderP !== null,
  };
}