Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-07-11 13:44:34 +00:00
parent 4bdcefb76c
commit 6b728df825
2 changed files with 165 additions and 116 deletions
+50 -49
View File
@@ -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" },
});
}
});
+115 -67
View File
@@ -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" },
});
}
});