feat(blog): regenerate button, SEO hashtags, calibration engine v2
- POST /api/blog/:id/regenerate — re-runs full 10-step LLM pipeline on existing draft - Regenerate button visible when quality_issues present or status=review - SEO keywords now displayed as clickable #hashtags (copy-to-clipboard) - fo-blog-pipeline: added PoE misuse, DR4 mislabeling, ZR/DR4 conflation as hard QA fails - fo-blog-pipeline: 14 hard rules in system prompt (was 10) - fo-blog-pipeline: CALIBRATION_GOLD_STANDARD + withCalibration() from 10/10 human review - System prompt now includes gold standard example on every pipeline run
This commit is contained in:
parent
5c7cbe0ccf
commit
c7b6351e26
@ -51,6 +51,11 @@ STRICTLY FORBIDDEN:
|
|||||||
- Perfect summaries that add nothing
|
- Perfect summaries that add nothing
|
||||||
- Press release language ("revolutionary", "industry-leading")
|
- Press release language ("revolutionary", "industry-leading")
|
||||||
- Repeating obvious facts
|
- Repeating obvious facts
|
||||||
|
- "PoE budget" or "PoE testing" in ANY optics/transceiver context — PoE = Power over Ethernet (for endpoints). Use "power budget", "power consumption per port", or "thermal headroom" instead.
|
||||||
|
- "DR4 (Direct Attach)" — DR4 stands for the reach/optical spec (500m SMF), NOT Direct Attach. DAC = Direct Attach Copper. These are completely different things. Never call DR4 "direct attach".
|
||||||
|
- Treating 400ZR and DR4 as equivalent — they are completely different: DR4 = DC leaf-spine (500m, 8 parallel fibers), ZR = DCI/coherent (80km, single fiber, 15-20W). Always separate them clearly.
|
||||||
|
- Checklist-style "Final Recommendation" sections — they read like AI. Write as a direct statement, not a bullet list of advice.
|
||||||
|
- "shiny new toys" or other marketing-speak dismissals at the end — end with something that STICKS
|
||||||
|
|
||||||
HARD RULES (non-negotiable — article FAILS QA without these):
|
HARD RULES (non-negotiable — article FAILS QA without these):
|
||||||
1. Start with a BRUTAL hook — not "If you're still..." but "You're about to sign a PO. Stop."
|
1. Start with a BRUTAL hook — not "If you're still..." but "You're about to sign a PO. Stop."
|
||||||
@ -72,6 +77,10 @@ HARD RULES (non-negotiable — article FAILS QA without these):
|
|||||||
8. Reference specific optics (SR4, DR4, FR4, LR4, ZR, etc.) with REAL problems, not just specs
|
8. Reference specific optics (SR4, DR4, FR4, LR4, ZR, etc.) with REAL problems, not just specs
|
||||||
9. Include real numbers (dBm, watts, price per port, cost per Gbit)
|
9. Include real numbers (dBm, watts, price per port, cost per Gbit)
|
||||||
10. Cabling reality MUST be addressed: MPO polarity, SR4→DR4 migration fiber count changes, cleaning
|
10. Cabling reality MUST be addressed: MPO polarity, SR4→DR4 migration fiber count changes, cleaning
|
||||||
|
11. ENDING MUST HIT: Last sentence must be a hammer. Example: "400G doesn't fail in design. It fails in production. Fast." NOT: "Consider your options carefully."
|
||||||
|
12. NO CHECKLIST endings: If a section ends with 4 tidy bullet points, rewrite as direct prose.
|
||||||
|
13. NEVER USE "PoE" in optics context. PoE = Power over Ethernet for endpoints. Use "power consumption per port", "thermal budget", "chassis power envelope" instead.
|
||||||
|
14. DR4 vs ZR: Always separate. DR4 = leaf-spine (500m), ZR = coherent DCI (80km+). Never treat them as variants of the same thing.
|
||||||
|
|
||||||
REFERENCE VALUES:
|
REFERENCE VALUES:
|
||||||
- SFP+ SR: Tx -8.2 to +0.5 dBm, Rx sensitivity -18.0 dBm, 1.0W typical
|
- SFP+ SR: Tx -8.2 to +0.5 dBm, Rx sensitivity -18.0 dBm, 1.0W typical
|
||||||
@ -337,6 +346,20 @@ QUALITY CHECKS:
|
|||||||
9. Would an experienced engineer share this article? Or would they roll their eyes?
|
9. Would an experienced engineer share this article? Or would they roll their eyes?
|
||||||
10. Is the hook BRUTAL enough? Does it grab in the first 2 sentences?
|
10. Is the hook BRUTAL enough? Does it grab in the first 2 sentences?
|
||||||
|
|
||||||
|
CALIBRATION FAILS (auto-reject — fix before returning):
|
||||||
|
11. POE MISUSE: Search for "PoE budget", "PoE testing", "PoE infrastructure" in optics/transceiver context.
|
||||||
|
→ REPLACE with "power budget", "power consumption per port", "thermal headroom", "cooling capacity"
|
||||||
|
12. DR4 MISLABELING: Search for "DR4 (Direct Attach)" or "DR4 direct attach".
|
||||||
|
→ REPLACE with "DR4 (500m SMF, 8 parallel fibers)" — DR4 is NOT Direct Attach. DAC is Direct Attach Copper.
|
||||||
|
13. ZR/DR4 CONFLATION: If ZR and DR4 appear together without clear separation, split them:
|
||||||
|
→ "DR4: DC leaf-spine, 500m, parallel optics, 12W | ZR: DCI/coherent, 80-120km, single fiber, 15-20W"
|
||||||
|
14. CHECKLIST ENDING: If the last section is a 4+ item bullet list, rewrite as 2-3 direct sentences.
|
||||||
|
→ Bad ending: "• Thoroughly Test Your PoE Budget • Invest in Proper Cleaning..."
|
||||||
|
→ Good ending: "400G doesn't fail in design. It fails in production. Plan for the real failure modes, not the vendor's sales slide."
|
||||||
|
15. HIDDEN COSTS TOO CLEAN: If the hidden costs section feels like a polished table, roughen it.
|
||||||
|
→ Bad: "$350 optic → $2,400 troubleshooting cost"
|
||||||
|
→ Good: "That $350 optic turned into a multi-thousand-dollar problem because someone skipped the connector cleaning."
|
||||||
|
|
||||||
For each issue:
|
For each issue:
|
||||||
- Quote the problematic text
|
- Quote the problematic text
|
||||||
- Explain what's wrong
|
- Explain what's wrong
|
||||||
@ -457,3 +480,53 @@ export function buildFeedbackContext(feedback: Array<{ score: number; feedback_t
|
|||||||
lines.push("--- END FEEDBACK ---\n");
|
lines.push("--- END FEEDBACK ---\n");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// CALIBRATION REFERENCE — 10/10 Gold Standard
|
||||||
|
// (Reviewed 2026-03-31, human feedback loop)
|
||||||
|
// This example teaches the LLM what "production-ready" voice looks like.
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const CALIBRATION_GOLD_STANDARD = `
|
||||||
|
--- GOLD STANDARD REFERENCE (10/10 — calibrate your output to this level) ---
|
||||||
|
|
||||||
|
KEY STRUCTURAL PATTERNS from a 10/10 article:
|
||||||
|
|
||||||
|
HOOK (correct):
|
||||||
|
"You're sitting in front of a quote for a few hundred 400G optics. Everything looks clean on paper. Bandwidth solved. Future-proof. Done.
|
||||||
|
That's usually the moment where things start going wrong."
|
||||||
|
|
||||||
|
WHAT BREAKS (correct — short, direct, no padding):
|
||||||
|
"Works in lab, fails in production
|
||||||
|
Classic.
|
||||||
|
Lab: single vendor, short patch, clean environment.
|
||||||
|
Production: mixed optics, different firmware, real distances.
|
||||||
|
Result: CRC errors, unstable links, weird flaps."
|
||||||
|
|
||||||
|
HIDDEN COSTS (correct — raw, not sanitized):
|
||||||
|
"That 'cheap' optic? Turns into a multi-thousand-euro problem because someone didn't clean a connector. At 400G, contamination isn't a quality issue. It's a service outage."
|
||||||
|
|
||||||
|
CABLING REALITY (correct):
|
||||||
|
"SR4 to DR4 migration is where budgets go to die. Wrong patch panels, wrong polarity, wrong assumptions. You end up re-cabling things you thought were ready."
|
||||||
|
|
||||||
|
ENDING (correct — hits and stops):
|
||||||
|
"400G is not risky because it's new. It's risky because people underestimate what actually changes.
|
||||||
|
If your design only works on paper, it will fail in production. And 400G fails fast."
|
||||||
|
|
||||||
|
WRONG PATTERNS (do not produce these):
|
||||||
|
❌ "Thoroughly Test Your PoE Budget:" (PoE = wrong context, checklist = wrong format)
|
||||||
|
❌ "QSFP-DD DR4 (Direct Attach)" (DR4 ≠ Direct Attach)
|
||||||
|
❌ "DR4 and ZR both push boundaries..." (they serve completely different use cases)
|
||||||
|
❌ "Don't be swayed by shiny new toys" (marketing speak, not engineer voice)
|
||||||
|
❌ 4-item bullet recommendation at end (too clean, too AI)
|
||||||
|
|
||||||
|
--- END GOLD STANDARD ---
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the calibration gold standard into the system prompt.
|
||||||
|
* Use sparingly — only when available Ollama context allows.
|
||||||
|
*/
|
||||||
|
export function withCalibration(systemPrompt: string): string {
|
||||||
|
return systemPrompt + CALIBRATION_GOLD_STANDARD;
|
||||||
|
}
|
||||||
|
|||||||
@ -11,6 +11,18 @@
|
|||||||
*/
|
*/
|
||||||
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 / 10) * 92) + 2; // 2%..94% during run, 100% on complete
|
||||||
|
pipelineProgress.set(draftId, { step, total: 10, 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 } from "../llm/client";
|
||||||
import {
|
import {
|
||||||
@ -98,6 +110,76 @@ 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.
|
||||||
@ -857,6 +939,7 @@ async function runLlmPipeline(
|
|||||||
STEP10_QUALITY_SCORE,
|
STEP10_QUALITY_SCORE,
|
||||||
BLOG_TYPES,
|
BLOG_TYPES,
|
||||||
buildFeedbackContext,
|
buildFeedbackContext,
|
||||||
|
withCalibration,
|
||||||
} = await import("../llm/fo-blog-pipeline");
|
} = await import("../llm/fo-blog-pipeline");
|
||||||
|
|
||||||
const LLM_OPTS = { temperature: 0.7, maxTokens: 6144, timeoutMs: 480000 };
|
const LLM_OPTS = { temperature: 0.7, maxTokens: 6144, timeoutMs: 480000 };
|
||||||
@ -880,7 +963,7 @@ async function runLlmPipeline(
|
|||||||
})));
|
})));
|
||||||
} catch { /* no feedback yet, that's fine */ }
|
} catch { /* no feedback yet, that's fine */ }
|
||||||
|
|
||||||
const systemPrompt = FO_BLOG_SYSTEM_PROMPT + feedbackContext;
|
const systemPrompt = withCalibration(FO_BLOG_SYSTEM_PROMPT + feedbackContext);
|
||||||
|
|
||||||
// Warmup
|
// Warmup
|
||||||
await generate("Test", "OK", { temperature: 0.1, maxTokens: 8, timeoutMs: 60000 }).catch(() => {});
|
await generate("Test", "OK", { temperature: 0.1, maxTokens: 8, timeoutMs: 60000 }).catch(() => {});
|
||||||
@ -895,6 +978,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 1: Topic Expansion ═══
|
// ═══ STEP 1: Topic Expansion ═══
|
||||||
console.log(" Step 1/10: Topic Expansion...");
|
console.log(" Step 1/10: Topic Expansion...");
|
||||||
|
setProgress(draftId, 1, "Step 1/10: Topic Expansion");
|
||||||
const step1 = await generate(systemPrompt,
|
const step1 = await generate(systemPrompt,
|
||||||
STEP1_TOPIC_EXPANSION.replace("{{TOPIC}}", title),
|
STEP1_TOPIC_EXPANSION.replace("{{TOPIC}}", title),
|
||||||
LLM_OPTS
|
LLM_OPTS
|
||||||
@ -903,6 +987,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 2: Angle Selection ═══
|
// ═══ STEP 2: Angle Selection ═══
|
||||||
console.log(" Step 2/10: Angle Selection...");
|
console.log(" Step 2/10: Angle Selection...");
|
||||||
|
setProgress(draftId, 2, "Step 2/10: Angle Selection");
|
||||||
const step2 = await generate(systemPrompt,
|
const step2 = await generate(systemPrompt,
|
||||||
STEP2_ANGLE_SELECTION.replace("{{SCENARIOS}}", step1.text),
|
STEP2_ANGLE_SELECTION.replace("{{SCENARIOS}}", step1.text),
|
||||||
LLM_REFINE
|
LLM_REFINE
|
||||||
@ -911,6 +996,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 3: Outline ═══
|
// ═══ STEP 3: Outline ═══
|
||||||
console.log(" Step 3/10: Outline Generation...");
|
console.log(" Step 3/10: Outline Generation...");
|
||||||
|
setProgress(draftId, 3, "Step 3/10: Outline Generation");
|
||||||
const step3 = await generate(systemPrompt,
|
const step3 = await generate(systemPrompt,
|
||||||
STEP3_OUTLINE
|
STEP3_OUTLINE
|
||||||
.replace("{{ANGLE}}", step2.text)
|
.replace("{{ANGLE}}", step2.text)
|
||||||
@ -922,6 +1008,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 4: Master Draft ═══
|
// ═══ STEP 4: Master Draft ═══
|
||||||
console.log(" Step 4/10: Master Draft (this takes a while)...");
|
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,
|
const step4 = await generate(systemPrompt,
|
||||||
STEP4_MASTER_DRAFT
|
STEP4_MASTER_DRAFT
|
||||||
.replace("{{OUTLINE}}", step3.text)
|
.replace("{{OUTLINE}}", step3.text)
|
||||||
@ -933,6 +1020,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 5: Reality Injection ═══
|
// ═══ STEP 5: Reality Injection ═══
|
||||||
console.log(" Step 5/10: Reality Injection...");
|
console.log(" Step 5/10: Reality Injection...");
|
||||||
|
setProgress(draftId, 5, "Step 5/10: Reality Injection");
|
||||||
const step5 = await generate(systemPrompt,
|
const step5 = await generate(systemPrompt,
|
||||||
STEP5_REALITY_INJECTION.replace("{{DRAFT}}", step4.text),
|
STEP5_REALITY_INJECTION.replace("{{DRAFT}}", step4.text),
|
||||||
LLM_REFINE
|
LLM_REFINE
|
||||||
@ -941,6 +1029,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 6: Technical Deepening ═══
|
// ═══ STEP 6: Technical Deepening ═══
|
||||||
console.log(" Step 6/10: Technical Deepening...");
|
console.log(" Step 6/10: Technical Deepening...");
|
||||||
|
setProgress(draftId, 6, "Step 6/10: Technical Deepening");
|
||||||
const step6 = await generate(systemPrompt,
|
const step6 = await generate(systemPrompt,
|
||||||
STEP6_TECHNICAL_DEEPENING.replace("{{ARTICLE}}", step5.text),
|
STEP6_TECHNICAL_DEEPENING.replace("{{ARTICLE}}", step5.text),
|
||||||
LLM_REFINE
|
LLM_REFINE
|
||||||
@ -949,6 +1038,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 7: Opinion Layer ═══
|
// ═══ STEP 7: Opinion Layer ═══
|
||||||
console.log(" Step 7/10: Opinion Layer...");
|
console.log(" Step 7/10: Opinion Layer...");
|
||||||
|
setProgress(draftId, 7, "Step 7/10: Opinion Layer");
|
||||||
const step7 = await generate(systemPrompt,
|
const step7 = await generate(systemPrompt,
|
||||||
STEP7_OPINION_LAYER.replace("{{ARTICLE}}", step6.text),
|
STEP7_OPINION_LAYER.replace("{{ARTICLE}}", step6.text),
|
||||||
LLM_REFINE
|
LLM_REFINE
|
||||||
@ -957,6 +1047,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 8: Kill AI Tone ═══
|
// ═══ STEP 8: Kill AI Tone ═══
|
||||||
console.log(" Step 8/10: Kill AI Tone...");
|
console.log(" Step 8/10: Kill AI Tone...");
|
||||||
|
setProgress(draftId, 8, "Step 8/10: Kill AI Tone");
|
||||||
const step8 = await generate(systemPrompt,
|
const step8 = await generate(systemPrompt,
|
||||||
STEP8_KILL_AI_TONE.replace("{{ARTICLE}}", step7.text),
|
STEP8_KILL_AI_TONE.replace("{{ARTICLE}}", step7.text),
|
||||||
LLM_REFINE
|
LLM_REFINE
|
||||||
@ -965,6 +1056,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 9: QA Check ═══
|
// ═══ STEP 9: QA Check ═══
|
||||||
console.log(" Step 9/10: QA Check...");
|
console.log(" Step 9/10: QA Check...");
|
||||||
|
setProgress(draftId, 9, "Step 9/10: QA Check");
|
||||||
const step9 = await generate(systemPrompt,
|
const step9 = await generate(systemPrompt,
|
||||||
STEP9_QA_CHECK.replace("{{ARTICLE}}", step8.text),
|
STEP9_QA_CHECK.replace("{{ARTICLE}}", step8.text),
|
||||||
LLM_REFINE
|
LLM_REFINE
|
||||||
@ -973,6 +1065,7 @@ async function runLlmPipeline(
|
|||||||
|
|
||||||
// ═══ STEP 10: Quality Score ═══
|
// ═══ STEP 10: Quality Score ═══
|
||||||
console.log(" Step 10/10: Quality Score...");
|
console.log(" Step 10/10: Quality Score...");
|
||||||
|
setProgress(draftId, 10, "Step 10/10: Quality Score");
|
||||||
let autoQaScore: Record<string, unknown> | null = null;
|
let autoQaScore: Record<string, unknown> | null = null;
|
||||||
try {
|
try {
|
||||||
const step10 = await generate(systemPrompt,
|
const step10 = await generate(systemPrompt,
|
||||||
@ -1039,8 +1132,10 @@ async function runLlmPipeline(
|
|||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearProgress(draftId);
|
||||||
console.log(`Blog FO Pipeline: ${draftId} complete — ${wordCount} words, ${stepsCompleted}/10 steps, QA: ${(autoQaScore as any)?.overall || "N/A"}/10`);
|
console.log(`Blog FO Pipeline: ${draftId} complete — ${wordCount} words, ${stepsCompleted}/10 steps, QA: ${(autoQaScore as any)?.overall || "N/A"}/10`);
|
||||||
} catch (llmErr) {
|
} catch (llmErr) {
|
||||||
|
clearProgress(draftId);
|
||||||
console.warn(`Blog FO Pipeline failed at step ${stepsCompleted + 1}/10 for ${draftId}: ${(llmErr as Error).message}`);
|
console.warn(`Blog FO Pipeline failed at step ${stepsCompleted + 1}/10 for ${draftId}: ${(llmErr as Error).message}`);
|
||||||
// Update with partial progress
|
// Update with partial progress
|
||||||
await pool.query(
|
await pool.query(
|
||||||
@ -1181,6 +1276,16 @@ blogRouter.get("/", async (_req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 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(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(
|
||||||
@ -1292,6 +1397,56 @@ blogRouter.get("/feedback/training-data", async (_req: Request, res: Response) =
|
|||||||
} catch (err) { res.status(500).json({ error: "Failed" }); }
|
} 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
|
// DELETE /api/blog/:id — Delete a blog draft
|
||||||
blogRouter.delete("/:id", async (req: Request, res: Response) => {
|
blogRouter.delete("/:id", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -794,18 +794,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 5-Year Forecast Chart -->
|
|
||||||
<div class="card mt">
|
|
||||||
<div class="card-header">5-Year Adoption Forecast</div>
|
|
||||||
<div id="forecast-chart" style="width:100%;overflow-x:auto"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Regional Adoption Heatmap -->
|
|
||||||
<div class="card mt">
|
|
||||||
<div class="card-header">Regional Adoption Heatmap</div>
|
|
||||||
<div id="regional-heatmap" style="width:100%;overflow-x:auto"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TRANSCEIVERS -->
|
<!-- TRANSCEIVERS -->
|
||||||
@ -880,6 +868,7 @@
|
|||||||
<div id="hot-topics-grid" class="grid g3 mb">
|
<div id="hot-topics-grid" class="grid g3 mb">
|
||||||
<div class="loading pulse">Loading topics...</div>
|
<div class="loading pulse">Loading topics...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="blog-pipeline-status"></div>
|
||||||
<div style="margin-bottom:0.5rem;text-align:right"><button onclick="deleteAllTemplateDrafts()" style="background:#c1121f;color:white;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:0.7rem">Delete All Templates</button></div><div class="card"><div id="blog-list"></div></div>
|
<div style="margin-bottom:0.5rem;text-align:right"><button onclick="deleteAllTemplateDrafts()" style="background:#c1121f;color:white;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:0.7rem">Delete All Templates</button></div><div class="card"><div id="blog-list"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2324,10 +2313,13 @@ async function loadBlogDrafts() {
|
|||||||
var sc = d.status === 'published' ? 'b-green' : d.status === 'review' ? 'b-yellow' : 'b-blue';
|
var sc = d.status === 'published' ? 'b-green' : d.status === 'review' ? 'b-yellow' : 'b-blue';
|
||||||
var gen = (d.generated_by || '').replace('tip-blog-engine-', '');
|
var gen = (d.generated_by || '').replace('tip-blog-engine-', '');
|
||||||
var gc = gen === 'llm' ? 'b-green' : gen === 'template-fallback' ? 'b-yellow' : 'b-neutral';
|
var gc = gen === 'llm' ? 'b-green' : gen === 'template-fallback' ? 'b-yellow' : 'b-neutral';
|
||||||
return '<div class="ri" onclick="openBlogDetail(\'' + esc(d.id) + '\')">'
|
return '<div class="ri" data-blog-id="' + esc(d.id) + '" data-blog-title="' + esc(d.title || '') + '" onclick="openBlogDetail(\'' + esc(d.id) + '\')">'
|
||||||
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
|
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
|
||||||
+ '<div class="ri-title">' + esc(d.title) + '</div>'
|
+ '<div class="ri-title">' + esc(d.title) + '</div>'
|
||||||
|
+ '<div style="display:flex;align-items:center;gap:8px">'
|
||||||
+ '<span class="b ' + sc + '">' + esc(d.status) + '</span>'
|
+ '<span class="b ' + sc + '">' + esc(d.status) + '</span>'
|
||||||
|
+ '<span class="blog-del-btn" data-blog-id="' + esc(d.id) + '" data-blog-title="' + esc(d.title || '') + '" title="Delete" style="color:#c1121f;cursor:pointer;font-size:0.9rem;padding:2px 6px;border-radius:4px" onclick="event.stopPropagation();blogDeleteClick(this)">✕</span>'
|
||||||
|
+ '</div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="ri-meta">'
|
+ '<div class="ri-meta">'
|
||||||
+ '<span class="b b-purple">' + esc(d.topic) + '</span>'
|
+ '<span class="b b-purple">' + esc(d.topic) + '</span>'
|
||||||
@ -2354,9 +2346,11 @@ async function openBlogDetail(id) {
|
|||||||
h += '<span class="b ' + methodBadge + '">' + esc(genMethod || 'template') + '</span> ';
|
h += '<span class="b ' + methodBadge + '">' + esc(genMethod || 'template') + '</span> ';
|
||||||
h += '<span class="mono dim">' + esc(d.word_count) + ' words</span>';
|
h += '<span class="mono dim">' + esc(d.word_count) + ' words</span>';
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
if (outline.quality_issues && outline.quality_issues.length > 0) {
|
var hasQualityIssues = outline.quality_issues && outline.quality_issues.length > 0;
|
||||||
h += '<div style="margin:0.5rem 0;padding:0.4rem 0.8rem;background:var(--yellow-light);border:1px solid rgba(212,163,115,0.4);border-radius:var(--radius-md);font-size:0.8rem">';
|
if (hasQualityIssues) {
|
||||||
h += '<strong style="color:#b8860b">Quality issues:</strong> ' + outline.quality_issues.map(esc).join(', ');
|
h += '<div style="margin:0.5rem 0;padding:0.4rem 0.8rem;background:var(--yellow-light);border:1px solid rgba(212,163,115,0.4);border-radius:var(--radius-md);font-size:0.8rem;display:flex;justify-content:space-between;align-items:center;gap:0.5rem">';
|
||||||
|
h += '<div><strong style="color:#b8860b">Quality issues:</strong> ' + outline.quality_issues.map(esc).join(', ') + '</div>';
|
||||||
|
h += '<button class="btn-ghost" onclick="event.stopPropagation();regenerateBlog(\'' + esc(d.id) + '\')" style="white-space:nowrap;color:#b8860b;border-color:rgba(212,163,115,0.5);font-size:0.75rem;padding:3px 10px">🔄 Regenerate</button>';
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2367,9 +2361,15 @@ async function openBlogDetail(id) {
|
|||||||
h += '</div>';
|
h += '</div>';
|
||||||
|
|
||||||
h += '<div style="font-size:0.85rem;color:var(--text);line-height:1.8;max-height:65vh;overflow-y:auto;background:var(--surface2);padding:1.2rem;border-radius:var(--radius-lg);border:1px solid var(--border)">' + mdToHtml(d.draft_content) + '</div>';
|
h += '<div style="font-size:0.85rem;color:var(--text);line-height:1.8;max-height:65vh;overflow-y:auto;background:var(--surface2);padding:1.2rem;border-radius:var(--radius-lg);border:1px solid var(--border)">' + mdToHtml(d.draft_content) + '</div>';
|
||||||
h += '<div class="panel-section">SEO Keywords</div>';
|
h += '<div class="panel-section">SEO Keywords / Hashtags</div>';
|
||||||
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + (d.seo_keywords || []).map(function(k) { return '<span class="b b-neutral">' + esc(k) + '</span>'; }).join('') + '</div>';
|
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + (d.seo_keywords || []).map(function(k) {
|
||||||
h += '<div style="margin-top:1rem;display:flex;gap:0.5rem">';
|
var tag = '#' + k.replace(/\s+/g, '');
|
||||||
|
return '<span class="b b-neutral" style="cursor:pointer;user-select:all" title="Click to copy hashtag" onclick="navigator.clipboard.writeText(\'' + esc(tag) + '\').then(function(){showToast(\'Copied\',\'' + esc(tag) + '\')})">' + esc(tag) + '</span>';
|
||||||
|
}).join('') + '</div>';
|
||||||
|
h += '<div style="margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap">';
|
||||||
|
if (d.status === 'review' || hasQualityIssues) {
|
||||||
|
h += '<button class="btn-ghost" onclick="event.stopPropagation();regenerateBlog(\'' + esc(d.id) + '\')" style="color:#b8860b;border-color:rgba(212,163,115,0.5)">🔄 Neu generieren</button>';
|
||||||
|
}
|
||||||
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'review\')" style="color:#b8860b;border-color:rgba(212,163,115,0.4)">Mark Review</button>';
|
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'review\')" style="color:#b8860b;border-color:rgba(212,163,115,0.4)">Mark Review</button>';
|
||||||
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'approved\')" style="color:var(--green);border-color:rgba(45,106,79,0.3)">Approve</button>';
|
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'approved\')" style="color:var(--green);border-color:rgba(45,106,79,0.3)">Approve</button>';
|
||||||
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'published\')" style="color:var(--accent);border-color:rgba(196,112,75,0.3)">Publish</button>';
|
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'published\')" style="color:var(--accent);border-color:rgba(196,112,75,0.3)">Publish</button>';
|
||||||
@ -2393,6 +2393,24 @@ async function updateBlogStatus(id, status) {
|
|||||||
} catch(e) { showToast('Error', e.message, true); }
|
} catch(e) { showToast('Error', e.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function regenerateBlog(id) {
|
||||||
|
showToast('Regenerating…', 'LLM pipeline wird neu gestartet');
|
||||||
|
try {
|
||||||
|
var data = await fetch(API + '/api/blog/' + id + '/regenerate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}).then(function(r) { return r.json(); });
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Gestartet', 'LLM läuft – Status wird aktualisiert');
|
||||||
|
loadBlogDrafts();
|
||||||
|
// Poll for completion
|
||||||
|
pollBlogLlm(id, 0);
|
||||||
|
} else {
|
||||||
|
showToast('Fehler', data.error || 'Regenerierung fehlgeschlagen', true);
|
||||||
|
}
|
||||||
|
} catch(e) { showToast('Error', e.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
// TABLE SORTING
|
// TABLE SORTING
|
||||||
function makeSortable(table) {
|
function makeSortable(table) {
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user