feat(b2b-marketing): Finalize grounding architecture and frontend improvements

- Upgrade backend to use gemini-2.5-flash with sanitized HTML parsing (no token limit).
- Implement robust retry logic and increased timeouts (600s) for deep analysis.
- Add file-based logging for prompts and responses.
- Fix API endpoint (v1) and regex parsing issues.
- Frontend: Optimize PDF export (landscape, no scrollbars), fix copy-paste button, add 'Repeat Step 6' feature.
- Update documentation to 'Completed' status.
This commit is contained in:
2025-12-23 10:40:48 +00:00
parent 101933f618
commit 46bf8b0b48
12 changed files with 3758 additions and 569 deletions

View File

@@ -1,14 +1,14 @@
import React, { useState, useCallback, useRef } from 'react';
import { GoogleGenAI, Chat } from "@google/genai";
import React, { useState, useCallback } from 'react';
import { InputForm } from './components/InputForm';
import { StepDisplay } from './components/StepDisplay';
import { LoadingSpinner, BotIcon, SparklesIcon, MarkdownIcon, PrintIcon } from './components/Icons';
import { ExportMenu } from './components/ExportMenu';
import { PROMPTS, translations } from './constants';
import { translations } from './constants';
import type { AnalysisStep, AnalysisData, InputData } from './types';
import { parseGeminiStepResponse } from './services/parser';
import { generateMarkdown, downloadFile, tableToMarkdown } from './services/export';
import { generateMarkdown, downloadFile } from './services/export';
const API_BASE_URL = '/api';
const App: React.FC = () => {
const [inputData, setInputData] = useState<InputData>({
@@ -20,10 +20,8 @@ const App: React.FC = () => {
});
const [analysisData, setAnalysisData] = useState<Partial<AnalysisData>>({});
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isEnriching, setIsEnriching] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [generationStep, setGenerationStep] = useState<number>(0); // 0: idle, 1-6: step X is complete
const chatRef = useRef<Chat | null>(null);
const t = translations[inputData.language];
const STEP_TITLES = t.stepTitles;
@@ -41,26 +39,28 @@ const App: React.FC = () => {
setGenerationStep(0);
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const currentPrompts = PROMPTS[inputData.language];
const newChat = ai.chats.create({
model: 'gemini-2.5-pro',
config: {
systemInstruction: currentPrompts.SYSTEM_PROMPT.replace('{{language}}', inputData.language)
}
const response = await fetch(`${API_BASE_URL}/start-generation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
companyUrl: inputData.companyUrl,
language: inputData.language,
regions: inputData.regions,
focus: inputData.focus,
}),
});
chatRef.current = newChat;
let prompt = currentPrompts.STEP_PROMPTS[0];
prompt = prompt.replace('{{company_url}}', inputData.companyUrl);
prompt = prompt.replace('{{language}}', inputData.language);
prompt = prompt.replace('{{regions}}', inputData.regions);
prompt = prompt.replace('{{focus}}', inputData.focus);
const response = await newChat.sendMessage({ message: prompt });
const parsedData = parseGeminiStepResponse(response.text);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.details || `HTTP error! status: ${response.status}`);
}
const parsedData = await response.json();
if (parsedData.error) {
throw new Error(parsedData.error);
}
setAnalysisData(parsedData);
setGenerationStep(1);
@@ -73,35 +73,33 @@ const App: React.FC = () => {
}, [inputData]);
const handleGenerateNextStep = useCallback(async () => {
if (!chatRef.current || generationStep >= 6) return;
if (generationStep >= 6) return;
setIsLoading(true);
setError(null);
const nextStepIndex = generationStep;
try {
let context = '';
for (let i = 0; i < generationStep; i++) {
const stepKey = STEP_KEYS[i];
const stepObject = analysisData[stepKey];
if (stepObject) {
context += `\n\n## ${STEP_TITLES[stepKey]}\n\n`;
const summary = stepObject.summary && stepObject.summary.length > 0 ? `**${t.summaryTitle}**\n${stepObject.summary.map(s => `* ${s}`).join('\n')}\n\n` : '';
const table = tableToMarkdown(stepObject);
context += `${summary}${table}`;
}
}
const currentPrompts = PROMPTS[inputData.language];
let prompt = currentPrompts.STEP_PROMPTS[nextStepIndex];
prompt = prompt.replace('{{previous_steps_data}}', context);
const response = await fetch(`${API_BASE_URL}/next-step`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
analysisData,
language: inputData.language,
channels: inputData.channels,
generationStep: generationStep + 1, // Pass the step we want to generate
}),
});
if (nextStepIndex === 5) { // Step 6 is index 5
prompt = prompt.replace('{{channels}}', inputData.channels.join(', '));
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.details || `HTTP error! status: ${response.status}`);
}
const parsedData = await response.json();
const response = await chatRef.current.sendMessage({ message: prompt });
const parsedData = parseGeminiStepResponse(response.text);
if (parsedData.error) {
throw new Error(parsedData.error);
}
setAnalysisData(prev => ({ ...prev, ...parsedData }));
setGenerationStep(prev => prev + 1);
@@ -113,120 +111,14 @@ const App: React.FC = () => {
} finally {
setIsLoading(false);
}
}, [analysisData, generationStep, inputData.channels, inputData.language, STEP_KEYS, STEP_TITLES, t.summaryTitle]);
}, [analysisData, generationStep, inputData.channels, inputData.language]);
const handleDataChange = <K extends keyof AnalysisData>(step: K, newData: AnalysisData[K]) => {
if (analysisData[step]) {
setAnalysisData(prev => prev ? { ...prev, [step]: newData } : { [step]: newData });
}
};
const getEnrichPrompt = (lang: 'de' | 'en', url: string, productName: string) => {
if (lang === 'en') {
return `# Task
Fill in the information for the following product/solution based on the website ${url}. Respond ONLY with the content for the remaining 4 columns, separated by '|||'.
# Product/Solution
${productName}
# Column Order
1. Description (12 sentences)
2. Core Features
3. Differentiation
4. Primary Source (URL)
# Important
Respond *only* with the text for the 4 columns, separated by '|||'. Do not output any headers or explanations.`;
}
// German (original)
return `# Aufgabe
Fülle die Informationen für das folgende Produkt/Lösung basierend auf der Webseite ${url} aus. Antworte NUR mit dem Inhalt für die restlichen 4 Spalten, getrennt durch '|||'.
# Produkt/Lösung
${productName}
# Spalten-Reihenfolge
1. Beschreibung (12 Sätze)
2. Kernfunktionen
3. Differenzierung
4. Primäre Quelle (URL)
# Wichtig
Antworte *nur* mit dem Text für die 4 Spalten, getrennt durch '|||'. Gib keine Überschriften oder Erklärungen aus.`;
};
const handleEnrichOfferRow = useCallback(async (productName: string, productUrl?: string) => {
if (!analysisData.offer) return;
setIsEnriching(true);
setError(null);
const loadingText = t.loadingButton.replace('...', '');
const placeholderRow = [productName, loadingText, loadingText, loadingText, loadingText];
setAnalysisData(prev => {
if (!prev.offer) return prev;
const currentOffer = prev.offer;
const updatedRows = [...currentOffer.rows, placeholderRow];
return {
...prev,
offer: {
...currentOffer,
rows: updatedRows,
}
};
});
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
// Use the specific product URL if provided, otherwise fallback to the main company URL
const targetUrl = productUrl && productUrl.trim() !== '' ? productUrl.trim() : inputData.companyUrl;
const enrichPrompt = getEnrichPrompt(inputData.language, targetUrl, productName);
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: enrichPrompt
});
const text = response.text || '';
const enrichedParts = text.split('|||').map(s => s.trim());
const finalEnrichedData = Array(4).fill('');
for (let i = 0; i < 4; i++) {
if (enrichedParts[i]) {
finalEnrichedData[i] = enrichedParts[i];
}
}
const newRow = [productName, ...finalEnrichedData];
setAnalysisData(prev => {
const offerData = prev.offer;
if (!offerData) return prev;
const finalRows = offerData.rows.map(row =>
row[0] === productName && row[1] === loadingText ? newRow : row
);
return { ...prev, offer: { ...offerData, rows: finalRows } };
});
} catch (e) {
console.error(e);
// On error, clear loading text so user can edit manually
setAnalysisData(prev => {
const offerData = prev.offer;
if (!offerData) return prev;
const emptyRow = [productName, '', '', '', ''];
const finalRows = offerData.rows.map(row =>
row[0] === productName && row[1] === loadingText ? emptyRow : row
);
return { ...prev, offer: { ...offerData, rows: finalRows } };
});
} finally {
setIsEnriching(false);
}
}, [analysisData.offer, inputData.companyUrl, inputData.language, t.loadingButton]);
const handleDownloadMarkdown = () => {
if (!analysisData) return;
const markdownContent = generateMarkdown(analysisData as AnalysisData, STEP_TITLES, t.summaryTitle);
@@ -251,9 +143,9 @@ Antworte *nur* mit dem Text für die 4 Spalten, getrennt durch '|||'. Gib keine
headers={step.headers}
rows={step.rows}
onDataChange={(newRows) => handleDataChange(stepKey, { ...step, rows: newRows })}
canAddRows={stepKey === 'offer'}
onEnrichRow={stepKey === 'offer' ? handleEnrichOfferRow : undefined}
isEnriching={isEnriching}
canAddRows={false} // Disabled enrich functionality
onEnrichRow={undefined}
isEnriching={false}
canDeleteRows={canDelete}
t={t}
/>
@@ -359,7 +251,7 @@ Antworte *nur* mit dem Text für die 4 Spalten, getrennt durch '|||'. Gib keine
{t.analysisCompleteText.replace('{{otherLanguage}}', t.otherLanguage)}
</p>
<div className="flex flex-col sm:flex-row justify-center items-center gap-4">
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 mb-6">
<button
onClick={handleDownloadMarkdown}
className="w-full sm:w-auto flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-slate-900 dark:bg-sky-600 hover:bg-slate-800 dark:hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 shadow-md transition-all"
@@ -375,6 +267,16 @@ Antworte *nur* mit dem Text für die 4 Spalten, getrennt durch '|||'. Gib keine
{t.exportAsPdf}
</button>
</div>
<div className="border-t border-sky-200 dark:border-sky-700/50 pt-6 mt-2">
<p className="text-sm text-slate-500 dark:text-slate-400 mb-3">Ergebnis verfeinern?</p>
<button
onClick={() => setGenerationStep(5)}
className="text-sm text-sky-600 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-200 font-medium underline decoration-sky-300 hover:decoration-sky-600 transition-colors"
>
Schritt 6 (Botschaften) mit Fokus auf eine Branche neu generieren
</button>
</div>
</div>
)}
</>