This commit is contained in:
2026-06-24 14:40:43 +00:00
parent 22b05a7a1b
commit 7058970f98
80 changed files with 6383 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# n8n-Integration
## Empfohlene Node-Reihenfolge
```text
Schedule Trigger
-> PostgreSQL: aktive Kunden laden
-> Loop Over Items
-> PostgreSQL: Monatsmetriken und Verlauf laden
-> Code: Monatsdaten vorbereiten
-> Code: Claude Request bauen
-> HTTP Request: Anthropic Messages API
-> Code: Claude JSON extrahieren
-> Code: Render Payload bauen
-> HTTP Request: Report Renderer /render
-> Speicher / E-Mail / Kundenportal
-> PostgreSQL: Reportlauf protokollieren
```
## Anthropic HTTP Request
- Methode: `POST`
- URL: `https://api.anthropic.com/v1/messages`
- Header:
- `x-api-key`: aus Credential oder Secret
- `anthropic-version`: `2023-06-01`
- `content-type`: `application/json`
- Body: `={{ $json.claudeRequest }}`
- Response: JSON
## Renderer HTTP Request
- Methode: `POST`
- URL im gleichen Docker-Netz: `http://report-renderer:8080/render`
- Body Content Type: JSON
- Body: gesamtes aktuelles Item
- Response Format: File
- Binary Property: `data`
Danach kann `data` als E-Mail-Anhang, S3-Upload oder Datei verwendet werden.
## Datenschutz und Fehlerbehandlung
- Jeden Kunden im Loop als separates Item verarbeiten.
- Kunden-ID und Berichtsmonat vor jedem Node erhalten.
- Bei Claude- oder Renderfehlern keinen leeren Report versenden.
- Reportlauf erst nach erfolgreicher Ablage als `completed` markieren.
- Eindeutigen DB-Schluessel auf Kunde, Jahr und Monat setzen.
+40
View File
@@ -0,0 +1,40 @@
// n8n Code Node: "Claude Request bauen"
// Erwartet: customer, period, metrics, trends und optional context.
// Das Schema ist fuer die rohe Messages API vereinfacht. Die strengeren
// Laengen- und Wertepruefungen erfolgen anschliessend im Report Renderer.
const input = $json;
const schema = {"type": "object", "additionalProperties": false, "properties": {"summary": {"type": "object", "additionalProperties": false, "properties": {"headline": {"type": "string", "description": "Minimum characters: 8. Maximum characters: 110."}, "text": {"type": "string", "description": "Minimum characters: 80. Maximum characters: 900."}, "rating": {"type": "string", "enum": ["positive", "neutral", "watch"]}}, "required": ["headline", "text", "rating"]}, "developments": {"type": "array", "items": {"type": "object", "additionalProperties": false, "properties": {"text": {"type": "string", "description": "Minimum characters: 40. Maximum characters: 450."}, "impact": {"type": "string", "enum": ["positive", "neutral", "negative"]}}, "required": ["title", "text", "impact"]}, "description": "Minimum items: 2. Maximum items: 4."}, "recommendations": {"type": "array", "items": {"type": "object", "additionalProperties": false, "properties": {"priority": {"type": "integer", "description": "Minimum value: 1. Maximum value: 3."}, "category": {"type": "string", "description": "Minimum characters: 3. Maximum characters: 80."}, "reason": {"type": "string", "description": "Minimum characters: 40. Maximum characters: 450."}, "action": {"type": "string", "description": "Minimum characters: 30. Maximum characters: 450."}, "expected_effect": {"type": "string", "description": "Minimum characters: 20. Maximum characters: 300."}}, "required": ["priority", "category", "title", "reason", "action", "expected_effect"]}, "description": "Minimum items: 1. Maximum items: 3."}, "outlook": {"type": "object", "additionalProperties": false, "properties": {"headline": {"type": "string", "description": "Minimum characters: 5. Maximum characters: 100."}, "text": {"type": "string", "description": "Minimum characters: 60. Maximum characters: 700."}}, "required": ["headline", "text"]}, "data_quality_notes": {"type": "array", "items": {"type": "string", "description": "Minimum characters: 5. Maximum characters: 240."}, "description": "Maximum items: 4."}}, "required": ["summary", "developments", "recommendations", "outlook", "data_quality_notes"]};
const systemPrompt = `Du erstellst die redaktionellen Inhalte eines monatlichen Performance-Reports fuer Betreiber von Ferienunterkuenften.
Nutze ausschliesslich die gelieferten Daten. Erfinde keine Zahlen oder Ursachen.
Jede Empfehlung muss konkret und aus den Daten ableitbar sein.
Formuliere professionell, klar und auf Deutsch. Vermeide Marketingfloskeln und Wiederholungen.
Erzeuge 2 bis 4 Entwicklungen, 1 bis 3 Empfehlungen und hoechstens 4 Datenhinweise.
Nutze data_quality_notes fuer fehlende oder eingeschraenkte Datengrundlagen.`;
const userPrompt = `Erstelle den Monatsreport fuer diese Daten:
${JSON.stringify({
customer: input.customer,
period: input.period,
metrics: input.metrics,
trends: input.trends,
context: input.context ?? {}
}, null, 2)}`;
return [{
json: {
...input,
claudeRequest: {
model: input.claudeModel ?? 'claude-sonnet-4-6',
max_tokens: 3000,
temperature: 0.2,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
output_config: {
format: { type: 'json_schema', schema }
}
}
}
}];
+26
View File
@@ -0,0 +1,26 @@
// n8n Code Node: "Render Payload bauen"
// Passe die Node-Namen unten an deinen Workflow an.
const source = $('Monatsdaten vorbereiten').first().json;
const claude = $('Claude JSON extrahieren').first().json;
const now = new Date().toISOString();
const payload = {
report: {
report_version: '1.0',
prompt_version: '1.0',
template_version: '1.0',
generated_at: now,
publisher: source.publisher ?? 'Mein Reporting',
period: source.period
},
customer: source.customer,
design: source.design,
metrics: source.metrics,
trends: source.trends,
content: claude.content,
legal_note: source.legal_note ?? 'Dieser Report ist eine datenbasierte Entscheidungshilfe und keine Garantie fuer zukuenftige Ergebnisse.'
};
return [{ json: payload }];
+37
View File
@@ -0,0 +1,37 @@
// n8n Code Node: "Claude JSON extrahieren"
// Erwartet die Antwort der Anthropic Messages API.
const response = $json;
if (response.stop_reason === 'refusal') {
throw new Error('Claude hat die Anfrage abgelehnt. Es wird kein Report erzeugt.');
}
if (response.stop_reason === 'max_tokens') {
throw new Error('Claude-Ausgabe wurde wegen max_tokens abgeschnitten. max_tokens erhoehen und erneut ausfuehren.');
}
const textBlock = Array.isArray(response.content)
? response.content.find(block => block.type === 'text')
: null;
if (!textBlock?.text) {
throw new Error('Anthropic-Antwort enthaelt keinen Textblock.');
}
let content;
try {
content = JSON.parse(textBlock.text);
} catch (error) {
throw new Error(`Claude-Ausgabe ist kein valides JSON: ${error.message}`);
}
return [{
json: {
content,
claudeMeta: {
id: response.id,
model: response.model,
stop_reason: response.stop_reason,
usage: response.usage
}
}
}];
+49
View File
@@ -0,0 +1,49 @@
// n8n Code Node: "Monatsdaten vorbereiten"
// Dieses Beispiel normalisiert eine DB-Zeile. Feldnamen an deine Tabellen anpassen.
const row = $json;
const year = Number(row.report_year);
const month = Number(row.report_month);
const monthNames = ['Januar','Februar','Maerz','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
if (!Number.isInteger(year) || month < 1 || month > 12) {
throw new Error('report_year/report_month ungueltig');
}
const previous = new Date(Date.UTC(year, month - 2, 1));
const previousLabel = `${monthNames[previous.getUTCMonth()]} ${previous.getUTCFullYear()}`;
return [{
json: {
publisher: row.publisher_name ?? 'Mein Reporting',
customer: {
id: row.customer_slug,
name: row.customer_name,
property_name: row.property_name,
location: row.location,
contact_line: row.contact_line ?? '',
logo_data_uri: row.logo_data_uri || undefined,
logo_text: row.logo_text ?? row.customer_name.slice(0, 2).toUpperCase()
},
design: {
theme: row.theme ?? 'modern',
primary_color: row.primary_color ?? '#1F5D66',
secondary_color: row.secondary_color ?? '#2C7C82',
accent_color: row.accent_color ?? '#DBA25D',
text_color: row.text_color ?? '#24343A',
muted_color: row.muted_color ?? '#718087',
surface_color: row.surface_color ?? '#F2F6F5',
font_family: row.font_family ?? 'Arial'
},
period: {
year,
month,
label: `${monthNames[month - 1]} ${year}`,
comparison_label: previousLabel
},
metrics: row.metrics,
trends: row.trends,
context: row.context ?? {},
legal_note: row.legal_note
}
}];
+49
View File
@@ -0,0 +1,49 @@
-- 1. Aktive Kunden laden
SELECT *
FROM report_customer_config
WHERE active = TRUE
ORDER BY customer_id;
-- 2. Noch nicht erzeugte Reports eines Monats finden
SELECT c.*
FROM report_customer_config c
WHERE c.active = TRUE
AND NOT EXISTS (
SELECT 1
FROM monthly_report_runs r
WHERE r.customer_id = c.customer_id
AND r.report_year = $1
AND r.report_month = $2
AND r.report_version = '1.0'
AND r.status = 'completed'
);
-- 3. Reportlauf reservieren
INSERT INTO monthly_report_runs (
customer_id, report_year, report_month, status,
report_version, prompt_version, template_version
)
VALUES ($1, $2, $3, 'processing', '1.0', '1.0', '1.0')
ON CONFLICT (customer_id, report_year, report_month, report_version)
DO UPDATE SET
status = 'processing',
error_message = NULL,
started_at = NOW(),
completed_at = NULL
RETURNING id;
-- 4. Erfolgreich abschliessen
UPDATE monthly_report_runs
SET status = 'completed',
storage_path = $2,
checksum_sha256 = $3,
claude_model = $4,
completed_at = NOW()
WHERE id = $1;
-- 5. Fehler protokollieren
UPDATE monthly_report_runs
SET status = 'failed',
error_message = LEFT($2, 5000),
completed_at = NOW()
WHERE id = $1;
+42
View File
@@ -0,0 +1,42 @@
CREATE TABLE IF NOT EXISTS report_customer_config (
customer_id VARCHAR(64) PRIMARY KEY,
customer_name VARCHAR(160) NOT NULL,
property_name VARCHAR(160) NOT NULL,
location VARCHAR(160) NOT NULL,
contact_line VARCHAR(180) NOT NULL DEFAULT '',
logo_data_uri TEXT,
logo_text VARCHAR(5) NOT NULL,
theme VARCHAR(20) NOT NULL DEFAULT 'modern' CHECK (theme IN ('modern', 'classic')),
primary_color CHAR(7) NOT NULL DEFAULT '#1F5D66',
secondary_color CHAR(7) NOT NULL DEFAULT '#2C7C82',
accent_color CHAR(7) NOT NULL DEFAULT '#DBA25D',
text_color CHAR(7) NOT NULL DEFAULT '#24343A',
muted_color CHAR(7) NOT NULL DEFAULT '#718087',
surface_color CHAR(7) NOT NULL DEFAULT '#F2F6F5',
font_family VARCHAR(100) NOT NULL DEFAULT 'Arial',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS monthly_report_runs (
id BIGSERIAL PRIMARY KEY,
customer_id VARCHAR(64) NOT NULL REFERENCES report_customer_config(customer_id),
report_year INTEGER NOT NULL CHECK (report_year BETWEEN 2020 AND 2100),
report_month INTEGER NOT NULL CHECK (report_month BETWEEN 1 AND 12),
status VARCHAR(20) NOT NULL CHECK (status IN ('queued', 'processing', 'completed', 'failed')),
report_version VARCHAR(20) NOT NULL,
prompt_version VARCHAR(20) NOT NULL,
template_version VARCHAR(20) NOT NULL,
claude_model VARCHAR(80),
storage_path TEXT,
checksum_sha256 CHAR(64),
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (customer_id, report_year, report_month, report_version)
);
CREATE INDEX IF NOT EXISTS idx_monthly_report_runs_status
ON monthly_report_runs(status, report_year, report_month);
File diff suppressed because one or more lines are too long