fix: include linkedin_post in GET /api/blog response for SLL matching

This commit is contained in:
Rene Fichtmueller 2026-04-05 01:24:52 +02:00
parent d9f5fc253f
commit 15f3ff5bef

View File

@ -16,8 +16,8 @@ import { pool } from "../db/client";
const pipelineProgress = new Map<string, { step: number; total: number; label: string; pct: number }>();
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<string, unknown> | 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`,