feat: GPT-4.1 optimierte Ergänzungssuche bei Maps-Lücke

Wenn Google Maps weniger Leads findet als angefragt, wird automatisch
eine optimierte Suchanfrage via GPT-4.1 generiert und als SERP-Job
gestartet, um die Lücke zu füllen. Die KI-Query wird im LoadingCard
angezeigt. Fallback auf Original-Query wenn kein OpenAI-Key konfiguriert.

- lib/services/openai.ts: GPT-4.1 Query-Generator
- app/api/search/supplement: neuer Endpoint (GPT + SERP-Job)
- LoadingCard: ruft /api/search/supplement statt direkt SERP
- apiKey.ts + .env.local.example: openai Key-Support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
TimoUttenweiler
2026-04-01 10:43:33 +02:00
parent aa6707b8bc
commit 89a176700d
7 changed files with 156 additions and 20 deletions

View File

@@ -1,4 +1,4 @@
APP_ENCRYPTION_SECRET=437065e334849562d991112d74e23653 APP_ENCRYPTION_SECRET=32 Zeichen
# Lokal (wird ignoriert wenn TURSO_DATABASE_URL gesetzt ist) # Lokal (wird ignoriert wenn TURSO_DATABASE_URL gesetzt ist)
DATABASE_URL=file:./leadflow.db DATABASE_URL=file:./leadflow.db
@@ -12,3 +12,4 @@ ANYMAILFINDER_API_KEY=
APIFY_API_KEY= APIFY_API_KEY=
VAYNE_API_KEY= VAYNE_API_KEY=
GOOGLE_MAPS_API_KEY= GOOGLE_MAPS_API_KEY=
OPENAI_API_KEY=

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { getApiKey } from "@/lib/utils/apiKey";
import { generateSupplementQuery } from "@/lib/services/openai";
export async function POST(req: NextRequest) {
try {
const body = await req.json() as {
query: string;
region: string;
targetCount: number;
foundCount: number;
};
const { query, region, targetCount, foundCount } = body;
const base = req.nextUrl.origin;
const deficit = targetCount - foundCount;
// 1. Try to generate an optimized query via GPT-4.1
let optimizedQuery = region ? `${query} ${region}` : query;
let usedAI = false;
const openaiKey = await getApiKey("openai");
if (openaiKey) {
const aiQuery = await generateSupplementQuery(query, region, foundCount, targetCount, openaiKey);
if (aiQuery) {
optimizedQuery = aiQuery;
usedAI = true;
}
}
// 2. Start SERP job with the (possibly optimized) query
const maxPages = Math.min(Math.max(1, Math.ceil(deficit / 10)), 3);
const serpRes = await fetch(`${base}/api/jobs/serp-enrich`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: optimizedQuery,
maxPages,
countryCode: "de",
languageCode: "de",
filterSocial: true,
categories: ["ceo"],
enrichEmails: true,
}),
});
if (!serpRes.ok) {
const err = await serpRes.json() as { error?: string };
return NextResponse.json({ error: err.error || "SERP job konnte nicht gestartet werden" }, { status: 500 });
}
const { jobId } = await serpRes.json() as { jobId: string };
return NextResponse.json({ jobId, optimizedQuery, usedAI });
} catch (err) {
console.error("POST /api/search/supplement error:", err);
return NextResponse.json({ error: "Supplement-Suche fehlgeschlagen" }, { status: 500 });
}
}

View File

