/** * 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(" 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>/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); }