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:
parent
ede4f5b966
commit
fea0b0fb66
1829
packages/api/src/llm/fo-blog-pipeline.ts
Normal file
1829
packages/api/src/llm/fo-blog-pipeline.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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/:id — Get a specific draft
|
||||
* PUT /api/blog/:id/status — Update draft status
|
||||
*
|
||||
* Pipeline v3 (8 stages):
|
||||
* 1. MASTER — Article generation with narrative voice
|
||||
* 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.
|
||||
* Pipeline: gather data → LLM master pass → depth improvement → quality control
|
||||
* Voice: Senior optical network engineer, not marketing.
|
||||
*/
|
||||
import { Router, Request, Response } from "express";
|
||||
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 { generate, checkHealth } from "../llm/client";
|
||||
import { generate, checkHealth, resetOllamaQueue, getQueueDepth } from "../llm/client";
|
||||
import {
|
||||
SYSTEM_PROMPT,
|
||||
NARRATIVE_CONTROL_PROMPT,
|
||||
AUTO_KILL_PROMPT,
|
||||
REDUCTION_PROMPT,
|
||||
DEPTH_PROMPT,
|
||||
ANTI_GENERIC_INTRO_PROMPT,
|
||||
QUALITY_CONTROL_PROMPT,
|
||||
PROCUREMENT_LAYER_PROMPT,
|
||||
LINKEDIN_PROMPT,
|
||||
SCORING_PROMPT,
|
||||
buildTopicPrompt,
|
||||
} from "../llm/blog-prompts";
|
||||
|
||||
// Anti-patterns list for quality validation
|
||||
// Hard Delete List — v3 Auto-Kill Layer
|
||||
const GENERIC_PHRASES = [
|
||||
"plays a key role",
|
||||
"increasingly important",
|
||||
@ -52,23 +48,6 @@ const GENERIC_PHRASES = [
|
||||
"consider implementing",
|
||||
"may indicate issues",
|
||||
"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();
|
||||
@ -131,10 +110,83 @@ const BLOG_TEMPLATES: Record<string, BlogTemplate[]> = {
|
||||
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.
|
||||
* 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<{
|
||||
products: 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)
|
||||
const skipTroubleshooting = topic === "hype_cycle" || topic === "comparison" || topic === "new_product";
|
||||
|
||||
// Try vector search first (requires Qdrant + embeddings)
|
||||
try {
|
||||
const [products, 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(() => []),
|
||||
]);
|
||||
// Extract speed/form_factor hints from keywords for relevance filtering
|
||||
const speedHints = keywords.join(" ").match(/\b(10|25|40|100|200|400|800|1600)G\b/gi) || [];
|
||||
const speedGbps = speedHints.map(s => parseInt(s)).filter(Boolean);
|
||||
|
||||
const result = {
|
||||
products: products.map((r) => ({ score: r.score, ...r.payload })),
|
||||
news: news.map((r) => ({ score: r.score, ...r.payload })),
|
||||
faq: faq.map((r) => ({ score: r.score, ...r.payload })),
|
||||
troubleshooting: troubleshooting.map((r) => ({ score: r.score, ...r.payload })),
|
||||
};
|
||||
|
||||
// If we got data from vector search, return it
|
||||
if (result.products.length > 0 || result.news.length > 0) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
console.log("Vector search unavailable, falling back to PostgreSQL");
|
||||
}
|
||||
|
||||
// Fallback: query PostgreSQL directly for product and news data
|
||||
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
|
||||
// ── Fetch real products with verified prices from DB ──────────────────────
|
||||
// Primary: filter by keyword-extracted speed; fallback to top products by speed
|
||||
const productQuery = speedGbps.length > 0
|
||||
? `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,
|
||||
v.name as vendor, v.type as vendor_type
|
||||
FROM transceivers t
|
||||
LEFT JOIN vendors v ON t.vendor_id = v.id
|
||||
WHERE t.speed_gbps = ANY($1::int[])
|
||||
ORDER BY v.type = 'Compatible' DESC, t.speed_gbps DESC
|
||||
LIMIT 20`
|
||||
: `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,
|
||||
v.name as vendor, v.type as vendor_type
|
||||
FROM transceivers t
|
||||
LEFT JOIN vendors v ON t.vendor_id = v.id
|
||||
ORDER BY t.speed_gbps DESC
|
||||
LIMIT 15`
|
||||
).catch(() => ({ rows: [] })),
|
||||
LIMIT 20`;
|
||||
|
||||
const [productsDb, newsDb] = await Promise.all([
|
||||
pool.query(productQuery, speedGbps.length > 0 ? [speedGbps] : []).catch(() => ({ rows: [] })),
|
||||
pool.query(
|
||||
`SELECT title, source, category, published_at::text as date
|
||||
FROM news_articles
|
||||
@ -188,8 +233,78 @@ async function gatherBlogData(keywords: string[], topic?: string): Promise<{
|
||||
).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 {
|
||||
products: productsDb.rows,
|
||||
products: enrichedProducts,
|
||||
news: newsDb.rows,
|
||||
faq: [],
|
||||
troubleshooting: [],
|
||||
@ -863,26 +978,11 @@ async function processLlmQueue(): Promise<void> {
|
||||
}
|
||||
}
|
||||
llmRunning = false;
|
||||
// Process next item
|
||||
if (llmQueue.length > 0) processLlmQueue();
|
||||
// Process next item — small delay between pipelines to avoid nginx rate-limit bursts
|
||||
if (llmQueue.length > 0) setTimeout(() => processLlmQueue(), 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Run 10-Step Flexoptix Style LLM Pipeline and update draft in-place */
|
||||
async function runLlmPipeline(
|
||||
draftId: string,
|
||||
title: string,
|
||||
@ -890,146 +990,388 @@ async function runLlmPipeline(
|
||||
targetAudience: string,
|
||||
data: Awaited<ReturnType<typeof gatherBlogData>>,
|
||||
): 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 {
|
||||
console.log(`Blog LLM v3: Starting 8-stage pipeline for ${draftId}`);
|
||||
const passOpts = { temperature: 0.4, maxTokens: 6144, timeoutMs: 480000 };
|
||||
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
|
||||
await generate("You are a test.", "Reply OK.", {
|
||||
temperature: 0.1, maxTokens: 8, timeoutMs: 60000,
|
||||
}).catch(() => { /* non-fatal */ });
|
||||
// Load accumulated feedback to inject into system prompt
|
||||
let feedbackContext = "";
|
||||
try {
|
||||
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 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`);
|
||||
const systemPrompt = withCalibration(FO_BLOG_SYSTEM_PROMPT + feedbackContext);
|
||||
|
||||
// ── Pass 2: NARRATIVE CONTROL ──
|
||||
const pass2 = await generate(SYSTEM_PROMPT, [
|
||||
NARRATIVE_CONTROL_PROMPT,
|
||||
"", "--- ARTICLE ---", "", pass1.text,
|
||||
].join("\n"), passOpts);
|
||||
console.log(` 2/8 Narrative: ${pass2.evalCount} tokens`);
|
||||
// Warmup
|
||||
await generate("Test", "OK", { temperature: 0.1, maxTokens: 8, timeoutMs: 60000 }).catch(() => {});
|
||||
|
||||
// ── Pass 3: AUTO-KILL LAYER ──
|
||||
const pass3 = await generate(SYSTEM_PROMPT, [
|
||||
AUTO_KILL_PROMPT,
|
||||
"", "--- ARTICLE ---", "", pass2.text,
|
||||
].join("\n"), passOpts);
|
||||
console.log(` 3/8 Auto-Kill: ${pass3.evalCount} tokens`);
|
||||
// Build context data string for injection — REAL DB data only, never fabricated
|
||||
type PriceEntry = { vendor: string; price: number; currency: string; url: string; observed_at: string };
|
||||
const contextLines: string[] = [];
|
||||
|
||||
// ── Pass 4: REDUCTION ENGINE ──
|
||||
const pass4 = await generate(SYSTEM_PROMPT, [
|
||||
REDUCTION_PROMPT,
|
||||
"", "--- ARTICLE ---", "", pass3.text,
|
||||
].join("\n"), passOpts);
|
||||
const wordsAfterReduction = pass4.text.split(/\s+/).length;
|
||||
console.log(` 4/8 Reduction: ${pass4.evalCount} tokens, ${wordsAfterReduction} words`);
|
||||
for (const p of data.products.slice(0, 20)) {
|
||||
const prices = (p.verified_prices as PriceEntry[] | undefined) || [];
|
||||
const hasPrice = prices.length > 0;
|
||||
|
||||
// ── Pass 5: DEPTH (selective) ──
|
||||
const pass5 = await generate(SYSTEM_PROMPT, [
|
||||
DEPTH_PROMPT,
|
||||
"", "--- ARTICLE ---", "", pass4.text,
|
||||
].join("\n"), passOpts);
|
||||
console.log(` 5/8 Depth: ${pass5.evalCount} tokens`);
|
||||
// Build product line with real specs
|
||||
let line = `[PRODUCT] ${p.standard_name || p.slug || "unknown"}`;
|
||||
if (p.form_factor) line += ` | Form factor: ${p.form_factor}`;
|
||||
if (p.speed) line += ` | Speed: ${p.speed}`;
|
||||
if (p.reach_label) line += ` | Reach: ${p.reach_label}`;
|
||||
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 ──
|
||||
// Check intro first
|
||||
const introCheck = pass5.text.split("\n").slice(0, 8).join("\n").toLowerCase();
|
||||
const needsIntroFix =
|
||||
introCheck.includes("the optical transceiver market") ||
|
||||
introCheck.includes("in today") ||
|
||||
introCheck.includes("increasingly") ||
|
||||
introCheck.includes("plays a key role");
|
||||
// Optical specs if available
|
||||
if (p.tx_power_min_dbm != null) line += ` | TX min: ${p.tx_power_min_dbm} dBm`;
|
||||
if (p.tx_power_max_dbm != null) line += ` TX max: ${p.tx_power_max_dbm} dBm`;
|
||||
if (p.rx_sensitivity_dbm != null) line += ` | RX sensitivity: ${p.rx_sensitivity_dbm} dBm`;
|
||||
if (p.power_consumption_w != null) line += ` | Power: ${p.power_consumption_w}W`;
|
||||
|
||||
const issues = validateArticle(pass5.text);
|
||||
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");
|
||||
contextLines.push(line);
|
||||
|
||||
const pass6 = await generate(SYSTEM_PROMPT, qcPrompt, passOpts);
|
||||
console.log(` 6/8 QC: ${pass6.evalCount} tokens${needsIntroFix ? " (intro fixed)" : ""}${issues.length > 0 ? ` (${issues.length} issues)` : ""}`);
|
||||
|
||||
let draftContent = `# ${title}\n\n${pass6.text}`;
|
||||
|
||||
// ── Pass 7: PROCUREMENT LAYER (optional) ──
|
||||
if (targetAudience === "sales" || targetAudience === "customer") {
|
||||
try {
|
||||
const pass7 = await generate(SYSTEM_PROMPT, [
|
||||
PROCUREMENT_LAYER_PROMPT,
|
||||
"", "--- 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)");
|
||||
// Append verified prices — clearly tagged as real DB observations
|
||||
if (hasPrice) {
|
||||
for (const pr of prices.slice(0, 3)) {
|
||||
const date = pr.observed_at ? pr.observed_at.split("T")[0] : "recent";
|
||||
contextLines.push(
|
||||
` [VERIFIED PRICE] ${pr.currency} ${pr.price.toFixed(2)} — ${pr.vendor} (observed ${date}) ${pr.url ? `| ${pr.url}` : ""}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
contextLines.push(` [NO VERIFIED PRICE IN DB — do NOT invent a price for this product]`);
|
||||
}
|
||||
} else {
|
||||
console.log(" 7/8 Procurement: skipped (audience: " + targetAudience + ")");
|
||||
}
|
||||
|
||||
// ── Pass 8: LINKEDIN POST ──
|
||||
let linkedinPost = "";
|
||||
try {
|
||||
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)");
|
||||
}
|
||||
const contextData = contextLines.length > 0
|
||||
? contextLines.join("\n")
|
||||
: "[NO PRODUCT DATA AVAILABLE — do NOT invent product names, part numbers, or prices]";
|
||||
|
||||
// ── SCORING (non-destructive) ──
|
||||
let scores: Record<string, unknown> = {};
|
||||
// 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...");
|
||||
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 {
|
||||
const scoreResult = await generate(SYSTEM_PROMPT, [
|
||||
SCORING_PROMPT,
|
||||
"", "--- ARTICLE ---", "", draftContent,
|
||||
].join("\n"), { temperature: 0.2, maxTokens: 512, timeoutMs: 60000 });
|
||||
// Try to parse JSON from response
|
||||
const jsonMatch = scoreResult.text.match(/\{[\s\S]*\}/);
|
||||
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) {
|
||||
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 {
|
||||
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 finalIssues = validateArticle(draftContent);
|
||||
|
||||
// Update the draft in DB
|
||||
await pool.query(
|
||||
`UPDATE blog_drafts
|
||||
SET draft_content = $1, word_count = $2, generated_by = 'tip-blog-engine-v3',
|
||||
outline = $3, status = 'draft', updated_at = NOW()
|
||||
WHERE id = $4::uuid`,
|
||||
SET draft_content = $1, word_count = $2,
|
||||
generated_by = 'fo-blog-engine-v5-autokill',
|
||||
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,
|
||||
wordCount,
|
||||
stepsCompleted,
|
||||
autoQaScore ? JSON.stringify(autoQaScore) : null,
|
||||
JSON.stringify({
|
||||
generation_method: "llm-v3",
|
||||
pipeline: "8-stage",
|
||||
generation_method: "fo-pipeline-v5-autokill",
|
||||
auto_kill_scores: autoKillScores,
|
||||
steps_completed: stepsCompleted,
|
||||
blog_type: selectedTopic,
|
||||
quality_issues: finalIssues,
|
||||
scores,
|
||||
linkedin_post: linkedinPost,
|
||||
feedback_entries_used: feedbackContext ? feedbackContext.split("\n").length : 0,
|
||||
}),
|
||||
linkedinPost,
|
||||
linkedinCharCount,
|
||||
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) {
|
||||
console.warn(`Blog LLM v3 pipeline failed for ${draftId}: ${(llmErr as Error).message}`);
|
||||
// Draft stays as template-fallback
|
||||
clearProgress(draftId);
|
||||
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/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) => {
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user