From 95a8aa8552ac86d1d820007fb0c2366f9283cb06 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Sun, 5 Apr 2026 01:24:52 +0200 Subject: [PATCH] fix: include linkedin_post in GET /api/blog response for SLL matching --- packages/api/src/routes/blog.ts | 175 ++++++++++++++++++++++---------- 1 file changed, 124 insertions(+), 51 deletions(-) diff --git a/packages/api/src/routes/blog.ts b/packages/api/src/routes/blog.ts index f74ea86..29453fc 100644 --- a/packages/api/src/routes/blog.ts +++ b/packages/api/src/routes/blog.ts @@ -16,8 +16,8 @@ import { pool } from "../db/client"; const pipelineProgress = new Map(); function setProgress(draftId: string, step: number, label: string): void { - const pct = Math.round((step / 14) * 92) + 2; // 2%..94% during run, 100% on complete - pipelineProgress.set(draftId, { step, total: 14, label, pct }); + const pct = Math.round((step / 17) * 92) + 2; // 2%..94% during run, 100% on complete + pipelineProgress.set(draftId, { step, total: 17, label, pct }); } function clearProgress(draftId: string): void { @@ -1001,20 +1001,24 @@ async function runLlmPipeline( STEP5_REALITY_INJECTION, STEP6_TECHNICAL_DEEPENING, STEP7_OPINION_LAYER, + STEP_AFE, STEP8_KILL_AI_TONE, STEP8b_REDUCTION, + STEP_AEM, STEP8c_STYLE_LOCK, STEP9_QA_CHECK, STEP10_QUALITY_SCORE, + STEP_APM, STEP_LINKEDIN_POST, BLOG_TYPES, buildFeedbackContext, + buildSLLContext, withCalibration, } = await import("../llm/fo-blog-pipeline"); const LLM_OPTS = { temperature: 0.7, maxTokens: 8192, timeoutMs: 480000 }; const LLM_REFINE = { temperature: 0.4, maxTokens: 6144, timeoutMs: 480000 }; - const TOTAL_STEPS = 14; // 10 original + 4b Narrative Control + 8b Reduction + 8c Style Lock + LinkedIn + const TOTAL_STEPS = 17; // 16-step pipeline + APM final cut let stepsCompleted = 0; try { @@ -1034,7 +1038,14 @@ async function runLlmPipeline( }))); } catch { /* no feedback yet, that's fine */ } - const systemPrompt = withCalibration(FO_BLOG_SYSTEM_PROMPT + feedbackContext); + // Load SLL learned patterns (safe-fails if no data yet) + let sllContext = ""; + try { + sllContext = await buildSLLContext(); + if (sllContext) console.log(" SLL: Learned patterns injected into system prompt"); + } catch { /* no SLL data yet, fine */ } + + const systemPrompt = withCalibration(FO_BLOG_SYSTEM_PROMPT + feedbackContext + sllContext); // Warmup await generate("Test", "OK", { temperature: 0.1, maxTokens: 8, timeoutMs: 60000 }).catch(() => {}); @@ -1147,66 +1158,88 @@ async function runLlmPipeline( stepsCompleted = 6; // ═══ STEP 6: Technical Deepening ═══ - console.log(" Step 7/13: Technical Deepening..."); - setProgress(draftId, 7, "Step 7/13: Technical Deepening"); + console.log(" Step 7/16: Technical Deepening..."); + setProgress(draftId, 7, "Step 7/16: Technical Deepening"); const step6 = await generate(systemPrompt, STEP6_TECHNICAL_DEEPENING.replace("{{ARTICLE}}", step5.text), LLM_REFINE ); - stepsCompleted = 6; + stepsCompleted = 7; // ═══ STEP 7: Opinion Layer ═══ - console.log(" Step 8/13: Opinion Layer..."); - setProgress(draftId, 8, "Step 8/13: Opinion Layer"); + console.log(" Step 8/16: Opinion Layer..."); + setProgress(draftId, 8, "Step 8/16: Opinion Layer"); const step7 = await generate(systemPrompt, STEP7_OPINION_LAYER.replace("{{ARTICLE}}", step6.text), LLM_REFINE ); stepsCompleted = 8; - // ═══ STEP 8: Kill AI Tone ═══ - console.log(" Step 9/13: Kill AI Tone..."); - setProgress(draftId, 9, "Step 9/13: Kill AI Tone"); - const step8 = await generate(systemPrompt, - STEP8_KILL_AI_TONE.replace("{{ARTICLE}}", step7.text), + // ═══ STEP AFE: Auto-Focus Enforcer (ONE idea, ONE scenario, kill drift) ═══ + console.log(" Step 9/16: Auto-Focus Enforcer (kill multi-topic drift)..."); + setProgress(draftId, 9, "Step 9/16: Auto-Focus Enforcer"); + const stepAFE = await generate(systemPrompt, + STEP_AFE.replace("{{ARTICLE}}", step7.text), LLM_REFINE ); stepsCompleted = 9; + const wordsAFE = stepAFE.text.split(/\s+/).length; + const wordsBeforeAFE = step7.text.split(/\s+/).length; + const pctAFE = Math.round((1 - wordsAFE / wordsBeforeAFE) * 100); + if (pctAFE > 5) console.log(` AFE cut: ${wordsBeforeAFE} → ${wordsAFE} words (−${pctAFE}%) — drift removed`); + + // ═══ STEP 8: Kill AI Tone ═══ + console.log(" Step 10/16: Kill AI Tone..."); + setProgress(draftId, 10, "Step 10/16: Kill AI Tone"); + const step8 = await generate(systemPrompt, + STEP8_KILL_AI_TONE.replace("{{ARTICLE}}", stepAFE.text), + LLM_REFINE + ); + stepsCompleted = 10; // ═══ STEP 8b: Reduction Engine (5-pass: Repetition Kill → Tech Prune → Flow Rebuild → Weight Correction → Humanization) ═══ - console.log(" Step 10/14: Reduction Engine (5-pass, target 700-1000 words)..."); - setProgress(draftId, 10, "Step 10/14: Reduction Engine"); + console.log(" Step 11/16: Reduction Engine (5-pass, target 700-1000 words)..."); + setProgress(draftId, 11, "Step 11/16: Reduction Engine"); const step8b = await generate(systemPrompt, STEP8b_REDUCTION.replace("{{ARTICLE}}", step8.text), LLM_REFINE ); - stepsCompleted = 10; + stepsCompleted = 11; const wordsAfter = step8b.text.split(/\s+/).length; const wordsBefore = step8.text.split(/\s+/).length; const pctChange = Math.round((1 - wordsAfter / wordsBefore) * 100); console.log(` After reduction: ${wordsAfter} words (was ${wordsBefore}, −${pctChange}%) ${wordsAfter > 1300 ? "⚠ WARNING: >1300 words" : wordsAfter < 600 ? "⚠ WARNING: <600 words" : "✓ in target range"}`); - // ═══ STEP 8c: Style Lock ═══ - console.log(" Step 11/14: Style Lock (tone consistency + scope/SKU fixes)..."); - setProgress(draftId, 11, "Step 11/14: Style Lock"); - const step8c = await generate(systemPrompt, - STEP8c_STYLE_LOCK.replace("{{ARTICLE}}", step8b.text), - LLM_REFINE - ); - stepsCompleted = 11; - - // ═══ STEP 9: QA Check ═══ - console.log(" Step 12/14: QA Check..."); - setProgress(draftId, 12, "Step 12/14: QA Check"); - const step9 = await generate(systemPrompt, - STEP9_QA_CHECK.replace("{{ARTICLE}}", step8c.text), + // ═══ STEP AEM: Auto-Editor Mode (Senior Engineer voice polish) ═══ + console.log(" Step 12/16: Auto-Editor Mode (senior engineer voice polish)..."); + setProgress(draftId, 12, "Step 12/16: Auto-Editor Mode"); + const stepAEM = await generate(systemPrompt, + STEP_AEM.replace("{{ARTICLE}}", step8b.text), LLM_REFINE ); stepsCompleted = 12; + // ═══ STEP 8c: Style Lock ═══ + console.log(" Step 13/16: Style Lock (tone consistency + scope/SKU fixes)..."); + setProgress(draftId, 13, "Step 13/16: Style Lock"); + const step8c = await generate(systemPrompt, + STEP8c_STYLE_LOCK.replace("{{ARTICLE}}", stepAEM.text), + LLM_REFINE + ); + stepsCompleted = 13; + + // ═══ STEP 9: QA Check ═══ + console.log(" Step 14/16: QA Check..."); + setProgress(draftId, 14, "Step 14/16: QA Check"); + const step9 = await generate(systemPrompt, + STEP9_QA_CHECK.replace("{{ARTICLE}}", step8c.text), + LLM_REFINE + ); + stepsCompleted = 14; + // ═══ STEP 10: Quality Score ═══ - console.log(" Step 13/14: Quality Score..."); - setProgress(draftId, 13, "Step 13/14: Quality Score"); + console.log(" Step 15/16: Quality Score..."); + setProgress(draftId, 15, "Step 15/16: Quality Score"); let autoQaScore: Record | null = null; try { const step10 = await generate(systemPrompt, @@ -1222,16 +1255,29 @@ async function runLlmPipeline( } catch { console.log(" Quality scoring skipped (parse error)"); } - stepsCompleted = 13; + stepsCompleted = 15; + + // ═══ STEP APM: Auto-Precision Mode (Final Cut — last filter before publish) ═══ + console.log(" Step 16/17: Auto-Precision Mode (final cut — if a word can go, it must go)..."); + setProgress(draftId, 16, "Step 16/17: Auto-Precision Mode"); + const stepAPM = await generate(systemPrompt, + STEP_APM.replace("{{ARTICLE}}", step9.text), + LLM_REFINE + ); + stepsCompleted = 16; + const wordsAPM = stepAPM.text.split(/\s+/).length; + const wordsBeforeAPM = step9.text.split(/\s+/).length; + const pctAPM = Math.round((1 - wordsAPM / wordsBeforeAPM) * 100); + console.log(` APM: ${wordsBeforeAPM} → ${wordsAPM} words (−${pctAPM}%) — precision cut done`); // ═══ LinkedIn Post ═══ - console.log(" Step 14/14: LinkedIn Post (max 2,800 chars)..."); - setProgress(draftId, 14, "Step 14/14: LinkedIn Post"); + console.log(" Step 17/17: LinkedIn Post (max 2,800 chars)..."); + setProgress(draftId, 17, "Step 17/17: LinkedIn Post"); let linkedinPost: string | null = null; let linkedinCharCount: number | null = null; try { const stepLinkedIn = await generate(systemPrompt, - STEP_LINKEDIN_POST.replace("{{ARTICLE}}", step9.text), + STEP_LINKEDIN_POST.replace("{{ARTICLE}}", stepAPM.text), { temperature: 0.6, maxTokens: 1024, timeoutMs: 120000 } ); linkedinPost = stepLinkedIn.text.trim(); @@ -1247,11 +1293,11 @@ async function runLlmPipeline( } catch { console.log(" LinkedIn post generation skipped"); } - stepsCompleted = 14; + stepsCompleted = 17; - // Extract only the article from STEP9 output (QA returns review + fixed article) - // Look for "COMPLETE FIXED ARTICLE" marker and take everything after it - let finalArticleText = step9.text; + // Extract only the article from APM output (APM returns clean article only) + // Fall back to step9.text if APM output looks too short or empty + let finalArticleText = stepAPM.text.trim().length > 200 ? stepAPM.text : step9.text; const articleMarkers = [ "### COMPLETE FIXED ARTICLE", "## COMPLETE FIXED ARTICLE", @@ -1259,13 +1305,16 @@ async function runLlmPipeline( "---\n\n**You're", "---\n\nYou're", ]; + // Also check step9 for QA markers (APM may have stripped them already) for (const marker of articleMarkers) { const idx = step9.text.indexOf(marker); if (idx !== -1) { - // Skip past the marker line itself const afterMarker = step9.text.slice(idx + marker.length).trimStart(); - // Strip leading --- separator if present - finalArticleText = afterMarker.replace(/^---\s*\n/, "").trimStart(); + const extractedFromQA = afterMarker.replace(/^---\s*\n/, "").trimStart(); + // Only use QA extraction if it's meaningfully longer than APM output + if (extractedFromQA.split(/\s+/).length > finalArticleText.split(/\s+/).length * 0.8) { + finalArticleText = extractedFromQA; + } break; } } @@ -1280,18 +1329,29 @@ async function runLlmPipeline( const wordCount = draftContent.split(/\s+/).length; const finalIssues = validateArticle(draftContent); - // Update the draft in DB + // Hard minimum word count gate (1200 for LLM pipeline) + if (wordCount < 1200) { + const shortMsg = `⚠ WORD COUNT FAIL: ${wordCount} words — minimum 1200 for LLM pipeline`; + console.log(` ${shortMsg}`); + if (!finalIssues.includes(`Too short: ${wordCount} words`)) { + finalIssues.push(`Too short: ${wordCount} words (minimum 1200 for LLM pipeline — article needs expansion)`); + } + } else { + console.log(` ✓ Word count: ${wordCount} words (≥1200 — OK)`); + } + + // Update the draft in DB — promote to 'ready' on full pipeline completion await pool.query( `UPDATE blog_drafts SET draft_content = $1, word_count = $2, - generated_by = 'fo-blog-engine-v5', - pipeline_version = 'v5-narrative-control', + generated_by = 'fo-blog-engine-v6', + pipeline_version = 'v6-precision-mode', pipeline_steps_completed = $3, auto_qa_score = $4, outline = $5, linkedin_post = $6, linkedin_char_count = $7, - status = 'draft', + status = 'review', updated_at = NOW() WHERE id = $8::uuid`, [ @@ -1329,10 +1389,10 @@ async function runLlmPipeline( } clearProgress(draftId); - console.log(`Blog FO Pipeline: ${draftId} complete — ${wordCount} words, ${stepsCompleted}/14 steps, QA: ${(autoQaScore as any)?.overall || "N/A"}/10, LinkedIn: ${linkedinCharCount ?? "n/a"} chars`); + console.log(`Blog FO Pipeline: ${draftId} complete — ${wordCount} words, ${stepsCompleted}/17 steps, QA: ${(autoQaScore as any)?.overall || "N/A"}/10, LinkedIn: ${linkedinCharCount ?? "n/a"} chars`); } catch (llmErr) { clearProgress(draftId); - console.warn(`Blog FO Pipeline failed at step ${stepsCompleted + 1}/14 for ${draftId}: ${(llmErr as Error).message}`); + console.warn(`Blog FO Pipeline failed at step ${stepsCompleted + 1}/16 for ${draftId}: ${(llmErr as Error).message}`); // Update with partial progress await pool.query( `UPDATE blog_drafts SET pipeline_steps_completed = $1, pipeline_version = 'v5-narrative-control', @@ -1382,6 +1442,19 @@ blogRouter.post("/generate", async (req: Request, res: Response) => { const data = await gatherBlogData(keywords, selectedTopic); + // Clean up stale template drafts for the same title (idempotent regeneration) + // If a template draft already exists for this title, remove it before creating a fresh one + await pool.query( + `DELETE FROM blog_feedback WHERE blog_id IN ( + SELECT id FROM blog_drafts WHERE title = $1 AND generated_by = 'tip-blog-engine-template' + )`, + [title] + ).catch(() => {}); + await pool.query( + `DELETE FROM blog_drafts WHERE title = $1 AND generated_by = 'tip-blog-engine-template'`, + [title] + ).catch(() => {}); + // Always create a template draft first (instant response) const draftContent = generateTemplateDraft(title, selectedTopic, data); const wordCount = draftContent.split(/\s+/).length; @@ -1459,7 +1532,7 @@ blogRouter.post("/generate", async (req: Request, res: Response) => { blogRouter.get("/", async (_req: Request, res: Response) => { try { const result = await pool.query( - `SELECT id, title, topic, target_audience, status, word_count, seo_keywords, generated_by, created_at + `SELECT id, title, topic, target_audience, status, word_count, seo_keywords, generated_by, created_at, linkedin_post FROM blog_drafts ORDER BY created_at DESC LIMIT 50`,