upload
This commit is contained in:
@@ -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.
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
@@ -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 }];
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}];
|
||||
@@ -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
|
||||
}
|
||||
}];
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user