AUDIT / src /lib /artifacts /ArtifactExtractor.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 3)
95eb75a verified
Raw
History Blame Contribute Delete
6.14 kB
/**
* ArtifactExtractor.ts — Parse agent LLM responses to extract workspace artifacts (FASE 7)
*
* Detects code blocks, HTML pages, markdown headers, JSON blobs, CSV tables
* in raw LLM text output and converts them to Artifact objects ready for storage.
*
* Heuristics (in priority order):
* 1. Fenced code blocks with explicit language tag: ```python … ```
* 2. ```html … ``` → html kind
* 3. ```json … ``` → json kind
* 4. ```csv … ``` → csv kind
* 5. ```svg … ``` → svg kind
* 6. ```markdown … ``` or ``` … ``` that looks like markdown → markdown kind
* 7. Long code block without tag (≥10 lines) → code / text kind
*/
import type { Artifact, ArtifactKind } from "./ArtifactStore";
// ── Language → kind mapping ───────────────────────────────────────────────────
const CODE_LANGS = new Set([
"python","py","javascript","js","typescript","ts","jsx","tsx",
"rust","go","java","c","cpp","c++","c#","csharp","bash","sh","shell",
"ruby","rb","php","swift","kotlin","r","scala","dart","lua",
"yaml","toml","sql","graphql","regex",
]);
function langToKind(lang: string): ArtifactKind {
const l = lang.toLowerCase().trim();
if (l === "html") return "html";
if (l === "json") return "json";
if (l === "csv") return "csv";
if (l === "svg") return "svg";
if (l === "markdown" || l === "md") return "markdown";
if (CODE_LANGS.has(l)) return "code";
return "code"; // default
}
function inferKindFromContent(content: string): ArtifactKind {
const trimmed = content.trim();
if (trimmed.startsWith("<html") || (trimmed.startsWith("<!") && /doctype/i.test(trimmed))) return "html";
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try { JSON.parse(trimmed); return "json"; } catch { /* not json */ }
}
if (trimmed.startsWith("<svg")) return "svg";
const lines = trimmed.split("\n");
const hasCommas = lines.slice(0, 5).every(l => l.includes(","));
if (hasCommas && lines.length >= 2) return "csv";
if (/^#{1,6} /m.test(trimmed)) return "markdown";
return "code";
}
// ── Title inference ───────────────────────────────────────────────────────────
const TITLE_MAX = 48;
function inferTitle(kind: ArtifactKind, language: string | undefined, content: string, idx: number): string {
// Try to extract a meaningful name from the content
const lines = content.trim().split("\n");
if (kind === "html") {
const m = content.match(/<title>([^<]+)<\/title>/i);
if (m) return m[1].trim().slice(0, TITLE_MAX);
const h1 = content.match(/<h1[^>]*>([^<]+)<\/h1>/i);
if (h1) return h1[1].trim().slice(0, TITLE_MAX);
return "Pagina HTML";
}
if (kind === "json") {
try {
const j = JSON.parse(content.trim());
if (j.name) return String(j.name).slice(0, TITLE_MAX);
if (j.title) return String(j.title).slice(0, TITLE_MAX);
} catch { /* ok */ }
return "Dati JSON";
}
if (kind === "csv") {
const header = lines[0].replace(/,/g, " / ").trim();
return header.slice(0, TITLE_MAX) || "Tabella CSV";
}
if (kind === "svg") return "Immagine SVG";
if (kind === "markdown") {
const h1 = content.match(/^#{1,3} (.+)/m);
if (h1) return h1[1].trim().slice(0, TITLE_MAX);
return "Documento";
}
// code — look for function name, class name, filename comment
const commentFile = content.match(/^#\s+(?:file:\s*)?([a-zA-Z0-9_.\-/]+\.[a-z]{1,5})/m);
if (commentFile) return commentFile[1].slice(0, TITLE_MAX);
const fnName = content.match(/^(?:def|function|fn|class|func)\s+(\w+)/m);
if (fnName) return `${language ?? "codice"}: ${fnName[1]}`.slice(0, TITLE_MAX);
return `${language ?? kind} snippet ${idx + 1}`;
}
// ── Unique IDs ────────────────────────────────────────────────────────────────
let _counter = 0;
function shortId(): string {
return `${Date.now().toString(36)}_${(++_counter).toString(36)}`;
}
// ── Fenced code block regex ───────────────────────────────────────────────────
const FENCE_RE = /```([a-zA-Z0-9_+#\-.]*)\n([\s\S]*?)```/g;
// ── Main extraction function ──────────────────────────────────────────────────
export interface ExtractOptions {
sessionId?: string;
messageId?: string;
minLines?: number; // minimum lines to consider an artifact (default 4)
}
export function extractArtifacts(
text: string,
opts: ExtractOptions = {},
): Artifact[] {
const { sessionId, messageId, minLines = 4 } = opts;
const now = Date.now();
const found: Artifact[] = [];
let idx = 0;
let match: RegExpExecArray | null;
FENCE_RE.lastIndex = 0;
while ((match = FENCE_RE.exec(text)) !== null) {
const rawLang = (match[1] ?? "").toLowerCase().trim();
const content = match[2].trim();
if (!content || content.split("\n").length < minLines) continue;
const language = rawLang || undefined;
const kind = rawLang ? langToKind(rawLang) : inferKindFromContent(content);
const title = inferTitle(kind, language, content, idx);
found.push({
id: shortId(),
kind,
language,
title,
content,
sessionId,
messageId,
createdAt: now,
updatedAt: now,
version: 1,
pinned: false,
tags: language ? [language] : [],
});
idx++;
}
return found;
}
/**
* Quick check — does this text contain anything worth extracting?
* Cheap scan before running full extraction.
*/
export function hasExtractableContent(text: string): boolean {
return FENCE_RE.test(text);
}