feat: wire 10-step FO Blog Pipeline into blog generation route

Replaces old 2-pass pipeline with full Flexoptix Style 10-step generation:
1. Topic Expansion (real scenarios + wrong assumptions)
2. Angle Selection (single strong angle + audience)
3. Outline Generation (decision-driven, no generic sections)
4. Master Draft (Flexoptix voice, 2000+ words)
5. Reality Injection (failure scenarios, operational pain)
6. Technical Deepening (specific optics, power, density)
7. Opinion Layer (clear positions, no neutrality)
8. Kill AI Tone (remove all AI fingerprints)
9. QA Check (technical accuracy verification)
10. Quality Score (1-10 auto-rating, saved as self-feedback)

Feedback loop active:
- Accumulated feedback injected into system prompt
- Auto QA scores saved to blog_feedback table
- Training data export via GET /api/blog/feedback/training-data
This commit is contained in:
Rene Fichtmueller 2026-03-31 09:16:23 +02:00
parent d1d23ce31d
commit eec42e4818

View File

@ -834,7 +834,7 @@ async function processLlmQueue(): Promise<void> {
if (llmQueue.length > 0) processLlmQueue(); if (llmQueue.length > 0) processLlmQueue();
} }
/** Run LLM pipeline and update draft in-place */ /** Run 10-Step Flexoptix Style LLM Pipeline and update draft in-place */
async function runLlmPipeline( async function runLlmPipeline(
draftId: string, draftId: string,
title: string, title: string,
@ -842,95 +842,212 @@ async function runLlmPipeline(
targetAudience: string, targetAudience: string,
data: Awaited<ReturnType<typeof gatherBlogData>>, data: Awaited<ReturnType<typeof gatherBlogData>>,
): Promise<void> { ): Promise<void> {
// Lazy-load the new FO pipeline
const {
FO_BLOG_SYSTEM_PROMPT,
STEP1_TOPIC_EXPANSION,
STEP2_ANGLE_SELECTION,
STEP3_OUTLINE,
STEP4_MASTER_DRAFT,
STEP5_REALITY_INJECTION,
STEP6_TECHNICAL_DEEPENING,
STEP7_OPINION_LAYER,
STEP8_KILL_AI_TONE,
STEP9_QA_CHECK,
STEP10_QUALITY_SCORE,
BLOG_TYPES,
buildFeedbackContext,
} = await import("../llm/fo-blog-pipeline");
const LLM_OPTS = { temperature: 0.7, maxTokens: 6144, timeoutMs: 480000 };
const LLM_REFINE = { temperature: 0.4, maxTokens: 6144, timeoutMs: 480000 };
let stepsCompleted = 0;
try { try {
console.log(`Blog LLM: Starting pipeline for ${draftId}`); console.log(`Blog FO Pipeline: Starting 10-step generation for ${draftId}`);
console.log(` Topic: "${title}" | Type: ${selectedTopic} | Audience: ${targetAudience}`);
// Warmup: tiny prompt to ensure model is loaded in memory // Load accumulated feedback to inject into system prompt
await generate("You are a test.", "Reply OK.", { let feedbackContext = "";
temperature: 0.1,
maxTokens: 8,
timeoutMs: 60000,
}).catch(() => { /* warmup failure is non-fatal */ });
// Pass 1: Master generation with full structure enforcement
const topicPrompt = buildTopicPrompt(selectedTopic, data);
const pass1 = await generate(SYSTEM_PROMPT, `Title: "${title}"\n\n${topicPrompt}`, {
temperature: 0.7,
maxTokens: 6144,
timeoutMs: 480000,
});
console.log(` Pass 1 (master): ${pass1.evalCount} tokens`);
// Check intro quality — merge anti-generic into depth pass if needed
const introCheck = pass1.text.split("\n").slice(0, 10).join("\n").toLowerCase();
const needsIntroFix =
introCheck.includes("the optical transceiver market") ||
introCheck.includes("in today") ||
introCheck.includes("increasingly") ||
introCheck.includes("plays a key role");
// Pass 2: Combined depth + quality + intro fix
const issues = validateArticle(pass1.text);
const combinedPrompt = [
DEPTH_PROMPT,
"",
"ADDITIONALLY, apply these quality controls:",
QUALITY_CONTROL_PROMPT,
needsIntroFix ? `\nALSO: ${ANTI_GENERIC_INTRO_PROMPT}` : "",
issues.length > 0 ? `\nDETECTED ISSUES TO FIX: ${issues.join("; ")}` : "",
"",
"--- ARTICLE TO IMPROVE ---",
"",
pass1.text,
].join("\n");
const pass2 = await generate(SYSTEM_PROMPT, combinedPrompt, {
temperature: 0.4,
maxTokens: 6144,
timeoutMs: 480000,
});
console.log(` Pass 2 (depth+quality): ${pass2.evalCount} tokens`);
if (needsIntroFix) console.log(" (included intro fix)");
if (issues.length > 0) console.log(` Quality issues fixed: ${issues.join(", ")}`);
let draftContent = `# ${title}\n\n${pass2.text}`;
// Optional: Add procurement notes for sales/customer audience
if (targetAudience === "sales" || targetAudience === "customer") {
try { try {
const procPass = await generate(SYSTEM_PROMPT, `${PROCUREMENT_LAYER_PROMPT}\n\n--- ARTICLE ---\n\n${draftContent}`, { const fbResult = await pool.query(
temperature: 0.4, `SELECT score_overall, feedback_text, blog_type FROM blog_feedback
maxTokens: 4096, WHERE feedback_text IS NOT NULL AND feedback_text != ''
timeoutMs: 240000, ORDER BY score_overall ASC LIMIT 20`
}); );
draftContent = procPass.text; feedbackContext = buildFeedbackContext(fbResult.rows.map(r => ({
console.log(` Procurement layer: ${procPass.evalCount} tokens`); score: r.score_overall, feedback_text: r.feedback_text, blog_type: r.blog_type || ""
})));
} catch { /* no feedback yet, that's fine */ }
const systemPrompt = FO_BLOG_SYSTEM_PROMPT + feedbackContext;
// Warmup
await generate("Test", "OK", { temperature: 0.1, maxTokens: 8, timeoutMs: 60000 }).catch(() => {});
// Build context data string for injection
const contextData = data.products.slice(0, 15).map(p =>
`${p.standard_name || p.slug}: ${p.form_factor} ${p.speed}, reach ${p.reach_label || "N/A"}, fiber ${p.fiber_type || "N/A"}, vendor ${p.vendor || "N/A"}${p.price ? `, ~€${p.price}` : ""}`
).join("\n");
// Get blog type config
const blogType = BLOG_TYPES[selectedTopic as keyof typeof BLOG_TYPES] || BLOG_TYPES.tutorial;
// ═══ STEP 1: Topic Expansion ═══
console.log(" Step 1/10: Topic Expansion...");
const step1 = await generate(systemPrompt,
STEP1_TOPIC_EXPANSION.replace("{{TOPIC}}", title),
LLM_OPTS
);
stepsCompleted = 1;
// ═══ STEP 2: Angle Selection ═══
console.log(" Step 2/10: Angle Selection...");
const step2 = await generate(systemPrompt,
STEP2_ANGLE_SELECTION.replace("{{SCENARIOS}}", step1.text),
LLM_REFINE
);
stepsCompleted = 2;
// ═══ STEP 3: Outline ═══
console.log(" Step 3/10: Outline Generation...");
const step3 = await generate(systemPrompt,
STEP3_OUTLINE
.replace("{{ANGLE}}", step2.text)
.replace("{{AUDIENCE}}", targetAudience)
.replace("{{DECISION}}", title),
LLM_REFINE
);
stepsCompleted = 3;
// ═══ STEP 4: Master Draft ═══
console.log(" Step 4/10: Master Draft (this takes a while)...");
const step4 = await generate(systemPrompt,
STEP4_MASTER_DRAFT
.replace("{{OUTLINE}}", step3.text)
.replace("{{CONTEXT_DATA}}", contextData),
{ ...LLM_OPTS, maxTokens: 8192 }
);
stepsCompleted = 4;
console.log(` Draft: ${step4.text.split(/\s+/).length} words`);
// ═══ STEP 5: Reality Injection ═══
console.log(" Step 5/10: Reality Injection...");
const step5 = await generate(systemPrompt,
STEP5_REALITY_INJECTION.replace("{{DRAFT}}", step4.text),
LLM_REFINE
);
stepsCompleted = 5;
// ═══ STEP 6: Technical Deepening ═══
console.log(" Step 6/10: Technical Deepening...");
const step6 = await generate(systemPrompt,
STEP6_TECHNICAL_DEEPENING.replace("{{ARTICLE}}", step5.text),
LLM_REFINE
);
stepsCompleted = 6;
// ═══ STEP 7: Opinion Layer ═══
console.log(" Step 7/10: Opinion Layer...");
const step7 = await generate(systemPrompt,
STEP7_OPINION_LAYER.replace("{{ARTICLE}}", step6.text),
LLM_REFINE
);
stepsCompleted = 7;
// ═══ STEP 8: Kill AI Tone ═══
console.log(" Step 8/10: Kill AI Tone...");
const step8 = await generate(systemPrompt,
STEP8_KILL_AI_TONE.replace("{{ARTICLE}}", step7.text),
LLM_REFINE
);
stepsCompleted = 8;
// ═══ STEP 9: QA Check ═══
console.log(" Step 9/10: QA Check...");
const step9 = await generate(systemPrompt,
STEP9_QA_CHECK.replace("{{ARTICLE}}", step8.text),
LLM_REFINE
);
stepsCompleted = 9;
// ═══ STEP 10: Quality Score ═══
console.log(" Step 10/10: Quality Score...");
let autoQaScore: Record<string, unknown> | null = null;
try {
const step10 = await generate(systemPrompt,
STEP10_QUALITY_SCORE.replace("{{ARTICLE}}", step9.text),
{ temperature: 0.2, maxTokens: 1024, timeoutMs: 120000 }
);
// Try to parse JSON score
const jsonMatch = step10.text.match(/\{[\s\S]*"scores"[\s\S]*\}/);
if (jsonMatch) {
autoQaScore = JSON.parse(jsonMatch[0]);
console.log(` Auto QA Score: ${(autoQaScore as any)?.overall || "?"}/10`);
}
} catch { } catch {
console.log(" Procurement pass skipped (timeout)"); console.log(" Quality scoring skipped (parse error)");
}
} }
stepsCompleted = 10;
// Final article
const draftContent = `# ${title}\n\n${step9.text}`;
const wordCount = draftContent.split(/\s+/).length; const wordCount = draftContent.split(/\s+/).length;
const finalIssues = validateArticle(draftContent); const finalIssues = validateArticle(draftContent);
// Update the draft in DB // Update the draft in DB
await pool.query( await pool.query(
`UPDATE blog_drafts `UPDATE blog_drafts
SET draft_content = $1, word_count = $2, generated_by = 'tip-blog-engine-llm', SET draft_content = $1, word_count = $2,
outline = $3, status = 'draft', updated_at = NOW() generated_by = 'fo-blog-engine-v3',
WHERE id = $4::uuid`, pipeline_version = 'v3-flexoptix-style',
pipeline_steps_completed = $3,
auto_qa_score = $4,
outline = $5,
status = 'draft',
updated_at = NOW()
WHERE id = $6::uuid`,
[ [
draftContent, draftContent,
wordCount, wordCount,
JSON.stringify({ generation_method: "llm", quality_issues: finalIssues }), stepsCompleted,
autoQaScore ? JSON.stringify(autoQaScore) : null,
JSON.stringify({
generation_method: "fo-pipeline-v3",
steps_completed: stepsCompleted,
blog_type: selectedTopic,
quality_issues: finalIssues,
feedback_entries_used: feedbackContext ? feedbackContext.split("\n").length : 0,
}),
draftId, draftId,
], ],
); );
console.log(`Blog LLM: Draft ${draftId} updated — ${wordCount} words, LLM-generated`); // Auto-submit QA score as self-feedback
if (autoQaScore && (autoQaScore as any).scores) {
const s = (autoQaScore as any).scores;
await pool.query(
`INSERT INTO blog_feedback (blog_id, score_overall, score_technical_depth, score_real_world,
score_clarity, score_originality, score_engineer_voice, score_decision_value,
score_failure_scenarios, score_opinion_strength, reviewer, blog_type, blog_topic, improvements)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'llm_self', $11, $12, $13)`,
[draftId, (autoQaScore as any).overall || 5,
s.technical_depth, s.real_world_relevance, s.clarity, s.originality,
s.engineer_voice, s.decision_value, s.failure_scenarios, s.opinion_strength,
selectedTopic, title,
(autoQaScore as any).improvements ? JSON.stringify((autoQaScore as any).improvements) : null]
).catch(() => {});
}
console.log(`Blog FO Pipeline: ${draftId} complete — ${wordCount} words, ${stepsCompleted}/10 steps, QA: ${(autoQaScore as any)?.overall || "N/A"}/10`);
} catch (llmErr) { } catch (llmErr) {
console.warn(`Blog LLM pipeline failed for ${draftId}: ${(llmErr as Error).message}`); console.warn(`Blog FO Pipeline failed at step ${stepsCompleted + 1}/10 for ${draftId}: ${(llmErr as Error).message}`);
// Draft stays as template-fallback, no update needed // Update with partial progress
await pool.query(
`UPDATE blog_drafts SET pipeline_steps_completed = $1, pipeline_version = 'v3-flexoptix-style',
outline = $2, updated_at = NOW() WHERE id = $3::uuid`,
[stepsCompleted, JSON.stringify({ error: (llmErr as Error).message, steps_completed: stepsCompleted }), draftId]
).catch(() => {});
} }
} }