diff --git a/supabase/functions/generate-leads/index.ts b/supabase/functions/generate-leads/index.ts index 4818ebb..39cad03 100644 --- a/supabase/functions/generate-leads/index.ts +++ b/supabase/functions/generate-leads/index.ts @@ -2,19 +2,23 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; 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", + "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 STORSTOCKHOLM = [ + "Stockholm", "Solna", "Sundbyberg", "Täby", "Danderyd", "Lidingö", + "Nacka", "Sollentuna", "Järfälla", "Upplands Väsby", "Huddinge", + "Botkyrka", "Haninge", "Tyresö", "Nynäshamn", "Vallentuna", "Vaxholm", + "Österåker", "Ekerö", "Salem", "Nykvarn", "Södertälje", +]; + serve(async (req) => { - if (req.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders }); - } + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); try { const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); - if (!LOVABLE_API_KEY) { - throw new Error("LOVABLE_API_KEY is not configured"); - } + if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured"); const response = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { method: "POST", @@ -27,35 +31,29 @@ serve(async (req) => { messages: [ { role: "system", - content: `Du är en B2B-säljexpert som identifierar potentiella kunder för ett bageri som levererar färska kakor till företag i Storstockholm. - -Generera en lista med 15 verkliga eller realistiska företag i Storstockholmsområdet som skulle vara bra kunder för veckovisa kakleveranser. -Fokusera på: -- Kontor med 10+ anställda -- Hotell och restauranger -- Caféer och fik -- Eventlokaler -- Coworking-spaces -- Större företag med personalrum/fika + content: `Du är B2B-säljare för Lennart Svenssons Konditorivaror, ett bageri i Stockholm som levererar färska handgjorda kakor på abonnemang till företag. -För varje företag, ange: -- Företagsnamn -- Bransch -- Uppskattat antal anställda -- Stadsdel i Stockholm -- Varför de är en bra kund (kort)` +Din uppgift: identifiera potentiella B2B-kunder i STORSTOCKHOLM. + +ABSOLUTA REGLER: +- Endast företag inom följande kommuner räknas som Storstockholm: +${STORSTOCKHOLM.map((k) => ` • ${k}`).join("\n")} +- Företag utanför Storstockholm får ALDRIG genereras. +- Använd endast verifierbar, realistisk information. Hitta inte på fakta. +- Fokusera på kontor 10+ anställda, hotell, restauranger, coworking, eventlokaler, större företag med personalrum/fika. +- Variera bransch och kommun – inte bara innerstaden.`, }, { role: "user", - content: "Generera 15 potentiella B2B-kunder för kakleveranser i Storstockholm." - } + content: "Generera 15 potentiella B2B-kunder för kakabonnemang i Storstockholm.", + }, ], tools: [ { type: "function", function: { name: "generate_leads", - description: "Returnerar en lista med potentiella B2B-kunder", + description: "Returnerar en lista med potentiella B2B-kunder i Storstockholm", parameters: { type: "object", properties: { @@ -64,38 +62,39 @@ För varje företag, ange: items: { type: "object", properties: { - company_name: { type: "string", description: "Företagets namn" }, - industry: { type: "string", description: "Bransch" }, - employee_count: { type: "number", description: "Antal anställda" }, - district: { type: "string", description: "Stadsdel i Stockholm" }, - reason: { type: "string", description: "Varför de är en bra kund" } + company_name: { type: "string" }, + industry: { type: "string" }, + employee_count: { type: "number" }, + district: { + type: "string", + description: "Kommun i Storstockholm (t.ex. Solna, Nacka, Södertälje)", + }, + reason: { type: "string" }, }, required: ["company_name", "industry", "employee_count", "district", "reason"], - additionalProperties: false - } - } + additionalProperties: false, + }, + }, }, required: ["leads"], - additionalProperties: false - } - } - } + additionalProperties: false, + }, + }, + }, ], - tool_choice: { type: "function", function: { name: "generate_leads" } } + tool_choice: { type: "function", function: { name: "generate_leads" } }, }), }); if (!response.ok) { if (response.status === 429) { return new Response(JSON.stringify({ error: "Rate limit överskriden, försök igen senare." }), { - status: 429, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } if (response.status === 402) { return new Response(JSON.stringify({ error: "Krediter slut, vänligen fyll på." }), { - status: 402, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const errorText = await response.text(); @@ -104,24 +103,26 @@ För varje företag, ange: } const data = await response.json(); - - // Extract the tool call result const toolCall = data.choices?.[0]?.message?.tool_calls?.[0]; if (!toolCall || toolCall.function.name !== "generate_leads") { throw new Error("Unexpected AI response format"); } + const parsed = JSON.parse(toolCall.function.arguments); - const leads = JSON.parse(toolCall.function.arguments); + // Extra säkerhet: filtrera bort allt utanför Storstockholm + const allowed = new Set(STORSTOCKHOLM.map((s) => s.toLowerCase())); + const leads = (parsed.leads || []).filter((l: any) => + allowed.has(String(l.district || "").toLowerCase().trim()), + ); - return new Response(JSON.stringify(leads), { + return new Response(JSON.stringify({ leads }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } catch (error) { console.error("Error generating leads:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error"; return new Response(JSON.stringify({ error: errorMessage }), { - status: 500, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } }); diff --git a/supabase/functions/research-company/index.ts b/supabase/functions/research-company/index.ts index 18b3d85..f76a50e 100644 --- a/supabase/functions/research-company/index.ts +++ b/supabase/functions/research-company/index.ts @@ -2,9 +2,17 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; 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", + "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 STORSTOCKHOLM = [ + "Stockholm", "Solna", "Sundbyberg", "Täby", "Danderyd", "Lidingö", + "Nacka", "Sollentuna", "Järfälla", "Upplands Väsby", "Huddinge", + "Botkyrka", "Haninge", "Tyresö", "Nynäshamn", "Vallentuna", "Vaxholm", + "Österåker", "Ekerö", "Salem", "Nykvarn", "Södertälje", +]; + interface CompanyResearchRequest { company_name: string; district: string; @@ -12,30 +20,31 @@ interface CompanyResearchRequest { } serve(async (req) => { - if (req.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders }); - } + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); try { const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY"); const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); - - if (!FIRECRAWL_API_KEY) { - throw new Error("FIRECRAWL_API_KEY is not configured"); - } - if (!LOVABLE_API_KEY) { - throw new Error("LOVABLE_API_KEY is not configured"); - } + if (!FIRECRAWL_API_KEY) throw new Error("FIRECRAWL_API_KEY is not configured"); + if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured"); const { company_name, district, industry }: CompanyResearchRequest = await req.json(); + if (!company_name) throw new Error("company_name is required"); - if (!company_name) { - throw new Error("company_name is required"); + // Storstockholm-kontroll + const allowed = new Set(STORSTOCKHOLM.map((s) => s.toLowerCase())); + if (!allowed.has(String(district || "").toLowerCase().trim())) { + return new Response( + JSON.stringify({ + error: `Vi kontaktar endast företag i Storstockholm. "${district}" ligger utanför området.`, + }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); } - console.log(`Researching company: ${company_name}`); + console.log(`Researching company: ${company_name} (${district})`); - // Step 1: Search for company website using Firecrawl + // Steg 1: Research via Firecrawl const searchResponse = await fetch("https://api.firecrawl.dev/v1/search", { method: "POST", headers: { @@ -43,26 +52,40 @@ serve(async (req) => { "Content-Type": "application/json", }, body: JSON.stringify({ - query: `${company_name} ${district} Stockholm företag`, + query: `${company_name} ${district} Stockholm företag verksamhet`, limit: 3, - scrapeOptions: { - formats: ["markdown"], - }, + scrapeOptions: { formats: ["markdown"] }, }), }); - if (!searchResponse.ok) { - const errorText = await searchResponse.text(); - console.error("Firecrawl search error:", errorText); - throw new Error(`Firecrawl search failed: ${searchResponse.status}`); + let companyInfo = ""; + if (searchResponse.ok) { + const searchData = await searchResponse.json(); + companyInfo = searchData.data?.slice(0, 2) + .map((r: any) => r.markdown || r.description).join("\n\n") || ""; + } else { + console.error("Firecrawl search error:", await searchResponse.text()); } - const searchData = await searchResponse.json(); - const companyInfo = searchData.data?.slice(0, 2).map((result: any) => result.markdown || result.description).join("\n\n") || ""; + // Slumpad variation för att undvika mall-liknande utskick + const openingStyles = [ + "börja med en konkret observation om företaget", + "börja med en fråga om deras vardag på kontoret", + "börja med ett kort konstaterande – aldrig med hälsningsfras", + "börja med en referens till deras bransch eller uppdrag", + ]; + const ctaStyles = [ + '"Skulle det här kunna passa ert kontor?"', + '"Vill ni att jag skickar lite mer information?"', + '"Har ni redan en lösning för fika på kontoret?"', + '"Kan jag höra av mig med ett förslag?"', + '"Är det intressant för er?"', + ]; + const opening = openingStyles[Math.floor(Math.random() * openingStyles.length)]; + const cta = ctaStyles[Math.floor(Math.random() * ctaStyles.length)]; + const mentionTiffanyAge = Math.random() < 0.35; // ibland, aldrig alltid - console.log(`Found company info: ${companyInfo.substring(0, 200)}...`); - - // Step 2: Generate personalized email using Lovable AI + // Steg 2: Generera personligt mejl enligt masterprompt const emailResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { method: "POST", headers: { @@ -74,69 +97,99 @@ serve(async (req) => { messages: [ { role: "system", - content: `Du är en erfaren B2B-säljare för Lennart Svenssons Konditorivaror, ett bageri i Stockholm som levererar färska kakor till företag. - -Din uppgift är att skriva ett personligt och engagerande kall-mejl till potentiella kunder. + content: `Du agerar som en av världens bästa B2B-säljare, copywriter och SDR för Lennart Svenssons Konditorivaror – ett bageri i Stockholm som levererar färska handgjorda kakor på veckoabonnemang till kontor i Storstockholm. -Regler: -- Håll mejlet kort (max 150 ord) -- Var personlig och referera till specifik info om företaget -- Nämn att vi levererar färska kakor varje vecka -- Föreslå ett möte eller provleverans -- Avsluta med en tydlig call-to-action -- Skriv på svenska -- Var professionell men varm i tonen -- Signera ALLTID mejlet med "Med vänliga hälsningar,\nLennart Svenssons Konditorivaror" - använd ALDRIG "(ditt namn)" eller liknande platshållare` +MÅL: få mottagaren att svara på mejlet eller vilja veta mer om kakabonnemanget. + +MÅLGRUPP: endast företag i Storstockholm. Skriv aldrig till någon utanför området. + +SPRÅK: korrekt, professionell och naturlig svenska. Skriv som en människa – aldrig som en AI. + +RESEARCH: använd endast information som går att verifiera i researchmaterialet nedan. Hitta ALDRIG på fakta, siffror, namn eller händelser. Om du inte har säker info – håll det generellt om deras bransch utan att påstå specifika detaljer. + +VARIATION (kritisk regel – två företag får aldrig få samma mejl): +- Detta mejl ska ${opening}. +- Använd följande CTA (variera formuleringen lätt om det passar): ${cta} +- Variera alltid inledning, disposition, formuleringar, argument och avslut. + +TON: +- Professionell, vänlig, personlig, självsäker, kort, lätt att läsa. +- Undvik säljklyschor, spamord, överdrifter, "Jag hoppas allt är bra", "Jag tänkte bara...", och AI-liknande formuleringar. +- Använd inte många utropstecken, versaler eller överdrivna löften. + +PRODUKTVÄRDE (välj det som passar företaget, inte alla på en gång): +- Färska handgjorda kakor +- Leverans direkt till arbetsplatsen varje vecka +- Enkelt abonnemang, ingen administration +- Uppskattat av personal, skapar trivsel på kontoret +- Sparar tid för den som annars ordnar fikat + +AVSÄNDARE: Tiffany. ${mentionTiffanyAge ? "I detta mejl får du nämna att Tiffany är 16 år och driver företaget – men bara om det glider in naturligt, aldrig för att skapa medlidande." : "Nämn inte Tiffanys ålder i detta mejl."} + +SIGNATUR: avsluta alltid med: +Vänliga hälsningar, +Tiffany +Lennart Svenssons Konditorivaror + +Använd ALDRIG platshållare som "(ditt namn)". + +LÄNGD: max ca 130 ord i brödtexten. + +CTA: exakt en fråga i slutet.`, }, { role: "user", - content: `Skriv ett personligt kall-mejl till: + content: `Skriv ett unikt kall-mejl till: Företag: ${company_name} Bransch: ${industry} -Område: ${district} +Kommun (Storstockholm): ${district} -Information om företaget: -${companyInfo || "Ingen specifik information hittad, använd generella branschinsikter."}` - } +Researchmaterial (endast dessa fakta är verifierade – hitta inte på mer): +${companyInfo || "Ingen specifik information hittad. Håll mejlet generellt kring branschen utan att påstå specifika detaljer om företaget."}`, + }, ], tools: [ { type: "function", function: { name: "generate_email", - description: "Genererar ett personligt kall-mejl", + description: "Genererar ett unikt personligt kall-mejl enligt masterprompten", parameters: { type: "object", properties: { - subject: { type: "string", description: "Mejlets ämnesrad" }, - body: { type: "string", description: "Mejlets innehåll" }, + subject: { + type: "string", + description: "Kort, mänsklig ämnesrad utan spamord eller utropstecken", + }, + body: { + type: "string", + description: "Mejlets brödtext, inkl. signatur från Tiffany", + }, personalization_points: { type: "array", items: { type: "string" }, - description: "Punkter som gör mejlet personligt" - } + description: "Verifierbara punkter från researchen som mejlet bygger på", + }, }, required: ["subject", "body", "personalization_points"], - additionalProperties: false - } - } - } + additionalProperties: false, + }, + }, + }, ], - tool_choice: { type: "function", function: { name: "generate_email" } } + tool_choice: { type: "function", function: { name: "generate_email" } }, }), }); if (!emailResponse.ok) { if (emailResponse.status === 429) { return new Response(JSON.stringify({ error: "Rate limit överskriden, försök igen senare." }), { - status: 429, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } if (emailResponse.status === 402) { return new Response(JSON.stringify({ error: "Krediter slut, vänligen fyll på." }), { - status: 402, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const errorText = await emailResponse.text(); @@ -146,27 +199,22 @@ ${companyInfo || "Ingen specifik information hittad, använd generella branschin const emailData = await emailResponse.json(); const toolCall = emailData.choices?.[0]?.message?.tool_calls?.[0]; - if (!toolCall || toolCall.function.name !== "generate_email") { throw new Error("Unexpected AI response format"); } - const emailContent = JSON.parse(toolCall.function.arguments); return new Response(JSON.stringify({ success: true, company_name, research_summary: companyInfo.substring(0, 500), - email: emailContent - }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + email: emailContent, + }), { headers: { ...corsHeaders, "Content-Type": "application/json" } }); } catch (error) { console.error("Error researching company:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error"; return new Response(JSON.stringify({ error: errorMessage }), { - status: 500, - headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } });