// Streams structured study notes (Markdown) from Gemini 2.5 Pro for a given document. // Frontend reads SSE deltas; this function also persists the final markdown to `notes`. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version", }; const SYSTEM_PROMPT = `You are an Expert Academic Data Extraction Agent. Your directive is to perform a **lossless, exhaustive extraction** of the provided educational document into a professional, clean Markdown format. **STYLE CONSTRAINTS:** - **NO EMOJIS:** You are strictly forbidden from using any emojis in the output. - **PROFESSIONAL TONE:** Use clear, academic language. - **EXHAUSTIVE DETAIL:** Do not summarize. Capture every definition, example, and scenario in full. **REQUIRED OUTPUT STRUCTURE:** # [Document Title] **Overview:** This note covering [Topic] was created from a [Page Count] page document. It provides a structured walk-through of [Primary Goal of Document]. ## Key Points - [High-level topic 1] - [High-level topic 2] - [High-level topic 3] - [High-level topic 4] ## [Section Heading] [Detailed introduction to the section] ### [Sub-topic or Framework Item] **Full Definition:** [Extract exhaustive definition word-for-word] **Detailed Example/Context:** [Extract full context and examples] **Question:** [If a scenario is presented, format the question here] **Answer:** [Format the solution/result here] *(Use Tables for Frameworks like SHAPES or Ancillary Skills lists)* | Item | Meaning/Why Important | Example | |------|----------------------|---------| | [X] | [Y] | [Z] | ## Key Scenarios & Practical Applications **Scenario:** [Extract general scenarios] **Solution/Takeaway:** [Extract solution] ## Final Summary & Synthesis [A comprehensive wrap-up of the document's value] **FINAL DIRECTIVE:** Maintain 100% fidelity to the source text. Every sub-topic from the document must have its own detailed heading. Never group distinct items into a single paragraph.`; Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); const authHeader = req.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const ANON = Deno.env.get("SUPABASE_ANON_KEY")!; const SERVICE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const GROQ_API_KEY = Deno.env.get("GROQ_API_KEY"); if (!GROQ_API_KEY) { return new Response(JSON.stringify({ error: "GROQ_API_KEY not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const userClient = createClient(SUPABASE_URL, ANON, { global: { headers: { Authorization: authHeader } }, }); const admin = createClient(SUPABASE_URL, SERVICE); const token = authHeader.replace("Bearer ", ""); const { data: { user }, error: userErr } = await userClient.auth.getUser(token); if (userErr || !user) { const msg = userErr ? userErr.message : "User not found"; return new Response(JSON.stringify({ error: "Unauthorized", details: msg }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const userId = user.id; let body: { document_id?: string }; try { body = await req.json(); } catch { return new Response(JSON.stringify({ error: "Invalid JSON" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const documentId = body.document_id; if (!documentId) { return new Response(JSON.stringify({ error: "document_id required" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const { data: doc } = await admin .from("documents") .select("id,user_id,title,raw_text,status") .eq("id", documentId) .maybeSingle(); if (!doc || doc.user_id !== userId) { return new Response(JSON.stringify({ error: "Document not found" }), { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } if (!doc.raw_text || doc.raw_text.trim().length < 20) { return new Response(JSON.stringify({ error: "Document not ready" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // Groq llama-3.3-70b-versatile context: ~32k tokens; keep source under ~80k chars to be safe const MAX_CHARS = 80_000; const source = doc.raw_text.length > MAX_CHARS ? doc.raw_text.slice(0, MAX_CHARS) : doc.raw_text; const userPrompt = `Source title: ${doc.title}\n\n--- SOURCE START ---\n${source}\n--- SOURCE END ---\n\n` + `Generate the study notes now, following the required structure exactly.`; const aiResp = await fetch("https://api.groq.com/openai/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${GROQ_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "llama-3.3-70b-versatile", stream: true, temperature: 0.4, messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: userPrompt }, ], }), }); if (!aiResp.ok || !aiResp.body) { if (aiResp.status === 429) { return new Response( JSON.stringify({ error: "Rate limit reached on Groq's free tier — please wait a moment and try again." }), { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }, ); } const t = await aiResp.text(); console.error("Groq error", aiResp.status, t); return new Response(JSON.stringify({ error: "Groq API error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } let fullMarkdown = ""; const encoder = new TextEncoder(); const decoder = new TextDecoder(); const stream = new ReadableStream({ async start(controller) { const reader = aiResp.body!.getReader(); let textBuffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; controller.enqueue(value); textBuffer += decoder.decode(value, { stream: true }); let idx: number; while ((idx = textBuffer.indexOf("\n")) !== -1) { let line = textBuffer.slice(0, idx); textBuffer = textBuffer.slice(idx + 1); if (line.endsWith("\r")) line = line.slice(0, -1); if (!line.startsWith("data: ")) continue; const json = line.slice(6).trim(); if (json === "[DONE]") continue; try { const parsed = JSON.parse(json); const delta = parsed.choices?.[0]?.delta?.content; if (delta) fullMarkdown += delta; } catch { textBuffer = line + "\n" + textBuffer; break; } } } } catch (e) { console.error("stream error", e); } finally { controller.close(); try { if (fullMarkdown.trim().length > 0) { const { data: existing } = await admin .from("notes") .select("id") .eq("document_id", documentId) .maybeSingle(); if (existing) { await admin .from("notes") .update({ markdown: fullMarkdown }) .eq("id", existing.id); } else { await admin .from("notes") .insert({ document_id: documentId, user_id: userId, markdown: fullMarkdown }); } } } catch (e) { console.error("note persist error", e); } } }, }); return new Response(stream, { headers: { ...corsHeaders, "Content-Type": "text/event-stream", "Cache-Control": "no-cache", }, }); });