@@ -72,6 +72,7 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError
const [emailsFound, setEmailsFound] = useState(0); const [emailsFound, setEmailsFound] = useState(0);
const [progressWidth, setProgressWidth] = useState(3); const [progressWidth, setProgressWidth] = useState(3);
const [isTopping, setIsTopping] = useState(false); const [isTopping, setIsTopping] = useState(false);
const [optimizedQuery, setOptimizedQuery] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -95,35 +96,25 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError
}); });
}, 200); }, 200);
async function startSerpSupplement(deficit: number) { async function startSerpSupplement(foundCount: number) {
toppingActive = true; toppingActive = true;
setIsTopping(true); setIsTopping(true);
setPhase("topping"); setPhase("topping");
advanceBar(88); advanceBar(88);
const searchQuery = region ? `${query} ${region}` : query;
const maxPages = Math.max(1, Math.ceil(deficit / 10));
try { try {
const res = await fetch("/api/jobs/serp-enrich", { const res = await fetch("/api/search/supplement", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ query, region, targetCount, foundCount }),
query: searchQuery,
maxPages,
countryCode: "de",
languageCode: "de",
filterSocial: true,
categories: ["ceo"],
enrichEmails: true,
}),
}); });
if (!res.ok) throw new Error("SERP start failed"); if (!res.ok) throw new Error("supplement start failed");
const data = await res.json() as { jobId: string }; const data = await res.json() as { jobId: string; optimizedQuery: string; usedAI: boolean };
currentJobId = data.jobId; currentJobId = data.jobId;
if (data.optimizedQuery) setOptimizedQuery(data.optimizedQuery);
pollTimeout = setTimeout(poll, 2500); pollTimeout = setTimeout(poll, 2500);
} catch { } catch {
// SERP failed — complete with Maps results only // Supplement failed — complete with Maps results only
if (!cancelled) { if (!cancelled) {
setProgressWidth(100); setProgressWidth(100);
setPhase("done"); setPhase("done");
@@ -152,14 +143,14 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError
const leads = (data.results ?? []) as LeadResult[]; const leads = (data.results ?? []) as LeadResult[];
if (!toppingActive && data.totalLeads < targetCount) { if (!toppingActive && data.totalLeads < targetCount) {
// Maps returned fewer than requested → supplement with SERP // Maps returned fewer than requested → supplement with optimized SERP
mapsLeads = leads; mapsLeads = leads;
if (crawlInterval) clearInterval(crawlInterval); if (crawlInterval) clearInterval(crawlInterval);
crawlInterval = setInterval(() => { crawlInterval = setInterval(() => {
if (cancelled) return; if (cancelled) return;
setProgressWidth(prev => prev >= 96 ? prev : prev + 0.3); setProgressWidth(prev => prev >= 96 ? prev : prev + 0.3);
}, 200); }, 200);
await startSerpSupplement(targetCount - data.totalLeads); await startSerpSupplement(data.totalLeads);
} else { } else {
// All done // All done
if (crawlInterval) clearInterval(crawlInterval); if (crawlInterval) clearInterval(crawlInterval);
@@ -303,6 +294,17 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError
)} )}
</div> </div>
{/* Optimized query hint */}
{isTopping && optimizedQuery && (
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 14, fontSize: 11, color: "#6b7280" }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="2.5">
<path d="M9.663 17h4.673M12 3v1m6.364 1.636-.707.707M21 12h-1M4 12H3m3.343-5.657-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
KI-optimierte Suche:&nbsp;
<span style={{ color: "#9ca3af", fontStyle: "italic" }}>{optimizedQuery}"</span>
</div>
)}
{/* Warning banner */} {/* Warning banner */}
<div <div
style={{ style={{

50
lib/services/openai.ts Normal file
View File

@@ -0,0 +1,50 @@
import OpenAI from "openai";
/**
* Generates an optimized alternative search query when the primary Maps search
* returned fewer results than requested. Uses GPT-4.1 to find synonyms,
* related terms, or slight regional variations that surface different businesses.
*
* Returns null if OpenAI is not configured or the call fails (caller should fall
* back to the original query).
*/
export async function generateSupplementQuery(
query: string,
region: string,
foundCount: number,
targetCount: number,
apiKey: string,
): Promise<string | null> {
const client = new OpenAI({ apiKey });
const searchQuery = region ? `${query} ${region}` : query;
try {
const response = await client.chat.completions.create({
model: "gpt-4.1",
messages: [
{
role: "system",
content:
"Du bist ein Experte für B2B-Lead-Generierung in Deutschland. " +
"Antworte immer nur mit der Suchanfrage selbst — keine Anführungszeichen, keine Erklärungen.",
},
{
role: "user",
content:
`Eine Google-Suche nach "${searchQuery}" hat nur ${foundCount} von ${targetCount} Unternehmen gefunden. ` +
`Erstelle eine alternative Suchanfrage (max. 6 Wörter), die weitere passende Unternehmen findet, ` +
`die die erste Suche nicht erfasst hat. Nutze Synonyme, verwandte Branchen-Begriffe oder ` +
`leichte Variationen der Region — aber halte den Fokus auf dieselbe Branche und Region.`,
},
],
max_tokens: 30,
temperature: 0.7,
});
const text = response.choices[0]?.message?.content?.trim();
return text || null;
} catch (err) {
console.error("OpenAI query generation failed:", err);
return null;
}
}

View File

@@ -6,6 +6,7 @@ const ENV_VARS: Record<string, string> = {
apify: "APIFY_API_KEY", apify: "APIFY_API_KEY",
vayne: "VAYNE_API_KEY", vayne: "VAYNE_API_KEY",
googlemaps: "GOOGLE_MAPS_API_KEY", googlemaps: "GOOGLE_MAPS_API_KEY",
openai: "OPENAI_API_KEY",
}; };
export async function getApiKey(service: string): Promise<string | null> { export async function getApiKey(service: string): Promise<string | null> {

22
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.7", "next": "16.1.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^6.33.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"prisma": "^7.5.0", "prisma": "^7.5.0",
"react": "19.2.3", "react": "19.2.3",
@@ -9015,6 +9016,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/openai": {
"version": "6.33.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz",
"integrity": "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",

View File

@@ -27,6 +27,7 @@
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.7", "next": "16.1.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^6.33.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"prisma": "^7.5.0", "prisma": "^7.5.0",
"react": "19.2.3", "react": "19.2.3",