feat: blog engine v5 — Auto-Kill Layer, 16-step pipeline, longer content

Upgrades FO Blog Pipeline from 14 to 16 steps:
- NEW Step 8d: Auto-Kill Layer v1.0 (10 systematic categories A-J)
- NEW Step 15: Auto-Kill Scoring (cleanliness, narrative, non-AI, relevance)
- Updated banned phrases from Gold-standard editorial feedback
- Soft Delete List for conditional phrases
- Auto-Kill categories: spec blocks, formulas, section leakage,
  generic transitions, repeated concepts, SKU mentions, false authority,
  over-explained basics, whitepaper tone, fake precision

Content length changes per user feedback:
- Blog target: 1,200-2,000 words (was 700-1,000) — thorough and detailed
- LinkedIn target: 2,000-2,800 chars (was 350-600) — use maximum length
- Reduction pass: 25-30% cut (was 15-25%) — remove weak, keep depth
This commit is contained in:
Rene Fichtmueller 2026-04-04 11:02:45 +02:00
parent 4a501d4461
commit 4a53f3c45d
2 changed files with 2515 additions and 191 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +1,40 @@
/** /**
* Blog Draft Generator API v3 (2026-04-04) * Blog Draft Generator API
* *
* POST /api/blog/generate Generate a blog draft via 8-stage LLM pipeline * POST /api/blog/generate Generate a blog draft via LLM (multi-pass pipeline)
* GET /api/blog List all drafts * GET /api/blog List all drafts
* GET /api/blog/:id Get a specific draft * GET /api/blog/:id Get a specific draft
* PUT /api/blog/:id/status Update draft status * PUT /api/blog/:id/status Update draft status
* *
* Pipeline v3 (8 stages): * Pipeline: gather data LLM master pass depth improvement quality control
* 1. MASTER Article generation with narrative voice * Voice: Senior optical network engineer, not marketing.
* 2. NARRATIVE CONTROL Kill visible structure, enforce continuous flow
* 3. AUTO-KILL LAYER Remove spec residue, AI phrases, repetition
* 4. REDUCTION ENGINE Cut 40% (keep strongest version of each idea)
* 5. DEPTH Add specifics only where text is vague (no spec dumps)
* 6. QUALITY CONTROL Final validation against hard delete list
* 7. PROCUREMENT (optional) Cost context for sales audience
* 8. LINKEDIN Generate companion LinkedIn post
*
* Voice: Someone explaining a real deployment problem not teaching a class.
* Based on editorial Gold-standard feedback and Auto-Kill Layer v1.0.
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { pool } from "../db/client"; import { pool } from "../db/client";
/** In-memory pipeline progress tracker — step updates pushed here, polled via GET /api/blog/:id/progress */
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 / 16) * 92) + 2; // 2%..94% during run, 100% on complete
pipelineProgress.set(draftId, { step, total: 16, label, pct });
}
function clearProgress(draftId: string): void {
pipelineProgress.delete(draftId);
}
import { semanticSearch } from "../embeddings/client"; import { semanticSearch } from "../embeddings/client";
import { generate, checkHealth } from "../llm/client"; import { generate, checkHealth, resetOllamaQueue, getQueueDepth } from "../llm/client";
import { import {
SYSTEM_PROMPT, SYSTEM_PROMPT,
NARRATIVE_CONTROL_PROMPT,
AUTO_KILL_PROMPT,
REDUCTION_PROMPT,
DEPTH_PROMPT, DEPTH_PROMPT,
ANTI_GENERIC_INTRO_PROMPT, ANTI_GENERIC_INTRO_PROMPT,
QUALITY_CONTROL_PROMPT, QUALITY_CONTROL_PROMPT,
PROCUREMENT_LAYER_PROMPT, PROCUREMENT_LAYER_PROMPT,
LINKEDIN_PROMPT,
SCORING_PROMPT,
buildTopicPrompt, buildTopicPrompt,
} from "../llm/blog-prompts"; } from "../llm/blog-prompts";
// Anti-patterns list for quality validation // Anti-patterns list for quality validation
// Hard Delete List — v3 Auto-Kill Layer
const GENERIC_PHRASES = [ const GENERIC_PHRASES = [
"plays a key role", "plays a key role",
"increasingly important", "increasingly important",
@ -52,23 +48,6 @@ const GENERIC_PHRASES = [
"consider implementing", "consider implementing",
"may indicate issues", "may indicate issues",
"could potentially", "could potentially",
"let me tell you",
"in conclusion",
"let's break this down",
"here's what you need to know",
"the key takeaway",
"this highlights",
"in a real-world scenario",
"recipe for disaster",
"ticking time bomb",
"the numbers don't lie",
"robust validation",
"significant benefits",
"cutting-edge",
"future-proof",
"production-ready and future-proof",
"best practices",
"robust framework",
]; ];
export const blogRouter = Router(); export const blogRouter = Router();
@ -131,10 +110,83 @@ const BLOG_TEMPLATES: Record<string, BlogTemplate[]> = {
seo_keywords: ["transceiver buying guide", "how to choose transceiver", "form factor guide"], seo_keywords: ["transceiver buying guide", "how to choose transceiver", "form factor guide"],
}, },
], ],
technology_deep_dive: [
{
topic: "technology_deep_dive",
title: "Deep Dive: {SPEED} Technology — What the Specs Don't Tell You",
target_audience: "technical",
seo_keywords: ["optical transceiver technology", "deep dive", "silicon photonics", "coherent optics"],
},
{
topic: "technology_deep_dive",
title: "{YEAR} Standards Roundup: What's Actually Production-Ready",
target_audience: "technical",
seo_keywords: ["IEEE 802.3", "OIF standards", "MSA", "production optics"],
},
],
market_alert: [
{
topic: "market_alert",
title: "Market Alert: {SPEED} Transceiver Prices Are Moving — Here's Why",
target_audience: "sales",
seo_keywords: ["transceiver price", "market analysis", "optical networking market", "price drop"],
},
{
topic: "market_alert",
title: "Price War: What {YEAR}'s Transceiver Market Shift Means for Your Budget",
target_audience: "sales",
seo_keywords: ["transceiver market", "price trend", "optical module cost", "procurement"],
},
],
migration_guide: [
{
topic: "migration_guide",
title: "The Complete Migration Guide: Moving to {SPEED} Without Breaking Production",
target_audience: "technical",
seo_keywords: ["network migration", "transceiver upgrade", "100G to 400G", "migration guide"],
},
{
topic: "migration_guide",
title: "{YEAR} Migration Playbook: From Planning to Production in 12 Months",
target_audience: "technical",
seo_keywords: ["network upgrade", "migration planning", "optical transceiver migration"],
},
],
buying_guide: [
{
topic: "buying_guide",
title: "The {YEAR} Transceiver Buying Guide: What to Buy, What to Skip",
target_audience: "customer",
seo_keywords: ["transceiver buying guide", "best transceiver", "OEM vs compatible", "procurement"],
},
{
topic: "buying_guide",
title: "OEM vs Compatible Transceivers in {YEAR}: The Real Numbers",
target_audience: "customer",
seo_keywords: ["OEM transceiver", "compatible transceiver", "cost savings", "Flexoptix"],
},
],
competitor_analysis: [
{
topic: "competitor_analysis",
title: "Competitor Roundup: What's New in {SPEED} Transceivers and What It Means",
target_audience: "sales",
seo_keywords: ["transceiver comparison", "competitor analysis", "optical module vendors"],
},
{
topic: "competitor_analysis",
title: "{YEAR} Vendor Landscape: Who's Winning the {FORM_FACTOR} Market",
target_audience: "sales",
seo_keywords: ["transceiver vendor", "market share", "optical networking vendors"],
},
],
}; };
/** Gather data from vector collections for blog content with PostgreSQL fallback. /** Gather data from vector collections for blog content with PostgreSQL fallback.
* Topic-aware: strategy articles (hype_cycle, comparison) skip troubleshooting data. */ * Topic-aware: strategy articles (hype_cycle, comparison) skip troubleshooting data.
*
* IMPORTANT: Always enriches products with REAL verified prices from price_observations.
* The LLM may ONLY use prices returned here never invent pricing. */
async function gatherBlogData(keywords: string[], topic?: string): Promise<{ async function gatherBlogData(keywords: string[], topic?: string): Promise<{
products: Array<Record<string, unknown>>; products: Array<Record<string, unknown>>;
news: Array<Record<string, unknown>>; news: Array<Record<string, unknown>>;
@ -146,40 +198,33 @@ async function gatherBlogData(keywords: string[], topic?: string): Promise<{
// Strategy articles should NOT pull troubleshooting data (topic separation) // Strategy articles should NOT pull troubleshooting data (topic separation)
const skipTroubleshooting = topic === "hype_cycle" || topic === "comparison" || topic === "new_product"; const skipTroubleshooting = topic === "hype_cycle" || topic === "comparison" || topic === "new_product";
// Try vector search first (requires Qdrant + embeddings) // Extract speed/form_factor hints from keywords for relevance filtering
try { const speedHints = keywords.join(" ").match(/\b(10|25|40|100|200|400|800|1600)G\b/gi) || [];
const [products, news, faq, troubleshooting] = await Promise.all([ const speedGbps = speedHints.map(s => parseInt(s)).filter(Boolean);
semanticSearch("product_embeddings", query, 10).catch(() => []),
semanticSearch("news_embeddings", query, 5).catch(() => []),
skipTroubleshooting ? Promise.resolve([]) : semanticSearch("faq_embeddings", query, 5).catch(() => []),
skipTroubleshooting ? Promise.resolve([]) : semanticSearch("troubleshooting_embeddings", query, 3).catch(() => []),
]);
const result = { // ── Fetch real products with verified prices from DB ──────────────────────
products: products.map((r) => ({ score: r.score, ...r.payload })), // Primary: filter by keyword-extracted speed; fallback to top products by speed
news: news.map((r) => ({ score: r.score, ...r.payload })), const productQuery = speedGbps.length > 0
faq: faq.map((r) => ({ score: r.score, ...r.payload })), ? `SELECT t.id, t.slug, t.form_factor, t.speed, t.speed_gbps, t.reach_label,
troubleshooting: troubleshooting.map((r) => ({ score: r.score, ...r.payload })), t.fiber_type, t.standard_name, t.connector, t.power_consumption_w,
}; t.tx_power_min_dbm, t.tx_power_max_dbm, t.rx_sensitivity_dbm,
v.name as vendor, v.type as vendor_type
// If we got data from vector search, return it FROM transceivers t
if (result.products.length > 0 || result.news.length > 0) { LEFT JOIN vendors v ON t.vendor_id = v.id
return result; WHERE t.speed_gbps = ANY($1::int[])
} ORDER BY v.type = 'Compatible' DESC, t.speed_gbps DESC
} catch { LIMIT 20`
console.log("Vector search unavailable, falling back to PostgreSQL"); : `SELECT t.id, t.slug, t.form_factor, t.speed, t.speed_gbps, t.reach_label,
} t.fiber_type, t.standard_name, t.connector, t.power_consumption_w,
t.tx_power_min_dbm, t.tx_power_max_dbm, t.rx_sensitivity_dbm,
// Fallback: query PostgreSQL directly for product and news data v.name as vendor, v.type as vendor_type
const [productsDb, newsDb] = await Promise.all([
pool.query(
`SELECT t.slug, t.form_factor, t.speed, t.reach_label, t.fiber_type, t.standard_name,
v.name as vendor
FROM transceivers t FROM transceivers t
LEFT JOIN vendors v ON t.vendor_id = v.id LEFT JOIN vendors v ON t.vendor_id = v.id
ORDER BY t.speed_gbps DESC ORDER BY t.speed_gbps DESC
LIMIT 15` LIMIT 20`;
).catch(() => ({ rows: [] })),
const [productsDb, newsDb] = await Promise.all([
pool.query(productQuery, speedGbps.length > 0 ? [speedGbps] : []).catch(() => ({ rows: [] })),
pool.query( pool.query(
`SELECT title, source, category, published_at::text as date `SELECT title, source, category, published_at::text as date
FROM news_articles FROM news_articles
@ -188,8 +233,78 @@ async function gatherBlogData(keywords: string[], topic?: string): Promise<{
).catch(() => ({ rows: [] })), ).catch(() => ({ rows: [] })),
]); ]);
// ── Enrich each product with real verified prices ─────────────────────────
const productIds = productsDb.rows.map((r: Record<string, unknown>) => r.id).filter(Boolean);
let priceMap: Record<string, Array<{vendor: string; price: number; currency: string; url: string; observed_at: string}>> = {};
if (productIds.length > 0) {
const priceResult = await pool.query(
`SELECT po.transceiver_id,
v.name as vendor,
v.type as vendor_type,
po.price::float as price,
po.currency,
po.url,
po.time::text as observed_at
FROM price_observations po
JOIN vendors v ON po.source_vendor_id = v.id
WHERE po.transceiver_id = ANY($1::int[])
AND po.time > NOW() - INTERVAL '30 days'
AND po.price IS NOT NULL
AND po.price > 0
ORDER BY po.transceiver_id, po.time DESC`,
[productIds]
).catch(() => ({ rows: [] }));
// Group by transceiver_id — keep best price per vendor
for (const row of priceResult.rows) {
const tid = String(row.transceiver_id);
if (!priceMap[tid]) priceMap[tid] = [];
// Deduplicate by vendor — keep most recent
if (!priceMap[tid].find((p) => p.vendor === row.vendor)) {
priceMap[tid].push({
vendor: row.vendor,
price: row.price,
currency: row.currency || "EUR",
url: row.url || "",
observed_at: row.observed_at,
});
}
}
}
// Attach prices to products
const enrichedProducts = productsDb.rows.map((p: Record<string, unknown>) => ({
...p,
verified_prices: priceMap[String(p.id)] || [],
has_verified_price: (priceMap[String(p.id)] || []).length > 0,
}));
// Try vector search to supplement (but always use DB products as base — they have real prices)
try {
const [vectorProducts, news, faq, troubleshooting] = await Promise.all([
semanticSearch("product_embeddings", query, 10).catch(() => []),
semanticSearch("news_embeddings", query, 5).catch(() => []),
skipTroubleshooting ? Promise.resolve([]) : semanticSearch("faq_embeddings", query, 5).catch(() => []),
skipTroubleshooting ? Promise.resolve([]) : semanticSearch("troubleshooting_embeddings", query, 3).catch(() => []),
]);
return {
// DB products first (they have real prices) — vector results supplemental only
products: [
...enrichedProducts,
...vectorProducts.map((r) => ({ score: r.score, ...r.payload })),
].slice(0, 20),
news: news.length > 0 ? news.map((r) => ({ score: r.score, ...r.payload })) : newsDb.rows,
faq: faq.map((r) => ({ score: r.score, ...r.payload })),
troubleshooting: troubleshooting.map((r) => ({ score: r.score, ...r.payload })),
};
} catch {
// Vector search unavailable — use PostgreSQL only
}
return { return {
products: productsDb.rows, products: enrichedProducts,
news: newsDb.rows, news: newsDb.rows,
faq: [], faq: [],
troubleshooting: [], troubleshooting: [],
@ -863,26 +978,11 @@ async function processLlmQueue(): Promise<void> {
} }
} }
llmRunning = false; llmRunning = false;
// Process next item // Process next item — small delay between pipelines to avoid nginx rate-limit bursts
if (llmQueue.length > 0) processLlmQueue(); if (llmQueue.length > 0) setTimeout(() => processLlmQueue(), 3000);
} }
/** /** Run 10-Step Flexoptix Style LLM Pipeline and update draft in-place */
* Run LLM pipeline v3 8-stage blog generation
*
* Pipeline:
* 1. MASTER Full article generation
* 2. NARRATIVE CONTROL Kill visible structure, enforce flow
* 3. AUTO-KILL Remove spec residue, AI phrases, repetition
* 4. REDUCTION Cut 40% (keep strongest version of each idea)
* 5. DEPTH Add specifics only where text is vague
* 6. QUALITY CONTROL Final validation against kill list
* 7. PROCUREMENT (optional) Cost context for sales audience
* 8. LINKEDIN Generate companion LinkedIn post
*
* Each pass uses low temperature (0.3-0.4) except master (0.7).
* Scoring runs at the end but doesn't modify the article.
*/
async function runLlmPipeline( async function runLlmPipeline(
draftId: string, draftId: string,
title: string, title: string,
@ -890,146 +990,388 @@ 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,
STEP4b_NARRATIVE_CONTROL,
STEP5_REALITY_INJECTION,
STEP6_TECHNICAL_DEEPENING,
STEP7_OPINION_LAYER,
STEP8_KILL_AI_TONE,
STEP8b_REDUCTION,
STEP8c_STYLE_LOCK,
STEP8d_AUTO_KILL,
AUTO_KILL_SCORING,
STEP9_QA_CHECK,
STEP10_QUALITY_SCORE,
STEP_LINKEDIN_POST,
BLOG_TYPES,
buildFeedbackContext,
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 = 16; // 10 original + 4b Narrative Control + 8b Reduction + 8c Style Lock + 8d Auto-Kill + Auto-Kill Score + LinkedIn
let stepsCompleted = 0;
try { try {
console.log(`Blog LLM v3: Starting 8-stage pipeline for ${draftId}`); console.log(`Blog FO Pipeline: Starting 10-step generation for ${draftId}`);
const passOpts = { temperature: 0.4, maxTokens: 6144, timeoutMs: 480000 }; console.log(` Topic: "${title}" | Type: ${selectedTopic} | Audience: ${targetAudience}`);
// Warmup: tiny prompt to ensure model is loaded // 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, try {
}).catch(() => { /* non-fatal */ }); const fbResult = await pool.query(
`SELECT score_overall, feedback_text, blog_type FROM blog_feedback
WHERE feedback_text IS NOT NULL AND feedback_text != ''
ORDER BY score_overall ASC LIMIT 20`
);
feedbackContext = buildFeedbackContext(fbResult.rows.map(r => ({
score: r.score_overall, feedback_text: r.feedback_text, blog_type: r.blog_type || ""
})));
} catch { /* no feedback yet, that's fine */ }
// ── Pass 1: MASTER GENERATION ── const systemPrompt = withCalibration(FO_BLOG_SYSTEM_PROMPT + feedbackContext);
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(` 1/8 Master: ${pass1.evalCount} tokens, ${pass1.text.split(/\s+/).length} words`);
// ── Pass 2: NARRATIVE CONTROL ── // Warmup
const pass2 = await generate(SYSTEM_PROMPT, [ await generate("Test", "OK", { temperature: 0.1, maxTokens: 8, timeoutMs: 60000 }).catch(() => {});
NARRATIVE_CONTROL_PROMPT,
"", "--- ARTICLE ---", "", pass1.text,
].join("\n"), passOpts);
console.log(` 2/8 Narrative: ${pass2.evalCount} tokens`);
// ── Pass 3: AUTO-KILL LAYER ── // Build context data string for injection — REAL DB data only, never fabricated
const pass3 = await generate(SYSTEM_PROMPT, [ type PriceEntry = { vendor: string; price: number; currency: string; url: string; observed_at: string };
AUTO_KILL_PROMPT, const contextLines: string[] = [];
"", "--- ARTICLE ---", "", pass2.text,
].join("\n"), passOpts);
console.log(` 3/8 Auto-Kill: ${pass3.evalCount} tokens`);
// ── Pass 4: REDUCTION ENGINE ── for (const p of data.products.slice(0, 20)) {
const pass4 = await generate(SYSTEM_PROMPT, [ const prices = (p.verified_prices as PriceEntry[] | undefined) || [];
REDUCTION_PROMPT, const hasPrice = prices.length > 0;
"", "--- ARTICLE ---", "", pass3.text,
].join("\n"), passOpts);
const wordsAfterReduction = pass4.text.split(/\s+/).length;
console.log(` 4/8 Reduction: ${pass4.evalCount} tokens, ${wordsAfterReduction} words`);
// ── Pass 5: DEPTH (selective) ── // Build product line with real specs
const pass5 = await generate(SYSTEM_PROMPT, [ let line = `[PRODUCT] ${p.standard_name || p.slug || "unknown"}`;
DEPTH_PROMPT, if (p.form_factor) line += ` | Form factor: ${p.form_factor}`;
"", "--- ARTICLE ---", "", pass4.text, if (p.speed) line += ` | Speed: ${p.speed}`;
].join("\n"), passOpts); if (p.reach_label) line += ` | Reach: ${p.reach_label}`;
console.log(` 5/8 Depth: ${pass5.evalCount} tokens`); if (p.fiber_type) line += ` | Fiber: ${p.fiber_type}`;
if (p.connector) line += ` | Connector: ${p.connector}`;
if (p.vendor) line += ` | Vendor: ${p.vendor}`;
if (p.vendor_type) line += ` (${p.vendor_type})`;
// ── Pass 6: QUALITY CONTROL ── // Optical specs if available
// Check intro first if (p.tx_power_min_dbm != null) line += ` | TX min: ${p.tx_power_min_dbm} dBm`;
const introCheck = pass5.text.split("\n").slice(0, 8).join("\n").toLowerCase(); if (p.tx_power_max_dbm != null) line += ` TX max: ${p.tx_power_max_dbm} dBm`;
const needsIntroFix = if (p.rx_sensitivity_dbm != null) line += ` | RX sensitivity: ${p.rx_sensitivity_dbm} dBm`;
introCheck.includes("the optical transceiver market") || if (p.power_consumption_w != null) line += ` | Power: ${p.power_consumption_w}W`;
introCheck.includes("in today") ||
introCheck.includes("increasingly") ||
introCheck.includes("plays a key role");
const issues = validateArticle(pass5.text); contextLines.push(line);
const qcPrompt = [
QUALITY_CONTROL_PROMPT,
needsIntroFix ? `\nALSO FIX THE INTRO:\n${ANTI_GENERIC_INTRO_PROMPT}` : "",
issues.length > 0 ? `\nREMAINING ISSUES: ${issues.join("; ")}` : "",
"", "--- ARTICLE ---", "", pass5.text,
].join("\n");
const pass6 = await generate(SYSTEM_PROMPT, qcPrompt, passOpts); // Append verified prices — clearly tagged as real DB observations
console.log(` 6/8 QC: ${pass6.evalCount} tokens${needsIntroFix ? " (intro fixed)" : ""}${issues.length > 0 ? ` (${issues.length} issues)` : ""}`); if (hasPrice) {
for (const pr of prices.slice(0, 3)) {
let draftContent = `# ${title}\n\n${pass6.text}`; const date = pr.observed_at ? pr.observed_at.split("T")[0] : "recent";
contextLines.push(
// ── Pass 7: PROCUREMENT LAYER (optional) ── ` [VERIFIED PRICE] ${pr.currency} ${pr.price.toFixed(2)}${pr.vendor} (observed ${date}) ${pr.url ? `| ${pr.url}` : ""}`
if (targetAudience === "sales" || targetAudience === "customer") { );
try { }
const pass7 = await generate(SYSTEM_PROMPT, [ } else {
PROCUREMENT_LAYER_PROMPT, contextLines.push(` [NO VERIFIED PRICE IN DB — do NOT invent a price for this product]`);
"", "--- ARTICLE ---", "", draftContent,
].join("\n"), { temperature: 0.4, maxTokens: 4096, timeoutMs: 240000 });
draftContent = pass7.text;
console.log(` 7/8 Procurement: ${pass7.evalCount} tokens`);
} catch {
console.log(" 7/8 Procurement: skipped (timeout)");
} }
} else {
console.log(" 7/8 Procurement: skipped (audience: " + targetAudience + ")");
} }
// ── Pass 8: LINKEDIN POST ── const contextData = contextLines.length > 0
let linkedinPost = ""; ? contextLines.join("\n")
try { : "[NO PRODUCT DATA AVAILABLE — do NOT invent product names, part numbers, or prices]";
const pass8 = await generate(SYSTEM_PROMPT, [
LINKEDIN_PROMPT,
"", "--- BLOG ARTICLE ---", "", draftContent,
].join("\n"), { temperature: 0.5, maxTokens: 1024, timeoutMs: 120000 });
linkedinPost = pass8.text;
console.log(` 8/8 LinkedIn: ${pass8.evalCount} tokens`);
} catch {
console.log(" 8/8 LinkedIn: skipped (timeout)");
}
// ── SCORING (non-destructive) ── // Get blog type config
let scores: Record<string, unknown> = {}; const blogType = BLOG_TYPES[selectedTopic as keyof typeof BLOG_TYPES] || BLOG_TYPES.tutorial;
// ═══ STEP 1: Topic Expansion ═══
console.log(" Step 1/10: Topic Expansion...");
setProgress(draftId, 1, "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...");
setProgress(draftId, 2, "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...");
setProgress(draftId, 3, "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)...");
setProgress(draftId, 4, "Step 4/10: Master Draft (longest step…)");
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 4b: Narrative Control ═══
console.log(" Step 5/13: Narrative Control (framing check + anti-FUD)...");
setProgress(draftId, 5, "Step 5/13: Narrative Control");
const step4b = await generate(systemPrompt,
STEP4b_NARRATIVE_CONTROL.replace("{{ARTICLE}}", step4.text),
LLM_REFINE
);
stepsCompleted = 5;
console.log(` After narrative control: ${step4b.text.split(/\s+/).length} words`);
// ═══ STEP 5: Reality Injection ═══
console.log(" Step 6/13: Reality Injection...");
setProgress(draftId, 6, "Step 6/13: Reality Injection");
const step5 = await generate(systemPrompt,
STEP5_REALITY_INJECTION.replace("{{DRAFT}}", step4b.text),
LLM_REFINE
);
stepsCompleted = 6;
// ═══ STEP 6: Technical Deepening ═══
console.log(" Step 7/13: Technical Deepening...");
setProgress(draftId, 7, "Step 7/13: 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 8/13: Opinion Layer...");
setProgress(draftId, 8, "Step 8/13: 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),
LLM_REFINE
);
stepsCompleted = 9;
// ═══ STEP 8b: Reduction Engine (5-pass, target: cut 40%) ═══
console.log(" Step 10/16: Reduction Engine (5-pass, cut 40%, target 600-1000 words)...");
setProgress(draftId, 10, "Step 10/16: Reduction Engine (cut 40%)");
const step8b = await generate(systemPrompt,
STEP8b_REDUCTION.replace("{{ARTICLE}}", step8.text),
LLM_REFINE
);
stepsCompleted = 10;
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 > 1200 ? "⚠ WARNING: >1200 words" : wordsAfter < 500 ? "⚠ WARNING: <500 words" : "✓ in target range"}`);
// ═══ STEP 8c: Style Lock ═══
console.log(" Step 11/16: Style Lock (tone consistency + scope/SKU fixes)...");
setProgress(draftId, 11, "Step 11/16: Style Lock");
const step8c = await generate(systemPrompt,
STEP8c_STYLE_LOCK.replace("{{ARTICLE}}", step8b.text),
LLM_REFINE
);
stepsCompleted = 11;
// ═══ STEP 8d: Auto-Kill Layer v1.0 (10 categories A-J) ═══
console.log(" Step 12/16: Auto-Kill Layer (10 categories A-J)...");
setProgress(draftId, 12, "Step 12/16: Auto-Kill Layer");
const step8d = await generate(systemPrompt,
STEP8d_AUTO_KILL.replace("{{ARTICLE}}", step8c.text),
LLM_REFINE
);
stepsCompleted = 12;
const wordsAfterKill = step8d.text.split(/\s+/).length;
console.log(` After Auto-Kill: ${wordsAfterKill} words (was ${step8c.text.split(/\s+/).length})`);
// ═══ STEP 9: QA Check ═══
console.log(" Step 13/16: QA Check...");
setProgress(draftId, 13, "Step 13/16: QA Check");
const step9 = await generate(systemPrompt,
STEP9_QA_CHECK.replace("{{ARTICLE}}", step8d.text),
LLM_REFINE
);
stepsCompleted = 13;
// ═══ STEP 10: Quality Score ═══
console.log(" Step 14/16: Quality Score...");
setProgress(draftId, 14, "Step 14/16: Quality Score");
let autoQaScore: Record<string, unknown> | null = null;
try { try {
const scoreResult = await generate(SYSTEM_PROMPT, [ const step10 = await generate(systemPrompt,
SCORING_PROMPT, STEP10_QUALITY_SCORE.replace("{{ARTICLE}}", step9.text),
"", "--- ARTICLE ---", "", draftContent, { temperature: 0.2, maxTokens: 1024, timeoutMs: 120000 }
].join("\n"), { temperature: 0.2, maxTokens: 512, timeoutMs: 60000 }); );
// Try to parse JSON from response // Try to parse JSON score
const jsonMatch = scoreResult.text.match(/\{[\s\S]*\}/); const jsonMatch = step10.text.match(/\{[\s\S]*"scores"[\s\S]*\}/);
if (jsonMatch) { if (jsonMatch) {
scores = JSON.parse(jsonMatch[0]); autoQaScore = JSON.parse(jsonMatch[0]);
console.log(` Auto QA Score: ${(autoQaScore as any)?.overall || "?"}/10`);
} }
console.log(` Scoring: ${JSON.stringify(scores)}`);
} catch { } catch {
console.log(" Scoring: skipped"); console.log(" Quality scoring skipped (parse error)");
} }
stepsCompleted = 14;
// ═══ Auto-Kill Scoring (non-destructive) ═══
console.log(" Step 15/16: Auto-Kill Scoring...");
setProgress(draftId, 15, "Step 15/16: Auto-Kill Scoring");
let autoKillScores: Record<string, unknown> | null = null;
try {
const killScoreResult = await generate(systemPrompt,
AUTO_KILL_SCORING.replace("{{ARTICLE}}", step9.text),
{ temperature: 0.2, maxTokens: 512, timeoutMs: 60000 }
);
const killJson = killScoreResult.text.match(/\{[\s\S]*\}/);
if (killJson) {
autoKillScores = JSON.parse(killJson[0]);
console.log(` Auto-Kill Scores: ${JSON.stringify(autoKillScores)}`);
}
} catch {
console.log(" Auto-Kill scoring skipped");
}
stepsCompleted = 15;
// ═══ LinkedIn Post ═══
console.log(" Step 16/16: LinkedIn Post (max 2,800 chars)...");
setProgress(draftId, 16, "Step 16/16: 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),
{ temperature: 0.6, maxTokens: 1024, timeoutMs: 120000 }
);
linkedinPost = stepLinkedIn.text.trim();
linkedinCharCount = linkedinPost.length;
// Enforce hard limit — truncate at last sentence before 2800 if too long
if (linkedinCharCount > 2800) {
linkedinPost = linkedinPost.slice(0, 2800).replace(/[^.!?]*$/, "").trim();
linkedinCharCount = linkedinPost.length;
console.log(` LinkedIn post truncated to ${linkedinCharCount} chars`);
} else {
console.log(` LinkedIn post: ${linkedinCharCount} chars`);
}
} catch {
console.log(" LinkedIn post generation skipped");
}
stepsCompleted = 16;
// 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;
const articleMarkers = [
"### COMPLETE FIXED ARTICLE",
"## COMPLETE FIXED ARTICLE",
"COMPLETE FIXED ARTICLE",
"---\n\n**You're",
"---\n\nYou're",
];
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();
break;
}
}
// Strip any remaining markdown review headers (### lines) from the article
finalArticleText = finalArticleText
.split("\n")
.filter(line => !line.match(/^#{1,4}\s+(Critical Review|HARD FAIL|QUALITY CHECKS|CALIBRATION FAILS)/))
.join("\n")
.trim();
const draftContent = `# ${title}\n\n${finalArticleText}`;
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-v3', SET draft_content = $1, word_count = $2,
outline = $3, status = 'draft', updated_at = NOW() generated_by = 'fo-blog-engine-v5-autokill',
WHERE id = $4::uuid`, pipeline_version = 'v5-auto-kill-layer',
pipeline_steps_completed = $3,
auto_qa_score = $4,
outline = $5,
linkedin_post = $6,
linkedin_char_count = $7,
status = 'draft',
updated_at = NOW()
WHERE id = $8::uuid`,
[ [
draftContent, draftContent,
wordCount, wordCount,
stepsCompleted,
autoQaScore ? JSON.stringify(autoQaScore) : null,
JSON.stringify({ JSON.stringify({
generation_method: "llm-v3", generation_method: "fo-pipeline-v5-autokill",
pipeline: "8-stage", auto_kill_scores: autoKillScores,
steps_completed: stepsCompleted,
blog_type: selectedTopic,
quality_issues: finalIssues, quality_issues: finalIssues,
scores, feedback_entries_used: feedbackContext ? feedbackContext.split("\n").length : 0,
linkedin_post: linkedinPost,
}), }),
linkedinPost,
linkedinCharCount,
draftId, draftId,
], ],
); );
console.log(`Blog LLM v3: Draft ${draftId} updated — ${wordCount} words, scores: ${JSON.stringify(scores)}`); // 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(() => {});
}
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`);
} catch (llmErr) { } catch (llmErr) {
console.warn(`Blog LLM v3 pipeline failed for ${draftId}: ${(llmErr as Error).message}`); clearProgress(draftId);
// Draft stays as template-fallback console.warn(`Blog FO Pipeline failed at step ${stepsCompleted + 1}/14 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',
outline = $2, updated_at = NOW() WHERE id = $3::uuid`,
[stepsCompleted, JSON.stringify({ error: (llmErr as Error).message, steps_completed: stepsCompleted }), draftId]
).catch(() => {});
} }
} }
@ -1162,7 +1504,29 @@ blogRouter.get("/", async (_req: Request, res: Response) => {
} }
}); });
// GET /api/blog/llm/status — Queue depth + Ollama health
blogRouter.get("/llm/status", async (_req: Request, res: Response) => {
const health = await checkHealth().catch(() => ({ ok: false, model: "", error: "unreachable" }));
res.json({ success: true, queue_depth: getQueueDepth(), llm: health });
});
// POST /api/blog/llm/reset-queue — Force-reset stuck Ollama queue
blogRouter.post("/llm/reset-queue", (_req: Request, res: Response) => {
resetOllamaQueue();
res.json({ success: true, message: "Ollama queue reset — stuck requests cleared" });
});
// GET /api/blog/:id — Get a specific draft with full content // GET /api/blog/:id — Get a specific draft with full content
// GET /api/blog/:id/progress — Real-time pipeline step progress (in-memory)
blogRouter.get("/:id/progress", (req: Request, res: Response) => {
const p = pipelineProgress.get(String(req.params.id));
if (!p) {
res.json({ success: true, running: false, step: 0, total: 10, label: "Idle", pct: 0 });
return;
}
res.json({ success: true, running: true, ...p });
});
blogRouter.get("/:id", async (req: Request, res: Response) => { blogRouter.get("/:id", async (req: Request, res: Response) => {
try { try {
const result = await pool.query( const result = await pool.query(
@ -1210,3 +1574,134 @@ blogRouter.put("/:id/status", async (req: Request, res: Response) => {
res.status(500).json({ success: false, error: (err as Error).message }); res.status(500).json({ success: false, error: (err as Error).message });
} }
}); });
// ═══════════════════════════════════════════════════════
// FEEDBACK SYSTEM (v0.2.0 — FO_Blog_LLM Training Loop)
// ═══════════════════════════════════════════════════════
/**
* POST /api/blog/:id/feedback Submit rating + feedback. Fed back to LLM.
*/
blogRouter.post("/:id/feedback", async (req: Request, res: Response) => {
const {
score_overall, score_technical_depth, score_real_world, score_clarity,
score_originality, score_engineer_voice, score_decision_value,
score_failure_scenarios, score_opinion_strength,
feedback_text, reviewer = "human", improvements
} = req.body;
if (!score_overall) return res.status(400).json({ error: "score_overall required (1-10)" });
try {
const blog = await pool.query("SELECT topic, title FROM blog_drafts WHERE id = $1::uuid", [req.params.id]);
const bd = blog.rows[0];
const result = 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, feedback_text, reviewer,
blog_type, blog_topic, improvements)
VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id`,
[req.params.id, score_overall, score_technical_depth ?? null, score_real_world ?? null,
score_clarity ?? null, score_originality ?? null, score_engineer_voice ?? null,
score_decision_value ?? null, score_failure_scenarios ?? null, score_opinion_strength ?? null,
feedback_text ?? null, reviewer, bd?.topic ?? null, bd?.title ?? null,
improvements ? JSON.stringify(improvements) : null]
);
res.json({ success: true, feedback_id: result.rows[0].id });
} catch (err) {
console.error("Feedback error:", err);
res.status(500).json({ error: "Failed to save feedback" });
}
});
/** GET /api/blog/feedback/stats — Aggregate feedback for LLM improvement tracking */
blogRouter.get("/feedback/stats", async (_req: Request, res: Response) => {
try {
const [overall, byType] = await Promise.all([
pool.query(`SELECT COUNT(*) AS total, AVG(score_overall)::numeric(3,1) AS avg FROM blog_feedback`),
pool.query(`SELECT blog_type, COUNT(*) AS cnt, AVG(score_overall)::numeric(3,1) AS avg
FROM blog_feedback WHERE blog_type IS NOT NULL GROUP BY blog_type ORDER BY avg ASC`),
]);
res.json({ total: parseInt(overall.rows[0]?.total||"0"), avg_score: overall.rows[0]?.avg, by_type: byType.rows });
} catch (err) { res.status(500).json({ error: "Failed" }); }
});
/** GET /api/blog/feedback/training-data — Export for FO_Blog_LLM injection */
blogRouter.get("/feedback/training-data", async (_req: Request, res: Response) => {
try {
const result = await pool.query(
`SELECT score_overall, feedback_text, blog_type, improvements FROM blog_feedback
WHERE feedback_text IS NOT NULL ORDER BY score_overall ASC LIMIT 30`);
await pool.query(`UPDATE blog_feedback SET fed_to_llm=true, fed_at=NOW() WHERE fed_to_llm=false AND feedback_text IS NOT NULL`);
res.json({ entries: result.rows, count: result.rowCount });
} catch (err) { res.status(500).json({ error: "Failed" }); }
});
// POST /api/blog/:id/regenerate — Re-run full LLM pipeline on existing draft (for review/quality-issue cases)
blogRouter.post("/:id/regenerate", async (req: Request, res: Response) => {
try {
const result = await pool.query(
`SELECT id, title, topic, target_audience, seo_keywords FROM blog_drafts WHERE id = $1::uuid`,
[req.params.id],
);
if (result.rows.length === 0) {
res.status(404).json({ success: false, error: "Draft not found" });
return;
}
const draft = result.rows[0];
const keywords: string[] = draft.seo_keywords || [];
// Re-gather fresh data for this topic
const data = await gatherBlogData(keywords, draft.topic);
// Reset status to draft + clear quality issues in outline
await pool.query(
`UPDATE blog_drafts SET status = 'draft', updated_at = NOW(),
outline = outline || '{"quality_issues":[],"regeneration_requested":true}'::jsonb
WHERE id = $1::uuid`,
[draft.id],
);
// Check LLM availability
const health = await checkHealth().catch(() => ({ ok: false, model: "", error: "unreachable" }));
if (!health.ok) {
res.status(503).json({ success: false, error: "LLM not available — cannot regenerate" });
return;
}
console.log(`Blog Regenerate: Re-queuing LLM pipeline for draft ${draft.id} ("${draft.title}")`);
enqueueLlmPipeline(draft.id, draft.title, draft.topic, draft.target_audience, data).catch((err) => {
console.error(`Blog regenerate pipeline error: ${(err as Error).message}`);
});
res.json({
success: true,
draft_id: draft.id,
title: draft.title,
message: "LLM pipeline re-queued — poll /api/blog/:id/progress for status",
});
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
// DELETE /api/blog/:id — Delete a blog draft
blogRouter.delete("/:id", async (req: Request, res: Response) => {
try {
// Delete feedback first (FK constraint)
await pool.query("DELETE FROM blog_feedback WHERE blog_id = $1::uuid", [req.params.id]);
const result = await pool.query(
"DELETE FROM blog_drafts WHERE id = $1::uuid RETURNING id, title",
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: "Draft not found" });
}
res.json({ success: true, deleted: result.rows[0] });
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});