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

225
scripts/test_all_routes.py Normal file
View File

@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Test ALL web UI routes and report any returning 500 errors.
Runs against the Flask test client with dev=True so admin routes are accessible.
Queries SQLite DB for real IDs needed by dynamic routes.
"""
import os
import sys
import sqlite3
import traceback
# Must run from src/ directory
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
sys.path.insert(0, src_dir)
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'data', 'drafts.db')
def get_test_ids():
"""Query the database for real IDs to use in dynamic routes."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
ids = {}
# Get a draft name
row = conn.execute("SELECT name FROM drafts LIMIT 1").fetchone()
ids["draft_name"] = row["name"] if row else "draft-nonexistent"
# Get a person_id from authors
row = conn.execute("SELECT person_id FROM authors WHERE person_id IS NOT NULL LIMIT 1").fetchone()
ids["person_id"] = row["person_id"] if row else 1
# Get a gap_id
row = conn.execute("SELECT id FROM gaps LIMIT 1").fetchone()
ids["gap_id"] = row["id"] if row else 1
# Get an idea_id
row = conn.execute("SELECT id FROM ideas LIMIT 1").fetchone()
ids["idea_id"] = row["id"] if row else 1
# Get a proposal_id (may not exist)
try:
row = conn.execute("SELECT id FROM proposals LIMIT 1").fetchone()
ids["proposal_id"] = row["id"] if row else None
except Exception:
ids["proposal_id"] = None
# Get a blog_draft_id (may not exist)
try:
row = conn.execute("SELECT id FROM blog_drafts LIMIT 1").fetchone()
ids["blog_draft_id"] = row["id"] if row else None
except Exception:
ids["blog_draft_id"] = None
# Get two draft names for compare
rows = conn.execute("SELECT name FROM drafts LIMIT 2").fetchall()
ids["two_drafts"] = ",".join(r["name"] for r in rows) if len(rows) >= 2 else ""
conn.close()
return ids
def main():
ids = get_test_ids()
print(f"Test IDs: draft={ids['draft_name']}, person={ids['person_id']}, "
f"gap={ids['gap_id']}, idea={ids['idea_id']}, "
f"proposal={ids['proposal_id']}, blog={ids['blog_draft_id']}")
print()
from webui.app import create_app
app = create_app(dev=True)
client = app.test_client()
# Define all routes to test: (method, url, description)
routes = [
# === Pages (public) ===
("GET", "/", "overview"),
("GET", "/drafts", "drafts list"),
("GET", f"/drafts/{ids['draft_name']}", "draft detail"),
("GET", "/ideas", "ideas"),
("GET", "/ratings", "ratings"),
("GET", "/timeline", "timeline"),
("GET", "/idea-clusters", "idea clusters"),
("GET", "/architecture", "architecture"),
("GET", f"/authors/{ids['person_id']}", "author detail"),
("GET", "/authors", "authors"),
("GET", "/citations", "citations"),
("GET", "/about", "about"),
("GET", "/impressum", "impressum"),
("GET", "/datenschutz", "datenschutz"),
("GET", "/search", "search (empty)"),
("GET", "/search?q=agent", "search (query)"),
("GET", "/ask", "ask (empty)"),
# === API (public) ===
("GET", "/api/drafts", "api drafts"),
("GET", "/api/stats", "api stats"),
("GET", "/api/authors/network", "api author network"),
("GET", "/api/citations", "api citations"),
("GET", "/api/search?q=agent", "api search"),
("GET", "/api/ideas", "api ideas"),
("GET", "/api/ratings", "api ratings"),
("GET", "/api/timeline", "api timeline"),
("GET", "/api/idea-clusters", "api idea clusters"),
("GET", "/api/categories", "api categories"),
("GET", f"/api/drafts/{ids['draft_name']}", "api draft detail"),
("GET", "/api/architecture", "api architecture"),
# === Admin pages ===
("GET", "/gaps", "gaps"),
("GET", "/gaps/demo", "gaps demo"),
("GET", f"/gaps/{ids['gap_id']}", "gap detail"),
("GET", "/api/gaps", "api gaps"),
("GET", f"/api/gaps/{ids['gap_id']}", "api gap detail"),
("GET", "/monitor", "monitor"),
("GET", "/api/monitor", "api monitor"),
("GET", "/admin/analytics", "analytics"),
("GET", "/landscape", "landscape"),
("GET", "/api/landscape", "api landscape"),
("GET", "/similarity", "similarity"),
("GET", "/api/similarity", "api similarity"),
("GET", "/compare", "compare (empty)"),
("GET", f"/compare?drafts={ids['two_drafts']}", "compare (with drafts)"),
("GET", "/sources", "sources"),
("GET", "/false-positives", "false positives"),
("GET", "/api/sources", "api sources"),
("GET", "/api/false-positives", "api false positives"),
("GET", "/api/citations/influence", "api citation influence"),
("GET", "/api/citations/bcp", "api bcp analysis"),
("GET", "/idea-analysis", "idea analysis"),
("GET", "/api/idea-analysis", "api idea analysis"),
("GET", f"/ideas/{ids['idea_id']}", "idea detail"),
("GET", "/trends", "trends"),
("GET", "/complexity", "complexity"),
("GET", "/api/trends", "api trends"),
("GET", "/api/complexity", "api complexity"),
("GET", "/proposals", "proposals"),
("GET", "/proposals/new", "proposal new (GET)"),
("GET", "/api/proposals", "api proposals"),
("GET", "/proposals/intake", "proposal intake (GET)"),
("GET", "/blog", "blog drafts"),
("GET", "/blog/generate", "blog generate (GET)"),
("GET", "/export/obsidian", "obsidian export"),
]
# Add conditional routes that need existing records
if ids["proposal_id"]:
routes.extend([
("GET", f"/proposals/{ids['proposal_id']}", "proposal detail"),
("GET", f"/proposals/{ids['proposal_id']}/edit", "proposal edit (GET)"),
("GET", f"/api/proposals/{ids['proposal_id']}", "api proposal detail"),
])
if ids["blog_draft_id"]:
routes.extend([
("GET", f"/blog/{ids['blog_draft_id']}", "blog detail"),
("GET", f"/blog/{ids['blog_draft_id']}/edit", "blog edit (GET)"),
("GET", f"/blog/{ids['blog_draft_id']}/export", "blog export"),
])
# Run tests
results = {"ok": [], "error_500": [], "error_other": [], "exception": []}
for method, url, desc in routes:
try:
if method == "GET":
resp = client.get(url)
elif method == "POST":
resp = client.post(url, json={})
else:
continue
status = resp.status_code
if status == 500:
# Try to extract error from response
error_text = resp.data.decode("utf-8", errors="replace")[:500]
results["error_500"].append((desc, url, status, error_text))
print(f" FAIL 500 {desc:30s} {url}")
elif status >= 400:
results["error_other"].append((desc, url, status))
print(f" WARN {status} {desc:30s} {url}")
else:
results["ok"].append((desc, url, status))
print(f" OK {status} {desc:30s} {url}")
except Exception as e:
tb = traceback.format_exc()
results["exception"].append((desc, url, str(e), tb))
print(f" EXCP {desc:30s} {url} -> {e}")
# Summary
print("\n" + "=" * 70)
print(f"SUMMARY: {len(results['ok'])} OK, {len(results['error_500'])} x 500, "
f"{len(results['error_other'])} x 4xx, {len(results['exception'])} exceptions")
print("=" * 70)
if results["error_500"]:
print("\n--- 500 ERRORS (BROKEN ROUTES) ---")
for desc, url, status, error_text in results["error_500"]:
print(f"\n Route: {desc}")
print(f" URL: {url}")
print(f" Error preview:")
# Show just enough of the error
for line in error_text.split("\n")[:10]:
print(f" {line}")
if results["exception"]:
print("\n--- EXCEPTIONS (APP CRASHED) ---")
for desc, url, err, tb in results["exception"]:
print(f"\n Route: {desc}")
print(f" URL: {url}")
print(f" Exception: {err}")
# Show last few lines of traceback
tb_lines = tb.strip().split("\n")
for line in tb_lines[-5:]:
print(f" {line}")
if results["error_other"]:
print("\n--- 4xx RESPONSES (may be expected) ---")
for desc, url, status in results["error_other"]:
print(f" {status} {desc:30s} {url}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,319 @@
"""Blog Draft Generator.
Generates Medium-style blog posts from project data: gaps, proposals,
drafts, stats, and the existing blog series narrative arc.
Usage:
from ietf_analyzer.blog_drafts import BlogDraftGenerator
gen = BlogDraftGenerator(config, db)
result = gen.generate(topic="all analysis results", style="deep-dive")
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
load_dotenv()
import anthropic
from rich.console import Console
from .config import Config
from .db import Database
console = Console()
BLOG_SYSTEM_PROMPT = """\
You are an expert technical writer producing Medium-style blog posts about IETF \
AI agent standardization. You write for a technical but non-specialist audience — \
engineers, product managers, and policy people who care about AI agent \
interoperability and safety.
Your writing style:
- Clear, opinionated, data-driven
- Use specific numbers and concrete examples (from the data provided)
- Short paragraphs, subheadings, occasional bold for emphasis
- Open with a hook that makes the reader care
- Include a "So what?" — why this matters for practitioners
- End with a forward-looking conclusion or call to action
- Tone: informed insider, not academic. Think Stratechery meets IETF.
- Use markdown formatting (## headings, **bold**, bullet lists, > blockquotes)
- Target length: 1500-3000 words depending on topic depth
- Include a suggested title and subtitle
Output the blog post in markdown. Start with a YAML front matter block:
```yaml
---
title: "The Title"
subtitle: "A compelling subtitle"
tags: [ietf, ai-agents, standards, ...]
estimated_reading_time: "X min read"
---
```
Then the full post body in markdown."""
CONTEXT_TEMPLATE = """\
## Project Data Context
You have access to the following data from our IETF AI Agent Standards analysis \
project (data as of March 2026):
### Corpus Overview
{overview}
### Gap Analysis ({gap_count} gaps identified)
{gaps}
### Draft Proposals ({proposal_count} proposals)
{proposals}
### Blog Series Context
The analysis is part of a planned 8-post blog series with the thesis:
"The IETF's AI agent standardization effort is the largest, fastest-growing, \
and most consequential standards race in a decade — but it is building the \
highways before the traffic lights."
Existing posts in the series:
{blog_series}
---
## User's Request
**Topic**: {topic}
**Style**: {style}
**Additional instructions**: {instructions}
Write the blog post now. Use the data above to support your arguments with \
specific numbers and examples. Do not invent statistics — only use what is \
provided in the context."""
STYLES = {
"deep-dive": "Long-form deep dive (2500-3000 words). Thorough analysis with multiple sections.",
"overview": "High-level overview (1500-2000 words). Accessible intro to a topic.",
"opinion": "Opinionated take (1500-2000 words). Strong thesis, backed by data.",
"listicle": "Structured list format (1500-2000 words). 'N things you should know about X'.",
"comparison": "Comparative analysis (2000-2500 words). Side-by-side evaluation of approaches.",
"series-post": "Part of the planned blog series (2000-2500 words). Fits the narrative arc.",
}
class BlogDraftGenerator:
"""Generate blog post drafts from project data."""
def __init__(self, config: Config, db: Database):
self.config = config
self.db = db
try:
self.client = anthropic.Anthropic()
except Exception:
console.print(
"[red bold]No Anthropic API key found.[/]\n"
"Set ANTHROPIC_API_KEY environment variable or add to .env"
)
raise SystemExit(1)
def _gather_overview(self) -> str:
"""Gather high-level corpus statistics."""
total_drafts = self.db.conn.execute("SELECT count(*) FROM drafts").fetchone()[0]
rated = self.db.conn.execute("SELECT count(*) FROM ratings WHERE false_positive = 0").fetchone()[0]
authors = self.db.conn.execute("SELECT count(*) FROM authors").fetchone()[0]
ideas = self.db.conn.execute("SELECT count(*) FROM ideas").fetchone()[0]
gaps = self.db.conn.execute("SELECT count(*) FROM gaps").fetchone()[0]
proposals = self.db.conn.execute("SELECT count(*) FROM proposals").fetchone()[0]
# Category breakdown
cat_rows = self.db.conn.execute(
"SELECT categories FROM ratings WHERE false_positive = 0"
).fetchall()
from collections import Counter
cat_counts = Counter()
for row in cat_rows:
try:
for cat in json.loads(row[0] or "[]"):
cat_counts[cat] += 1
except (json.JSONDecodeError, TypeError):
pass
top_cats = cat_counts.most_common(10)
cat_str = ", ".join(f"{c} ({n})" for c, n in top_cats)
# Avg scores
avg_row = self.db.conn.execute(
"SELECT avg(novelty), avg(maturity), avg(relevance), avg(momentum), avg(overlap) "
"FROM ratings WHERE false_positive = 0"
).fetchone()
# Source breakdown
src_rows = self.db.conn.execute(
"SELECT source, count(*) FROM drafts GROUP BY source ORDER BY count(*) DESC"
).fetchall()
src_str = ", ".join(f"{r[0]}: {r[1]}" for r in src_rows)
return (
f"- **{total_drafts} drafts** tracked ({rated} rated, rest pending)\n"
f"- **{authors} authors**, **{ideas} ideas** extracted, **{gaps} gaps** identified\n"
f"- **{proposals} draft proposals** in development\n"
f"- Sources: {src_str}\n"
f"- Top categories: {cat_str}\n"
f"- Avg scores (1-5): novelty {avg_row[0]:.1f}, maturity {avg_row[1]:.1f}, "
f"relevance {avg_row[2]:.1f}, momentum {avg_row[3]:.1f}, overlap {avg_row[4]:.1f}"
)
def _gather_gaps(self) -> str:
"""Gather gap data as context."""
gaps = self.db.all_gaps()
lines = []
for g in gaps:
lines.append(
f"- **#{g['id']} [{g['severity'].upper()}]** {g['topic']} ({g['category']}): "
f"{g['description'][:200]}"
)
return "\n".join(lines)
def _gather_proposals(self) -> str:
"""Gather proposal summaries as context."""
proposals = self.db.all_proposals()
if not proposals:
return "(No proposals yet)"
lines = []
for p in proposals:
gap_refs = ", ".join(f"#{gid}" for gid in p.get("gap_ids", []))
lines.append(
f"- **{p['title']}** [{p['status']}] (gaps: {gap_refs or 'none'}): "
f"{p.get('description', '')[:200]}"
)
return "\n".join(lines)
def _gather_blog_series(self) -> str:
"""Gather existing blog series info."""
blog_dir = Path(self.config.data_dir) / "reports" / "blog-series"
if not blog_dir.exists():
return "(No blog series found)"
lines = []
for f in sorted(blog_dir.glob("*.md")):
if f.name == "data" or f.is_dir():
continue
# Read first few lines for title
content = f.read_text(errors="replace")
title_line = ""
for line in content.split("\n"):
if line.startswith("# "):
title_line = line[2:].strip()
break
lines.append(f"- `{f.name}`: {title_line or f.stem}")
return "\n".join(lines)
def build_prompt(self, topic: str, style: str = "deep-dive",
instructions: str = "") -> str:
"""Build the full prompt with project data context."""
overview = self._gather_overview()
gaps_text = self._gather_gaps()
proposals_text = self._gather_proposals()
blog_series = self._gather_blog_series()
gap_count = len(self.db.all_gaps())
proposal_count = self.db.conn.execute("SELECT count(*) FROM proposals").fetchone()[0]
style_desc = STYLES.get(style, STYLES["deep-dive"])
return CONTEXT_TEMPLATE.format(
overview=overview,
gap_count=gap_count,
gaps=gaps_text,
proposal_count=proposal_count,
proposals=proposals_text,
blog_series=blog_series,
topic=topic,
style=f"{style}{style_desc}",
instructions=instructions or "(none)",
)
def generate(self, topic: str, style: str = "deep-dive",
instructions: str = "", cheap: bool = False) -> dict:
"""Generate a blog post draft.
Returns dict with keys: title, subtitle, content, tags, usage.
"""
console.print("[bold]Blog Draft Generator[/]")
console.print(f" Topic: {topic}")
console.print(f" Style: {style}")
prompt = self.build_prompt(topic, style, instructions)
model = self.config.claude_model_cheap if cheap else self.config.claude_model
console.print(f" Calling Claude ({model})...")
resp = self.client.messages.create(
model=model,
max_tokens=16000,
system=BLOG_SYSTEM_PROMPT,
messages=[{"role": "user", "content": prompt}],
)
content = resp.content[0].text.strip()
in_tok = resp.usage.input_tokens
out_tok = resp.usage.output_tokens
if "haiku" in model:
cost = (in_tok * 0.8 + out_tok * 4) / 1_000_000
else:
cost = (in_tok * 3 + out_tok * 15) / 1_000_000
console.print(
f" Tokens: {in_tok:,} in / {out_tok:,} out (~${cost:.3f})"
)
# Parse front matter if present
title = topic
subtitle = ""
tags = []
body = content
if content.startswith("---") or content.startswith("```yaml"):
# Try to extract YAML front matter
try:
if content.startswith("```yaml"):
fm_block = content.split("```")[1].replace("yaml\n", "", 1)
body = "```".join(content.split("```")[2:]).strip()
else:
parts = content.split("---", 2)
if len(parts) >= 3:
fm_block = parts[1]
body = parts[2].strip()
import re
title_match = re.search(r'title:\s*"(.+?)"', fm_block)
sub_match = re.search(r'subtitle:\s*"(.+?)"', fm_block)
tag_match = re.search(r'tags:\s*\[(.+?)\]', fm_block)
if title_match:
title = title_match.group(1)
if sub_match:
subtitle = sub_match.group(1)
if tag_match:
tags = [t.strip().strip('"').strip("'")
for t in tag_match.group(1).split(",")]
except Exception:
pass # Fall through with raw content
usage = {
"model": model,
"input_tokens": in_tok,
"output_tokens": out_tok,
"cost_usd": round(cost, 4),
}
return {
"title": title,
"subtitle": subtitle,
"tags": tags,
"content": body,
"full_markdown": content,
"usage": usage,
"generated_at": datetime.now(timezone.utc).isoformat(),
}

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); 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) -- Annotations (user notes + tags per draft)
CREATE TABLE IF NOT EXISTS annotations ( CREATE TABLE IF NOT EXISTS annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1169,6 +1189,62 @@ class Database:
).fetchall() ).fetchall()
return [dict(r) for r in rows] 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 --- # --- Refs ---
def insert_refs(self, draft_name: str, refs: list[tuple[str, str]]) -> None: def insert_refs(self, draft_name: str, refs: list[tuple[str, str]]) -> None:

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import functools import functools
import time import time
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from flask import Blueprint, render_template, request, jsonify, abort, g, Response, redirect, url_for from flask import Blueprint, render_template, request, jsonify, abort, g, Response, redirect, url_for
@@ -37,6 +38,8 @@ from webui.data import (
get_all_proposals, get_all_proposals,
get_proposal_detail, get_proposal_detail,
get_proposals_for_gap, get_proposals_for_gap,
get_all_blog_drafts,
get_blog_draft_detail,
) )
admin_bp = Blueprint("admin", __name__) admin_bp = Blueprint("admin", __name__)
@@ -563,6 +566,145 @@ def proposal_intake():
return render_template("proposal_intake.html") return render_template("proposal_intake.html")
# ── Blog Drafts (dev-only) ──────────────────────────────────────────────
@admin_bp.route("/blog")
@admin_required
def blog_drafts():
drafts = get_all_blog_drafts(db())
return render_template("blog_drafts.html", drafts=drafts)
@admin_bp.route("/blog/generate", methods=["GET", "POST"])
@admin_required
def blog_generate():
"""Generate a blog post from project data."""
if request.method == "POST":
topic = request.form.get("topic", "").strip()
style = request.form.get("style", "deep-dive")
instructions = request.form.get("instructions", "").strip()
cheap = request.form.get("cheap") == "on"
if not topic:
return jsonify({"error": "No topic provided"}), 400
try:
from ietf_analyzer.config import Config
from ietf_analyzer.blog_drafts import BlogDraftGenerator
cfg = Config.load()
gen = BlogDraftGenerator(cfg, db())
result = gen.generate(topic=topic, style=style,
instructions=instructions, cheap=cheap)
# Auto-save to DB
import re
slug = re.sub(r'[^a-z0-9]+', '-', result["title"].lower()).strip('-')[:80]
# Ensure unique slug
existing = db().conn.execute(
"SELECT count(*) FROM blog_drafts WHERE slug = ?", (slug,)
).fetchone()[0]
if existing:
slug = f"{slug}-{int(datetime.now(timezone.utc).timestamp()) % 10000}"
draft_id = db().upsert_blog_draft({
"title": result["title"],
"subtitle": result.get("subtitle", ""),
"slug": slug,
"status": "draft",
"style": style,
"tags": result.get("tags", []),
"topic": topic,
"instructions": instructions,
"content_md": result["full_markdown"],
"model_used": result["usage"]["model"],
"input_tokens": result["usage"]["input_tokens"],
"output_tokens": result["usage"]["output_tokens"],
"cost_usd": result["usage"]["cost_usd"],
})
return jsonify({
"success": True,
"id": draft_id,
"title": result["title"],
"subtitle": result.get("subtitle", ""),
"content": result["full_markdown"],
"tags": result.get("tags", []),
"usage": result["usage"],
})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
return render_template("blog_generate.html")
@admin_bp.route("/blog/<int:draft_id>")
@admin_required
def blog_detail(draft_id):
draft = get_blog_draft_detail(db(), draft_id)
if not draft:
abort(404)
return render_template("blog_detail.html", draft=draft)
@admin_bp.route("/blog/<int:draft_id>/edit", methods=["GET", "POST"])
@admin_required
def blog_edit(draft_id):
if request.method == "POST":
data = request.form
import re
slug = data.get("slug", "").strip()
if not slug:
slug = re.sub(r'[^a-z0-9]+', '-', data["title"].lower()).strip('-')[:80]
tags = [t.strip() for t in data.get("tags_str", "").split(",") if t.strip()]
db().upsert_blog_draft({
"id": draft_id,
"title": data["title"],
"subtitle": data.get("subtitle", ""),
"slug": slug,
"status": data.get("status", "draft"),
"style": data.get("style", "deep-dive"),
"tags": tags,
"topic": data.get("topic", ""),
"instructions": data.get("instructions", ""),
"content_md": data.get("content_md", ""),
"model_used": data.get("model_used", ""),
"input_tokens": int(data.get("input_tokens", 0) or 0),
"output_tokens": int(data.get("output_tokens", 0) or 0),
"cost_usd": float(data.get("cost_usd", 0) or 0),
})
return redirect(url_for("admin.blog_detail", draft_id=draft_id))
draft = get_blog_draft_detail(db(), draft_id)
if not draft:
abort(404)
return render_template("blog_edit.html", draft=draft)
@admin_bp.route("/blog/<int:draft_id>/delete", methods=["POST"])
@admin_required
def blog_delete(draft_id):
db().delete_blog_draft(draft_id)
return redirect(url_for("admin.blog_drafts"))
@admin_bp.route("/blog/<int:draft_id>/export")
@admin_required
def blog_export(draft_id):
"""Download a blog draft as markdown file."""
draft = get_blog_draft_detail(db(), draft_id)
if not draft:
abort(404)
filename = f"{draft['slug']}.md"
return Response(
draft["content_md"],
mimetype="text/markdown",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
# ── Obsidian Export ────────────────────────────────────────────────────── # ── Obsidian Export ──────────────────────────────────────────────────────
@admin_bp.route("/export/obsidian") @admin_bp.route("/export/obsidian")

View File

@@ -98,3 +98,9 @@ from webui.data.proposals import ( # noqa: F401
get_proposal_detail, get_proposal_detail,
get_proposals_for_gap, get_proposals_for_gap,
) )
# Blog Drafts
from webui.data.blog import ( # noqa: F401
get_all_blog_drafts,
get_blog_draft_detail,
)

View File

@@ -117,7 +117,7 @@ def get_author_detail(db: Database, person_id: int) -> dict | None:
"name": d.name, "name": d.name,
"title": d.title, "title": d.title,
"date": d.date, "date": d.date,
"status": d.status, "states": d.states,
"categories": r.categories if r else [], "categories": r.categories if r else [],
"score": round(r.composite_score, 2) if r else None, "score": round(r.composite_score, 2) if r else None,
"novelty": r.novelty if r else None, "novelty": r.novelty if r else None,

30
src/webui/data/blog.py Normal file
View File

@@ -0,0 +1,30 @@
"""Blog draft data access for the web dashboard."""
from __future__ import annotations
import json
from ietf_analyzer.db import Database
def get_all_blog_drafts(db: Database) -> list[dict]:
"""Return all blog drafts, newest first."""
drafts = db.all_blog_drafts()
for d in drafts:
try:
d["tags"] = json.loads(d.get("tags") or "[]")
except (json.JSONDecodeError, TypeError):
d["tags"] = []
return drafts
def get_blog_draft_detail(db: Database, draft_id: int) -> dict | None:
"""Return a single blog draft with parsed tags."""
d = db.get_blog_draft(draft_id)
if not d:
return None
try:
d["tags"] = json.loads(d.get("tags") or "[]")
except (json.JSONDecodeError, TypeError):
d["tags"] = []
return d

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from collections import Counter, defaultdict from collections import Counter, defaultdict
from ietf_analyzer.db import Database from ietf_analyzer.db import Database

View File

@@ -136,6 +136,10 @@
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Proposals Proposals
</a> </a>
<a href="/blog" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'blog' }}">
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/></svg>
Blog Drafts
</a>
{% endif %} {% endif %}
<a href="/timeline" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'timeline' }}"> <a href="/timeline" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'timeline' }}">
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>

View File

@@ -0,0 +1,170 @@
{% extends "base.html" %}
{% set active_page = "blog" %}
{% block title %}{{ draft.title }} — Blog Drafts{% endblock %}
{% block extra_head %}
<style>
.prose { color: #cbd5e1; line-height: 1.75; }
.prose h1, .prose h2, .prose h3 { color: #f1f5f9; font-weight: 700; margin-top: 1.5em; margin-bottom: 0.5em; }
.prose h1 { font-size: 1.5rem; }
.prose h2 { font-size: 1.25rem; }
.prose h3 { font-size: 1.1rem; }
.prose p { margin-bottom: 1em; }
.prose strong { color: #e2e8f0; }
.prose em { color: #94a3b8; }
.prose ul, .prose ol { margin-bottom: 1em; padding-left: 1.5em; }
.prose li { margin-bottom: 0.25em; }
.prose blockquote { border-left: 3px solid #475569; padding-left: 1em; color: #94a3b8; margin: 1em 0; }
.prose code { background: #1e293b; padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; }
.prose pre { background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 1em; overflow-x: auto; margin: 1em 0; }
.prose pre code { background: none; padding: 0; }
.prose a { color: #60a5fa; text-decoration: underline; }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<a href="/blog" class="text-blue-400 hover:text-blue-300 transition">Blog Drafts</a>
<span class="text-slate-600 mx-2">/</span>
<span class="text-slate-400">{{ draft.title[:60] }}{% if draft.title | length > 60 %}...{% endif %}</span>
</nav>
<!-- Header -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div>
<h1 class="text-2xl font-bold text-white">{{ draft.title }}</h1>
{% if draft.subtitle %}
<p class="text-base text-slate-400 mt-1">{{ draft.subtitle }}</p>
{% endif %}
</div>
<span class="px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap
{% if draft.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
{% elif draft.status == 'review' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
{% elif draft.status == 'published' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
{{ draft.status | upper }}
</span>
</div>
<!-- Metadata -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{% if draft.style %}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Style</h3>
<p class="text-sm text-slate-300">{{ draft.style }}</p>
</div>
{% endif %}
{% if draft.topic %}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Topic</h3>
<p class="text-sm text-slate-300">{{ draft.topic[:80] }}</p>
</div>
{% endif %}
{% if draft.model_used %}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Model</h3>
<p class="text-sm text-slate-300">{{ draft.model_used }}</p>
</div>
{% endif %}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Cost</h3>
<p class="text-sm text-slate-300">
{% if draft.cost_usd %}${{ "%.4f" | format(draft.cost_usd) }}{% else %}N/A{% endif %}
{% if draft.input_tokens %}
<span class="text-slate-500 text-xs">({{ "{:,}".format(draft.input_tokens) }} in / {{ "{:,}".format(draft.output_tokens) }} out)</span>
{% endif %}
</p>
</div>
</div>
{% if draft.tags %}
<div class="flex flex-wrap gap-1.5 mb-4">
{% for tag in draft.tags %}
<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-400 text-xs">#{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<div class="text-xs text-slate-500 mb-4">
Created: {{ draft.created_at[:10] if draft.created_at else 'N/A' }}
&middot; Updated: {{ draft.updated_at[:10] if draft.updated_at else 'N/A' }}
</div>
<!-- Actions -->
<div class="flex items-center gap-3 pt-4 border-t border-slate-800/50">
<a href="/blog/{{ draft.id }}/edit" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Edit
</a>
<a href="/blog/{{ draft.id }}/export" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Export .md
</a>
<button onclick="copyMarkdown()" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
<span id="copyBtn">Copy</span>
</button>
<div class="flex-1"></div>
<form method="POST" action="/blog/{{ draft.id }}/delete" onsubmit="return confirm('Delete this blog draft? This cannot be undone.');" class="inline">
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm font-medium rounded-lg transition ring-1 ring-red-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Delete
</button>
</form>
</div>
</div>
<!-- Content -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Content</h2>
<div class="flex items-center gap-2">
<button onclick="toggleView('rendered')" id="viewRendered" class="px-3 py-1.5 bg-slate-800 text-slate-300 text-xs font-medium rounded-lg transition">Rendered</button>
<button onclick="toggleView('source')" id="viewSource" class="px-3 py-1.5 bg-slate-950 text-slate-500 text-xs font-medium rounded-lg transition">Source</button>
</div>
</div>
<div id="renderedView" class="prose max-w-none"></div>
<div id="sourceView" class="hidden">
<pre class="text-sm text-slate-300 font-mono leading-relaxed whitespace-pre-wrap bg-slate-950 rounded-lg border border-slate-800 p-4">{{ draft.content_md }}</pre>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const rawMd = {{ draft.content_md | tojson }};
document.getElementById('renderedView').innerHTML = marked.parse(rawMd);
function toggleView(view) {
const rendered = document.getElementById('renderedView');
const source = document.getElementById('sourceView');
const btnRendered = document.getElementById('viewRendered');
const btnSource = document.getElementById('viewSource');
if (view === 'source') {
rendered.classList.add('hidden');
source.classList.remove('hidden');
btnSource.className = 'px-3 py-1.5 bg-slate-800 text-slate-300 text-xs font-medium rounded-lg transition';
btnRendered.className = 'px-3 py-1.5 bg-slate-950 text-slate-500 text-xs font-medium rounded-lg transition';
} else {
source.classList.add('hidden');
rendered.classList.remove('hidden');
btnRendered.className = 'px-3 py-1.5 bg-slate-800 text-slate-300 text-xs font-medium rounded-lg transition';
btnSource.className = 'px-3 py-1.5 bg-slate-950 text-slate-500 text-xs font-medium rounded-lg transition';
}
}
function copyMarkdown() {
navigator.clipboard.writeText(rawMd).then(() => {
const btn = document.getElementById('copyBtn');
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,138 @@
{% extends "base.html" %}
{% set active_page = "blog" %}
{% block title %}Blog Drafts — IETF Draft Analyzer{% endblock %}
{% block content %}
<div class="mb-6 flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-white">Blog Drafts</h1>
<p class="text-slate-400 text-sm mt-1">{{ drafts | length }} blog draft{{ 's' if drafts | length != 1 }}. Generate Medium-style posts from project data.</p>
</div>
<a href="/blog/generate" class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
Generate New
</a>
</div>
<!-- Status filter pills -->
<div class="flex flex-wrap gap-2 mb-6">
<button onclick="filterStatus('all')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-slate-800 text-slate-300 ring-1 ring-slate-700" data-status="all">
All ({{ drafts | length }})
</button>
{% set ns = namespace(draft=0, review=0, published=0, archived=0) %}
{% for d in drafts %}
{% if d.status == 'draft' %}{% set ns.draft = ns.draft + 1 %}
{% elif d.status == 'review' %}{% set ns.review = ns.review + 1 %}
{% elif d.status == 'published' %}{% set ns.published = ns.published + 1 %}
{% elif d.status == 'archived' %}{% set ns.archived = ns.archived + 1 %}
{% endif %}
{% endfor %}
<button onclick="filterStatus('draft')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30" data-status="draft">
Draft ({{ ns.draft }})
</button>
<button onclick="filterStatus('review')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30" data-status="review">
Review ({{ ns.review }})
</button>
<button onclick="filterStatus('published')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30" data-status="published">
Published ({{ ns.published }})
</button>
<button onclick="filterStatus('archived')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30" data-status="archived">
Archived ({{ ns.archived }})
</button>
</div>
<!-- Blog draft cards -->
<div class="space-y-4" id="blogList">
{% for d in drafts %}
<a href="/blog/{{ d.id }}" class="blog-card block bg-slate-900 rounded-xl border border-slate-800 hover:border-slate-600 p-5 transition group"
data-status="{{ d.status }}" data-title="{{ d.title | lower }}">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<h2 class="text-base font-semibold text-white group-hover:text-blue-400 transition">{{ d.title }}</h2>
{% if d.subtitle %}
<p class="text-sm text-slate-400 mt-0.5">{{ d.subtitle }}</p>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap
{% if d.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
{% elif d.status == 'review' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
{% elif d.status == 'published' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
{{ d.status | upper }}
</span>
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-400 transition" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-slate-500 mt-3">
{% if d.style %}
<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-400">{{ d.style }}</span>
{% endif %}
{% if d.tags %}
<div class="flex flex-wrap gap-1">
{% for tag in d.tags[:5] %}
<span class="px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-500">#{{ tag }}</span>
{% endfor %}
{% if d.tags | length > 5 %}
<span class="text-slate-600">+{{ d.tags | length - 5 }}</span>
{% endif %}
</div>
{% endif %}
{% if d.model_used %}
<span class="text-slate-600">|</span>
<span>{{ d.model_used }}</span>
{% endif %}
{% if d.cost_usd %}
<span class="text-slate-600">|</span>
<span>${{ "%.3f" | format(d.cost_usd) }}</span>
{% endif %}
{% if d.updated_at %}
<span class="text-slate-600">|</span>
<span>{{ d.updated_at[:10] }}</span>
{% endif %}
</div>
</a>
{% endfor %}
{% if not drafts %}
<div class="bg-slate-900 rounded-xl border border-slate-800 p-12 text-center">
<svg class="w-12 h-12 text-slate-700 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/></svg>
<p class="text-slate-400 mb-4">No blog drafts yet. Generate your first post from project data.</p>
<a href="/blog/generate" class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
Generate First Post
</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
let currentStatus = 'all';
function filterStatus(status) {
currentStatus = status;
document.querySelectorAll('.status-pill').forEach(pill => {
if (pill.dataset.status === status) {
pill.style.outline = '2px solid rgba(96, 165, 250, 0.5)';
pill.style.outlineOffset = '1px';
} else {
pill.style.outline = 'none';
}
});
document.querySelectorAll('.blog-card').forEach(card => {
const match = currentStatus === 'all' || card.dataset.status === currentStatus;
card.style.display = match ? 'block' : 'none';
});
}
filterStatus('all');
</script>
{% endblock %}

View File

@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% set active_page = "blog" %}
{% block title %}Edit: {{ draft.title }} — Blog Drafts{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<a href="/blog" class="text-blue-400 hover:text-blue-300 transition">Blog Drafts</a>
<span class="text-slate-600 mx-2">/</span>
<a href="/blog/{{ draft.id }}" class="text-blue-400 hover:text-blue-300 transition">{{ draft.title[:40] }}{% if draft.title | length > 40 %}...{% endif %}</a>
<span class="text-slate-600 mx-2">/</span>
<span class="text-slate-400">Edit</span>
</nav>
<form method="POST" action="/blog/{{ draft.id }}/edit">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main content -->
<div class="lg:col-span-2 space-y-6">
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h1 class="text-xl font-bold text-white mb-5">Edit Blog Draft</h1>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1.5">Title *</label>
<input type="text" name="title" required value="{{ draft.title }}"
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1.5">Subtitle</label>
<input type="text" name="subtitle" value="{{ draft.subtitle or '' }}"
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1.5">Content (Markdown)</label>
<textarea name="content_md" rows="30"
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-slate-300 font-mono leading-relaxed focus:outline-none focus:border-blue-500 transition resize-y">{{ draft.content_md }}</textarea>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-sm font-semibold text-white mb-4">Metadata</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">Status</label>
<select name="status" class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
<option value="draft" {{ 'selected' if draft.status == 'draft' }}>Draft</option>
<option value="review" {{ 'selected' if draft.status == 'review' }}>Review</option>
<option value="published" {{ 'selected' if draft.status == 'published' }}>Published</option>
<option value="archived" {{ 'selected' if draft.status == 'archived' }}>Archived</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">Style</label>
<select name="style" class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
<option value="deep-dive" {{ 'selected' if draft.style == 'deep-dive' }}>Deep Dive</option>
<option value="overview" {{ 'selected' if draft.style == 'overview' }}>Overview</option>
<option value="opinion" {{ 'selected' if draft.style == 'opinion' }}>Opinion</option>
<option value="listicle" {{ 'selected' if draft.style == 'listicle' }}>Listicle</option>
<option value="comparison" {{ 'selected' if draft.style == 'comparison' }}>Comparison</option>
<option value="series-post" {{ 'selected' if draft.style == 'series-post' }}>Series Post</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">Slug</label>
<input type="text" name="slug" value="{{ draft.slug }}"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white font-mono focus:outline-none focus:border-blue-500 transition">
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">Tags (comma-separated)</label>
<input type="text" name="tags_str" value="{{ draft.tags | join(', ') }}"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">Topic</label>
<input type="text" name="topic" value="{{ draft.topic or '' }}"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">Instructions</label>
<textarea name="instructions" rows="2"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-slate-400 focus:outline-none focus:border-blue-500 transition resize-y">{{ draft.instructions or '' }}</textarea>
</div>
</div>
</div>
<!-- Generation info (read-only) -->
{% if draft.model_used %}
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-sm font-semibold text-white mb-4">Generation Info</h2>
<div class="space-y-2 text-xs text-slate-400">
<div class="flex justify-between">
<span>Model</span>
<span class="text-slate-300">{{ draft.model_used }}</span>
</div>
<div class="flex justify-between">
<span>Input tokens</span>
<span class="text-slate-300">{{ "{:,}".format(draft.input_tokens or 0) }}</span>
</div>
<div class="flex justify-between">
<span>Output tokens</span>
<span class="text-slate-300">{{ "{:,}".format(draft.output_tokens or 0) }}</span>
</div>
<div class="flex justify-between">
<span>Cost</span>
<span class="text-slate-300">${{ "%.4f" | format(draft.cost_usd or 0) }}</span>
</div>
</div>
<input type="hidden" name="model_used" value="{{ draft.model_used }}">
<input type="hidden" name="input_tokens" value="{{ draft.input_tokens or 0 }}">
<input type="hidden" name="output_tokens" value="{{ draft.output_tokens or 0 }}">
<input type="hidden" name="cost_usd" value="{{ draft.cost_usd or 0 }}">
</div>
{% endif %}
<!-- Actions -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold rounded-lg transition mb-3">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Save Changes
</button>
<a href="/blog/{{ draft.id }}" class="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium rounded-lg transition">
Cancel
</a>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% set active_page = "blog" %}
{% block title %}Generate Blog Post — IETF Draft Analyzer{% endblock %}
{% block extra_head %}
<style>
.prose { color: #cbd5e1; line-height: 1.75; }
.prose h1, .prose h2, .prose h3 { color: #f1f5f9; font-weight: 700; margin-top: 1.5em; margin-bottom: 0.5em; }
.prose h1 { font-size: 1.5rem; }
.prose h2 { font-size: 1.25rem; }
.prose h3 { font-size: 1.1rem; }
.prose p { margin-bottom: 1em; }
.prose strong { color: #e2e8f0; }
.prose em { color: #94a3b8; }
.prose ul, .prose ol { margin-bottom: 1em; padding-left: 1.5em; }
.prose li { margin-bottom: 0.25em; }
.prose blockquote { border-left: 3px solid #475569; padding-left: 1em; color: #94a3b8; margin: 1em 0; }
.prose code { background: #1e293b; padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; }
.prose pre { background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 1em; overflow-x: auto; margin: 1em 0; }
.prose pre code { background: none; padding: 0; }
.prose a { color: #60a5fa; text-decoration: underline; }
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<a href="/blog" class="text-blue-400 hover:text-blue-300 transition">Blog Drafts</a>
<span class="text-slate-600 mx-2">/</span>
<span class="text-slate-400">Generate</span>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Input form -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h1 class="text-xl font-bold text-white mb-4">Generate Blog Post</h1>
<p class="text-sm text-slate-400 mb-6">Claude will use your project data (gaps, proposals, drafts, stats) to write a Medium-style blog post.</p>
<form id="generateForm" class="space-y-5">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1.5">Topic *</label>
<input type="text" name="topic" required placeholder="e.g., 'all analysis results', 'capability-based security for agents', 'gap #3 deep dive'"
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition">
<p class="text-xs text-slate-500 mt-1">What should the post be about? Reference gaps, proposals, or general topics.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1.5">Style</label>
<select name="style" class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
<option value="deep-dive">Deep Dive (2500-3000 words)</option>
<option value="overview">Overview (1500-2000 words)</option>
<option value="opinion">Opinion (1500-2000 words)</option>
<option value="listicle">Listicle (1500-2000 words)</option>
<option value="comparison">Comparison (2000-2500 words)</option>
<option value="series-post">Series Post (2000-2500 words)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1.5">Additional Instructions</label>
<textarea name="instructions" rows="3" placeholder="Optional: tone, audience, specific data to highlight, angles to take..."
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition resize-y"></textarea>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cheap" class="rounded border-slate-600 bg-slate-950 text-blue-500 focus:ring-blue-500 focus:ring-offset-0">
<span class="text-sm text-slate-400">Use Haiku (cheaper, faster, less detailed)</span>
</label>
</div>
<button type="submit" id="submitBtn"
class="w-full inline-flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-500 disabled:bg-slate-700 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span id="btnText">Generate Blog Post</span>
</button>
</form>
<!-- Error display -->
<div id="errorBox" class="hidden mt-4 p-4 bg-red-900/30 border border-red-500/30 rounded-lg">
<p class="text-sm text-red-400" id="errorText"></p>
</div>
<!-- Usage info -->
<div id="usageBox" class="hidden mt-4 p-4 bg-slate-800/50 rounded-lg border border-slate-700">
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Generation Info</h3>
<div class="grid grid-cols-2 gap-2 text-xs text-slate-400">
<span>Model:</span><span id="usageModel" class="text-slate-300"></span>
<span>Input tokens:</span><span id="usageIn" class="text-slate-300"></span>
<span>Output tokens:</span><span id="usageOut" class="text-slate-300"></span>
<span>Cost:</span><span id="usageCost" class="text-slate-300"></span>
</div>
<a id="viewLink" href="#" class="inline-flex items-center gap-1 mt-3 text-sm text-blue-400 hover:text-blue-300 transition">
View saved draft &rarr;
</a>
</div>
</div>
<!-- Preview pane -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Preview</h2>
<div id="previewActions" class="hidden flex items-center gap-2">
<button onclick="copyContent()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition">Copy MD</button>
</div>
</div>
<div id="previewEmpty" class="text-center py-16">
<svg class="w-16 h-16 text-slate-700 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/></svg>
<p class="text-slate-500 text-sm">Your generated blog post will appear here.</p>
</div>
<div id="previewLoading" class="hidden text-center py-16">
<svg class="w-10 h-10 text-purple-400 mx-auto mb-4 spinner" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" class="opacity-75"></path>
</svg>
<p class="text-slate-400 text-sm">Generating blog post with Claude...</p>
<p class="text-slate-500 text-xs mt-1">This may take 30-60 seconds.</p>
</div>
<div id="previewContent" class="hidden">
<div id="previewTitle" class="mb-4">
<h1 class="text-xl font-bold text-white" id="titleText"></h1>
<p class="text-sm text-slate-400 mt-1" id="subtitleText"></p>
<div class="flex flex-wrap gap-1.5 mt-2" id="tagsList"></div>
</div>
<div class="prose max-w-none" id="markdownPreview"></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
let rawMarkdown = '';
document.getElementById('generateForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const btn = document.getElementById('submitBtn');
const btnText = document.getElementById('btnText');
const errorBox = document.getElementById('errorBox');
const usageBox = document.getElementById('usageBox');
// Reset state
errorBox.classList.add('hidden');
usageBox.classList.add('hidden');
document.getElementById('previewEmpty').classList.add('hidden');
document.getElementById('previewContent').classList.add('hidden');
document.getElementById('previewLoading').classList.remove('hidden');
document.getElementById('previewActions').classList.add('hidden');
btn.disabled = true;
btnText.textContent = 'Generating...';
const formData = new FormData(form);
try {
const resp = await fetch('/blog/generate', {
method: 'POST',
body: formData,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || 'Generation failed');
}
// Show preview
rawMarkdown = data.content;
document.getElementById('titleText').textContent = data.title;
document.getElementById('subtitleText').textContent = data.subtitle || '';
const tagsList = document.getElementById('tagsList');
tagsList.innerHTML = '';
(data.tags || []).forEach(tag => {
const span = document.createElement('span');
span.className = 'px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-500 text-xs';
span.textContent = '#' + tag;
tagsList.appendChild(span);
});
document.getElementById('markdownPreview').innerHTML = marked.parse(data.content);
document.getElementById('previewLoading').classList.add('hidden');
document.getElementById('previewContent').classList.remove('hidden');
document.getElementById('previewActions').classList.remove('hidden');
// Usage info
if (data.usage) {
document.getElementById('usageModel').textContent = data.usage.model;
document.getElementById('usageIn').textContent = data.usage.input_tokens.toLocaleString();
document.getElementById('usageOut').textContent = data.usage.output_tokens.toLocaleString();
document.getElementById('usageCost').textContent = '$' + data.usage.cost_usd.toFixed(4);
usageBox.classList.remove('hidden');
}
if (data.id) {
document.getElementById('viewLink').href = '/blog/' + data.id;
document.getElementById('viewLink').classList.remove('hidden');
}
} catch (err) {
document.getElementById('previewLoading').classList.add('hidden');
document.getElementById('previewEmpty').classList.remove('hidden');
document.getElementById('errorText').textContent = err.message;
errorBox.classList.remove('hidden');
} finally {
btn.disabled = false;
btnText.textContent = 'Generate Blog Post';
}
});
function copyContent() {
navigator.clipboard.writeText(rawMarkdown).then(() => {
const btn = event.target;
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = orig, 1500);
});
}
</script>
{% endblock %}