| |
| |
| 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" }, |
| }); |
| } |
|
|
| |
| 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", |
| }, |
| }); |
| }); |
|
|