| 281 | } |
| 282 | |
| 283 | export async function scanPageContent(text: string): Promise<LayerSignal> { |
| 284 | if (!text || text.length === 0) { |
| 285 | return { layer: 'testsavant_content', confidence: 0 }; |
| 286 | } |
| 287 | if (testsavantState !== 'loaded') { |
| 288 | return { layer: 'testsavant_content', confidence: 0, meta: { degraded: true } }; |
| 289 | } |
| 290 | try { |
| 291 | // Normalize to plain text first — the classifier is trained on natural |
| 292 | // language, not HTML markup. A page with an injection buried in tag |
| 293 | // soup won't fire until we strip the noise. |
| 294 | const plain = htmlToPlainText(text); |
| 295 | // Character-level cap to avoid pathological memory use. The pipeline |
| 296 | // applies tokenizer truncation at 512 tokens (the BERT-small context |
| 297 | // limit — enforced via the model_max_length override in loadTestsavant) |
| 298 | // so the 4000-char cap is just a cheap upper bound. Real-world |
| 299 | // injection signals land in the first few hundred tokens anyway. |
| 300 | const input = plain.slice(0, 4000); |
| 301 | const raw = await testsavantClassifier(input); |
| 302 | const top = Array.isArray(raw) ? raw[0] : raw; |
| 303 | const label = top?.label ?? 'SAFE'; |
| 304 | const score = Number(top?.score ?? 0); |
| 305 | if (label === 'INJECTION') { |
| 306 | return { layer: 'testsavant_content', confidence: score, meta: { label } }; |
| 307 | } |
| 308 | return { layer: 'testsavant_content', confidence: 0, meta: { label, safeScore: score } }; |
| 309 | } catch (err: any) { |
| 310 | testsavantState = 'failed'; |
| 311 | testsavantLoadError = err?.message ?? String(err); |
| 312 | return { layer: 'testsavant_content', confidence: 0, meta: { degraded: true, error: testsavantLoadError } }; |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // ─── L4c: DeBERTa-v3 ensemble (opt-in) ─────────────────────── |
| 317 | |