feat: add blog draft generator and fix broken routes

Blog drafting section (dev-only):
- BlogDraftGenerator gathers project data (gaps, proposals, stats) as
  context and calls Claude to produce Medium-style blog posts
- DB schema: blog_drafts table with title, content, tags, cost tracking
- Web UI: list, generate (async with live preview), detail (rendered +
  source toggle), edit, and export routes
- 6 writing styles: deep-dive, overview, opinion, listicle, comparison,
  series-post
- Nav link added to sidebar under Proposals

Bug fixes found via route testing (scripts/test_all_routes.py):
- /authors/<id>: Draft.status → Draft.states (correct attribute name)
- /false-positives: add missing `import re` in ratings.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 04:39:16 +01:00
parent 2229e70c73
commit d1a20fa02e
13 changed files with 1481 additions and 1 deletions

View File

@@ -218,6 +218,26 @@ CREATE TABLE IF NOT EXISTS proposal_gaps (
CREATE INDEX IF NOT EXISTS idx_proposal_gaps_gap ON proposal_gaps(gap_id);
-- Blog drafts (generated or manually written)
CREATE TABLE IF NOT EXISTS blog_drafts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
subtitle TEXT DEFAULT '',
slug TEXT NOT NULL UNIQUE,
status TEXT DEFAULT 'draft',
style TEXT DEFAULT 'deep-dive',
tags TEXT DEFAULT '[]',
topic TEXT DEFAULT '',
instructions TEXT DEFAULT '',
content_md TEXT DEFAULT '',
model_used TEXT DEFAULT '',
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cost_usd REAL DEFAULT 0,
created_at TEXT,
updated_at TEXT
);
-- Annotations (user notes + tags per draft)
CREATE TABLE IF NOT EXISTS annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1169,6 +1189,62 @@ class Database:
).fetchall()
return [dict(r) for r in rows]
# --- Blog Drafts ---
def all_blog_drafts(self) -> list[dict]:
rows = self.conn.execute(
"SELECT * FROM blog_drafts ORDER BY updated_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_blog_draft(self, draft_id: int) -> dict | None:
row = self.conn.execute(
"SELECT * FROM blog_drafts WHERE id = ?", (draft_id,)
).fetchone()
return dict(row) if row else None
def upsert_blog_draft(self, draft: dict) -> int:
"""Insert or update a blog draft. Returns the draft ID."""
now = datetime.now(timezone.utc).isoformat()
if draft.get("id"):
self.conn.execute(
"""UPDATE blog_drafts SET title=?, subtitle=?, slug=?, status=?,
style=?, tags=?, topic=?, instructions=?, content_md=?,
model_used=?, input_tokens=?, output_tokens=?, cost_usd=?,
updated_at=?
WHERE id=?""",
(draft["title"], draft.get("subtitle", ""), draft["slug"],
draft.get("status", "draft"), draft.get("style", "deep-dive"),
json.dumps(draft.get("tags", [])), draft.get("topic", ""),
draft.get("instructions", ""), draft.get("content_md", ""),
draft.get("model_used", ""), draft.get("input_tokens", 0),
draft.get("output_tokens", 0), draft.get("cost_usd", 0),
now, draft["id"]),
)
self.conn.commit()
return draft["id"]
else:
cur = self.conn.execute(
"""INSERT INTO blog_drafts (title, subtitle, slug, status, style,
tags, topic, instructions, content_md, model_used,
input_tokens, output_tokens, cost_usd, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(draft["title"], draft.get("subtitle", ""), draft["slug"],
draft.get("status", "draft"), draft.get("style", "deep-dive"),
json.dumps(draft.get("tags", [])), draft.get("topic", ""),
draft.get("instructions", ""), draft.get("content_md", ""),
draft.get("model_used", ""), draft.get("input_tokens", 0),
draft.get("output_tokens", 0), draft.get("cost_usd", 0),
now, now),
)
self.conn.commit()
return cur.lastrowid
def delete_blog_draft(self, draft_id: int) -> bool:
cur = self.conn.execute("DELETE FROM blog_drafts WHERE id = ?", (draft_id,))
self.conn.commit()
return cur.rowcount > 0
# --- Refs ---
def insert_refs(self, draft_name: str, refs: list[tuple[str, str]]) -> None: