feat: blog engine v5 — narrative control + linkedin post + min words fix

- STEP4b_NARRATIVE_CONTROL: new pipeline step after draft; detects wrong
  narrative (technology blamed instead of processes), applies anti-FUD filter,
  reality reframe ("this becomes a problem when..."), Flexoptix voice check
- System prompt: NARRATIVE CONTROL RULE added as absolute rule #1
- Gold Standard 4: corrected "compatible vs OEM" article added as reference
- Minimum words: STEP4 raised from 1500 to 2500 words (final output was 750)
- Reduction pass: 25-35% → 15-25%, target 1500-2000 words final
- STEP_LINKEDIN_POST: generates LinkedIn post ≤2800 chars (hard limit 3000);
  stores in blog_drafts.linkedin_post + linkedin_char_count column
- Pipeline now 14 steps: v5-narrative-control
- Migration 024: linkedin_post + linkedin_char_count columns in blog_drafts
This commit is contained in:
Rene Fichtmueller 2026-04-04 08:30:27 +02:00
parent be9209ffbd
commit 1e19365e96
3 changed files with 361 additions and 40 deletions

View File

@ -27,6 +27,32 @@ export const FO_BLOG_SYSTEM_PROMPT = `You are a senior network engineer with 20+
You write for the FLEXOPTIX technical blog. Your readers are network engineers who build and operate real infrastructure.
NARRATIVE CONTROL ABSOLUTE RULE #1 (overrides everything)
Flexoptix sells compatible optics. This blog must make engineers trust compatible optics MORE, not less.
CORE PRINCIPLE: Technology is NEVER the root problem. Bad assumptions and bad processes are.
CORRECT FRAMING:
"compatible optics shift validation responsibility to the operator"
"this becomes a problem when validation is skipped"
"400G exposes what was always marginal — the optics don't introduce new problems"
"the failure isn't the optic. it's everything around it."
FORBIDDEN FRAMING (hard fail rewrite immediately):
"compatible optics cause problems" OEM FUD. Never.
"this technology is risky" Only allowed with explicit condition: "risky when X"
Stacking 5+ failure scenarios without resolution fear-mongering, not engineering
Implying OEM = safe, compatible = gamble not Flexoptix voice
FINAL OUTCOME TEST:
If a reader finishes this article thinking "I'll just buy OEM to be safe" article FAILED.
If a reader finishes thinking "I need to validate properly before deployment" article PASSED.
YOUR MINDSET:
- You write like an engineer at 2:17 AM in the DC, not like a marketing department
- You base everything on real problems, not spec sheets
@ -356,7 +382,10 @@ CONTENT APPROACH:
- Every number gets context (deployment size, vendor type, conditions)
- Max 3-4 core ideas pick the best and develop them through experience
MINIMUM 1500 words. No placeholders. No TODO markers. No sections. Complete prose article.
MINIMUM 2500 words. No placeholders. No TODO markers. No sections. Complete prose article.
NARRATIVE REMINDER: The failure is never the optic. It's the environment, the assumptions, the process.
Write with that framing from the first sentence.
Context data from Flexoptix database (verified use exactly as provided):
{{CONTEXT_DATA}}
@ -364,6 +393,91 @@ Context data from Flexoptix database (verified — use exactly as provided):
Outline:
{{OUTLINE}}`;
// ═══════════════════════════════════════════════════════
// STEP 4b: NARRATIVE CONTROL
// (2026-04-04: Added — LLM systematic error: correct problem detection,
// wrong cause assignment. Blames technology instead of processes.)
// ═══════════════════════════════════════════════════════
export const STEP4b_NARRATIVE_CONTROL = `FLEXOPTIX NARRATIVE CONTROL — read this article line by line and apply four corrections.
CORRECTION 1: ROOT CAUSE ASSIGNMENT
The article may frame technology (optics, standards, form factors) as the root problem.
That is wrong. Technology is never the root problem. Fix it everywhere it appears.
FIND patterns like:
- "compatible optics cause [X]"
- "this technology introduces [risk/problems/failure]"
- "[technology] can lead to [negative outcome]" (without specifying the condition)
REPLACE with correct framing:
- "unvalidated deployments of [technology] cause [X]"
- "[technology] exposes problems that were always present in the environment"
- "this becomes a problem when [specific condition — dirty connectors / firmware mismatch / MMF plant reused for SMF]"
The optic is not the problem. The optic is the test that reveals existing problems.
CORRECTION 2: ANTI-FUD FILTER
Scan for fear-based statements. Rewrite any of these patterns:
- Exaggerated risk without condition: "can be brutal" "is expensive when X"
- Stacked failure scenarios (4+ worst-cases in a row, no resolution) keep max 2-3, add one positive conditional
- OEM-style framing: "compatible = risky" or "cheap = unreliable" rewrite as "compatible = operator-owned validation"
- Absolute negative claims without context: "X always fails" "X fails when Y"
Every problem statement must answer implicitly: under what condition? What makes it worse? What fixes it?
CORRECTION 3: REALITY REFRAME
For each failure described in the article, ensure it is framed as conditional:
"this becomes a problem when…"
If the article says: "compatible optics have interoperability issues"
Rewrite as: "interoperability issues surface when firmware combinations are untested, cabling is marginal, or validation was skipped — the same conditions that cause OEM issues"
If the article says: "400G DR4 has tight loss budgets"
Rewrite as: "400G DR4 has tighter loss budgets than 100G SR4, which means anything that was marginal before becomes visible now"
The reframe doesn't soften the problem. It places it correctly: environment and process, not hardware.
CORRECTION 4: FLEXOPTIX VOICE CHECK
Apply the Flexoptix outcome test:
Read the final paragraph. Ask: what does a reader conclude?
WRONG conclusion: "I should just buy OEM to be safe."
Rewrite the conclusion or ending to shift this.
RIGHT conclusion: "I need to validate properly. Compatible optics work when I do my part."
This is the Flexoptix message.
The blog is not an OEM sales tool. It's proof that engineers who know what they're doing use compatible optics successfully.
OUTPUT RULE
Return ONLY the corrected article.
No explanation of what you changed.
No commentary, no "I rewrote X".
Start directly with the article hook. End with the final sentence.
Do not add new sections or headers.
Do not change the overall structure only fix narrative framing where needed.
Article:
{{ARTICLE}}`;
// ═══════════════════════════════════════════════════════
// STEP 5: REALITY INJECTION
// ═══════════════════════════════════════════════════════
@ -1011,6 +1125,102 @@ KEY ELEMENTS OF THIS STYLE B EXAMPLE 3:
- Ending: the cost is lost time, stated simply and directly
- ZERO section headers, ZERO bullet lists, ZERO numbered steps
STYLE B GOLD EXAMPLE 4 (2026-04-04 validated Compatible vs OEM, Narrative Control)
Topic: Price War / compatible optics. CRITICAL: This is the corrected narrative compatible problem.
This example was generated after wrong narrative feedback. Use as reference for ANY compatible optics article.
"You're looking at a quote for a few hundred 400G optics.
OEM pricing is what it always is. Then you look at compatible optics, and suddenly the numbers drop hard. Same form factor, same standards, much lower price.
That's usually the moment where people get uncomfortable.
Because the first instinct isn't excitement. It's suspicion.
'What's the catch?'
There usually isn't one.
At least not in the way people think.
The optics themselves aren't the problem. Modern compatible modules are solid. Interop works. Standards are real. If something doesn't come up, it's rarely because the optic is 'cheap'.
But that doesn't mean deployments are frictionless.
Because what actually breaks isn't the optic.
It's everything around it.
Most issues people run into with compatible optics look like this:
A link comes up, but behaves differently under load.
Another one shows CRC errors that shouldn't be there.
Everything works in the lab, but production feels inconsistent.
The natural reaction is to blame the optics.
That's almost always the wrong conclusion.
What's actually happening is much less exciting. You've changed one variable the optic and suddenly everything else in your setup gets exposed.
Cabling that was marginal but 'good enough' before now sits right at the edge.
Connectors that were never properly cleaned start to matter.
Firmware combinations you never tested together suddenly behave differently.
None of that shows up in a clean lab test. It shows up when you deploy at scale.
I've seen this play out more than once. Everything validated, everything looking good. Then production rollout starts and a handful of links behave strangely. Not down, just unstable enough to be annoying.
So you swap optics. No change. You swap ports. No change.
Eventually someone cleans the connectors properly not visually, actually checks them and the problem disappears.
Same optics. Same config. Different result.
That's the moment where the narrative usually flips.
Polarity is another classic. It's one of those things that's assumed to be correct because it always has been. Until it isn't.
At 400G, a mismatch in your MPO layout doesn't give you degraded performance. It gives you a dead link that looks completely fine from a config perspective.
So again, optics get blamed first. Physical layer gets checked last.
And then there's the real cost nobody talks about. Not optics. Time.
The time spent debugging issues that don't have a single clean root cause. The time spent validating combinations that were never tested together. The time lost because assumptions from previous generations don't hold anymore.
Compatible optics don't remove that complexity.
But they don't introduce it either.
They just remove the price premium.
If you treat a deployment the same way you did five years ago minimal validation, assumptions about cabling, trusting that everything 'just works' you will run into issues. It doesn't matter if you use OEM or compatible.
The difference is: with compatible optics, people are quicker to blame the hardware.
What actually works is pretty simple. Validate the setup you're going to run, not a simplified version of it. Treat the physical layer seriously cleaning, inspection, polarity, mapping. Test combinations of optics, platforms, and firmware before scaling.
Because in most cases, the optics are doing exactly what they're supposed to.
They're just showing you everything else that isn't."
KEY ELEMENTS OF THIS STYLE B EXAMPLE 4:
- Compatible optics NOT framed as problem framed as "exposing existing problems"
- Root causes named correctly: cabling, connectors, firmware combinations, assumptions
- No fear-mongering calm, factual, slightly uncomfortable
- Ending shifts responsibility to process, not to product choice
- Zero section headers, zero bullet lists
- Reader conclusion: "I need better validation" not "I need OEM"
WHAT TRIGGERED THIS GOLD STANDARD (learn from the failure)
WRONG version wrote: "compatible optics = hidden costs, extra QA, complex validation"
This is OEM FUD. It implies compatible = risky by default.
Flexoptix sells compatible optics. This narrative destroys the brand.
CORRECT version writes: "validation gaps = hidden costs, not optic choice"
Any deployment OEM or compatible fails when validation is skipped.
Compatible optics just make engineers more likely to blame the hardware instead of their process.
WRONG PATTERNS (both styles never produce):
"Thoroughly Test Your PoE Budget:" (PoE = wrong context, checklist = wrong format)
"QSFP-DD DR4 (Direct Attach)" (DR4 Direct Attach DAC is Direct Attach Copper)
@ -1066,15 +1276,78 @@ POWER / LOSS BUDGET PRECISION (always apply):
`;
// ═══════════════════════════════════════════════════════
// STEP 8b: REDUCTION PASS — Remove 25-35% of content
// STEP LINKEDIN: Generate LinkedIn post from final article
// (2026-04-04: LinkedIn hard limit = 3,000 chars. Optimal = 800-1500 chars.)
// ═══════════════════════════════════════════════════════
export const STEP_LINKEDIN_POST = `Write a LinkedIn post for this article.
HARD LIMIT: Maximum 2,800 characters total (LinkedIn's limit is 3,000 stay well under it).
OPTIMAL LENGTH: 8001,400 characters. This gets read in full, without "see more" truncation.
STRUCTURE (in this order):
1. HOOK LINE (first 1-2 sentences this is the ONLY part visible before "see more")
- Start with a bold statement, uncomfortable truth, or specific number
- NOT "I just published a new blog post"
- NOT "Check out my latest article"
- Something that makes an engineer stop scrolling
2. BODY (3-5 short paragraphs, 1-2 sentences each)
- One insight per paragraph
- Leave space between paragraphs (line breaks)
- Keep it specific numbers, conditions, real scenarios
- Write for engineers, not recruiters
3. CALL TO ACTION (1 sentence)
- Direct link invite: "Full breakdown on the Flexoptix blog — link in first comment."
- Do NOT include a URL in the post itself (kills reach on LinkedIn)
4. HASHTAGS (last line, 3-5 tags)
- Use: #OpticalNetworking #DataCenter #400G #Flexoptix + one topic-specific tag
- Never more than 5 hashtags
STYLE RULES:
- Engineer voice, not LinkedIn influencer voice
- No emojis unless one very strategic one at the start of the hook
- No "I'm thrilled to announce" / "Excited to share"
- No bullet lists inside the post use short paragraphs instead
- No markdown, no bold (**text**), no headers
- Write as if you're the engineer who wrote the article, not a social media manager
EXAMPLE STRUCTURE (700 chars, correct format):
At 400G, your existing cabling becomes your biggest risk. Not the optics.
When teams move from 100G SR4 to 400G DR4, they assume the cabling stays the same. Both use 8 fibers. Looks identical on paper.
In production, everything tightens up. Loss budgets shrink. Margins disappear. Connectors that were 'fine' for years suddenly cause CRC errors.
The optics don't fail. They expose what was always marginal.
We put together a full breakdown of what actually breaks and what to look for before deployment. Link in first comment.
#OpticalNetworking #400G #DataCenter #NetworkEngineering #Flexoptix
---
COUNT YOUR OUTPUT: After writing, count the total characters (including spaces and hashtags). If over 2,800 cut.
Return ONLY the LinkedIn post text. Nothing else. No explanation. No "Here is the post:".
Article:
{{ARTICLE}}`;
// ═══════════════════════════════════════════════════════
// STEP 8b: REDUCTION PASS — Remove 15-25% of content
// (2026-04-04: Added based on field feedback — articles were too long,
// repeated concepts, and "assembled" rather than written)
// ═══════════════════════════════════════════════════════
export const STEP8b_REDUCTION = `Cut this article by 2535%.
export const STEP8b_REDUCTION = `Cut this article by 1525%.
This is not optional. After the previous passes, the article has grown too long and repeats itself.
This is not optional. After the previous passes, the article has grown and accumulated some padding.
The goal is a tighter, more natural text not a shorter version of the same article.
Target: the final article should be 15002000 words. Do not cut below 1500 words.
WHAT TO REMOVE:
- Any concept explained more than once (pick its best version, cut the rest)

View File

@ -16,8 +16,8 @@ import { pool } from "../db/client";
const pipelineProgress = new Map<string, { step: number; total: number; label: string; pct: number }>();
function setProgress(draftId: string, step: number, label: string): void {
const pct = Math.round((step / 12) * 92) + 2; // 2%..94% during run, 100% on complete
pipelineProgress.set(draftId, { step, total: 12, label, pct });
const pct = Math.round((step / 14) * 92) + 2; // 2%..94% during run, 100% on complete
pipelineProgress.set(draftId, { step, total: 14, label, pct });
}
function clearProgress(draftId: string): void {
@ -997,6 +997,7 @@ async function runLlmPipeline(
STEP2_ANGLE_SELECTION,
STEP3_OUTLINE,
STEP4_MASTER_DRAFT,
STEP4b_NARRATIVE_CONTROL,
STEP5_REALITY_INJECTION,
STEP6_TECHNICAL_DEEPENING,
STEP7_OPINION_LAYER,
@ -1005,14 +1006,15 @@ async function runLlmPipeline(
STEP8c_STYLE_LOCK,
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: 6144, timeoutMs: 480000 };
const LLM_OPTS = { temperature: 0.7, maxTokens: 8192, timeoutMs: 480000 };
const LLM_REFINE = { temperature: 0.4, maxTokens: 6144, timeoutMs: 480000 };
const TOTAL_STEPS = 12; // 10 original + 8b Reduction + 8c Style Lock
const TOTAL_STEPS = 14; // 10 original + 4b Narrative Control + 8b Reduction + 8c Style Lock + LinkedIn
let stepsCompleted = 0;
try {
@ -1125,18 +1127,28 @@ async function runLlmPipeline(
stepsCompleted = 4;
console.log(` Draft: ${step4.text.split(/\s+/).length} words`);
// ═══ STEP 5: Reality Injection ═══
console.log(" Step 5/10: Reality Injection...");
setProgress(draftId, 5, "Step 5/10: Reality Injection");
const step5 = await generate(systemPrompt,
STEP5_REALITY_INJECTION.replace("{{DRAFT}}", step4.text),
// ═══ 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 6/10: Technical Deepening...");
setProgress(draftId, 6, "Step 6/10: 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
@ -1144,54 +1156,54 @@ async function runLlmPipeline(
stepsCompleted = 6;
// ═══ STEP 7: Opinion Layer ═══
console.log(" Step 7/10: Opinion Layer...");
setProgress(draftId, 7, "Step 7/10: 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 = 7;
stepsCompleted = 8;
// ═══ STEP 8: Kill AI Tone ═══
console.log(" Step 8/10: Kill AI Tone...");
setProgress(draftId, 8, "Step 8/10: 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 = 8;
stepsCompleted = 9;
// ═══ STEP 8b: Reduction Pass ═══
console.log(" Step 9/12: Reduction Pass (remove 25-35%)...");
setProgress(draftId, 9, "Step 9/12: Reduction Pass");
console.log(" Step 10/13: Reduction Pass (remove 15-25%, keep ≥1500 words)...");
setProgress(draftId, 10, "Step 10/13: Reduction Pass");
const step8b = await generate(systemPrompt,
STEP8b_REDUCTION.replace("{{ARTICLE}}", step8.text),
LLM_REFINE
);
stepsCompleted = 9;
stepsCompleted = 10;
console.log(` After reduction: ${step8b.text.split(/\s+/).length} words (was ${step8.text.split(/\s+/).length})`);
// ═══ STEP 8c: Style Lock ═══
console.log(" Step 10/12: Style Lock (tone consistency + scope/SKU fixes)...");
setProgress(draftId, 10, "Step 10/12: Style Lock");
console.log(" Step 11/13: Style Lock (tone consistency + scope/SKU fixes)...");
setProgress(draftId, 11, "Step 11/13: Style Lock");
const step8c = await generate(systemPrompt,
STEP8c_STYLE_LOCK.replace("{{ARTICLE}}", step8b.text),
LLM_REFINE
);
stepsCompleted = 10;
stepsCompleted = 11;
// ═══ STEP 9: QA Check ═══
console.log(" Step 11/12: QA Check...");
setProgress(draftId, 11, "Step 11/12: QA Check");
console.log(" Step 12/13: QA Check...");
setProgress(draftId, 12, "Step 12/13: QA Check");
const step9 = await generate(systemPrompt,
STEP9_QA_CHECK.replace("{{ARTICLE}}", step8c.text),
LLM_REFINE
);
stepsCompleted = 11;
stepsCompleted = 12;
// ═══ STEP 10: Quality Score ═══
console.log(" Step 12/12: Quality Score...");
setProgress(draftId, 12, "Step 12/12: Quality Score");
console.log(" Step 13/13: Quality Score...");
setProgress(draftId, 13, "Step 13/13: Quality Score");
let autoQaScore: Record<string, unknown> | null = null;
try {
const step10 = await generate(systemPrompt,
@ -1207,7 +1219,32 @@ async function runLlmPipeline(
} catch {
console.log(" Quality scoring skipped (parse error)");
}
stepsCompleted = 12;
stepsCompleted = 13;
// ═══ LinkedIn Post ═══
console.log(" Step 14/14: LinkedIn Post (max 2,800 chars)...");
setProgress(draftId, 14, "Step 14/14: 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 = 14;
// Extract only the article from STEP9 output (QA returns review + fixed article)
// Look for "COMPLETE FIXED ARTICLE" marker and take everything after it
@ -1244,26 +1281,30 @@ async function runLlmPipeline(
await pool.query(
`UPDATE blog_drafts
SET draft_content = $1, word_count = $2,
generated_by = 'fo-blog-engine-v4',
pipeline_version = 'v4-reduction-stylelock',
generated_by = 'fo-blog-engine-v5',
pipeline_version = 'v5-narrative-control',
pipeline_steps_completed = $3,
auto_qa_score = $4,
outline = $5,
linkedin_post = $6,
linkedin_char_count = $7,
status = 'draft',
updated_at = NOW()
WHERE id = $6::uuid`,
WHERE id = $8::uuid`,
[
draftContent,
wordCount,
stepsCompleted,
autoQaScore ? JSON.stringify(autoQaScore) : null,
JSON.stringify({
generation_method: "fo-pipeline-v3",
generation_method: "fo-pipeline-v5",
steps_completed: stepsCompleted,
blog_type: selectedTopic,
quality_issues: finalIssues,
feedback_entries_used: feedbackContext ? feedbackContext.split("\n").length : 0,
}),
linkedinPost,
linkedinCharCount,
draftId,
],
);
@ -1285,13 +1326,13 @@ async function runLlmPipeline(
}
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}/14 steps, QA: ${(autoQaScore as any)?.overall || "N/A"}/10, LinkedIn: ${linkedinCharCount ?? "n/a"} chars`);
} catch (llmErr) {
clearProgress(draftId);
console.warn(`Blog FO Pipeline failed at step ${stepsCompleted + 1}/10 for ${draftId}: ${(llmErr as Error).message}`);
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 = 'v3-flexoptix-style',
`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(() => {});

View File

@ -0,0 +1,7 @@
-- Migration 024: Add linkedin_post column to blog_drafts
-- LinkedIn hard limit: 3,000 characters
-- Optimal visibility (before "see more"): ~210 chars preview
ALTER TABLE blog_drafts
ADD COLUMN IF NOT EXISTS linkedin_post TEXT,
ADD COLUMN IF NOT EXISTS linkedin_char_count INTEGER;