From d1a20fa02e3d8d431f885a049b1169ab7a768a56 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Mon, 9 Mar 2026 04:39:16 +0100 Subject: [PATCH] feat: add blog draft generator and fix broken routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/: Draft.status → Draft.states (correct attribute name) - /false-positives: add missing `import re` in ratings.py Co-Authored-By: Claude Opus 4.6 --- scripts/test_all_routes.py | 225 +++++++++++++++++ src/ietf_analyzer/blog_drafts.py | 319 +++++++++++++++++++++++++ src/ietf_analyzer/db.py | 76 ++++++ src/webui/blueprints/admin.py | 142 +++++++++++ src/webui/data/__init__.py | 6 + src/webui/data/authors.py | 2 +- src/webui/data/blog.py | 30 +++ src/webui/data/ratings.py | 1 + src/webui/templates/base.html | 4 + src/webui/templates/blog_detail.html | 170 +++++++++++++ src/webui/templates/blog_drafts.html | 138 +++++++++++ src/webui/templates/blog_edit.html | 141 +++++++++++ src/webui/templates/blog_generate.html | 228 ++++++++++++++++++ 13 files changed, 1481 insertions(+), 1 deletion(-) create mode 100644 scripts/test_all_routes.py create mode 100644 src/ietf_analyzer/blog_drafts.py create mode 100644 src/webui/data/blog.py create mode 100644 src/webui/templates/blog_detail.html create mode 100644 src/webui/templates/blog_drafts.html create mode 100644 src/webui/templates/blog_edit.html create mode 100644 src/webui/templates/blog_generate.html diff --git a/scripts/test_all_routes.py b/scripts/test_all_routes.py new file mode 100644 index 0000000..b2990ea --- /dev/null +++ b/scripts/test_all_routes.py @@ -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() diff --git a/src/ietf_analyzer/blog_drafts.py b/src/ietf_analyzer/blog_drafts.py new file mode 100644 index 0000000..5aad5ee --- /dev/null +++ b/src/ietf_analyzer/blog_drafts.py @@ -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(), + } diff --git a/src/ietf_analyzer/db.py b/src/ietf_analyzer/db.py index 7a5d9e9..5fc7c53 100644 --- a/src/ietf_analyzer/db.py +++ b/src/ietf_analyzer/db.py @@ -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: diff --git a/src/webui/blueprints/admin.py b/src/webui/blueprints/admin.py index 534fb9d..25c2b5f 100644 --- a/src/webui/blueprints/admin.py +++ b/src/webui/blueprints/admin.py @@ -4,6 +4,7 @@ from __future__ import annotations import functools import time from collections import defaultdict +from datetime import datetime, timezone from pathlib import Path 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_proposal_detail, get_proposals_for_gap, + get_all_blog_drafts, + get_blog_draft_detail, ) admin_bp = Blueprint("admin", __name__) @@ -563,6 +566,145 @@ def proposal_intake(): 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/") +@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//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//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//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 ────────────────────────────────────────────────────── @admin_bp.route("/export/obsidian") diff --git a/src/webui/data/__init__.py b/src/webui/data/__init__.py index 2e7f5c1..6ac956e 100644 --- a/src/webui/data/__init__.py +++ b/src/webui/data/__init__.py @@ -98,3 +98,9 @@ from webui.data.proposals import ( # noqa: F401 get_proposal_detail, get_proposals_for_gap, ) + +# Blog Drafts +from webui.data.blog import ( # noqa: F401 + get_all_blog_drafts, + get_blog_draft_detail, +) diff --git a/src/webui/data/authors.py b/src/webui/data/authors.py index 6f9ced3..8ccb2f2 100644 --- a/src/webui/data/authors.py +++ b/src/webui/data/authors.py @@ -117,7 +117,7 @@ def get_author_detail(db: Database, person_id: int) -> dict | None: "name": d.name, "title": d.title, "date": d.date, - "status": d.status, + "states": d.states, "categories": r.categories if r else [], "score": round(r.composite_score, 2) if r else None, "novelty": r.novelty if r else None, diff --git a/src/webui/data/blog.py b/src/webui/data/blog.py new file mode 100644 index 0000000..6ebfadb --- /dev/null +++ b/src/webui/data/blog.py @@ -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 diff --git a/src/webui/data/ratings.py b/src/webui/data/ratings.py index 172954e..9cae7ba 100644 --- a/src/webui/data/ratings.py +++ b/src/webui/data/ratings.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import re from collections import Counter, defaultdict from ietf_analyzer.db import Database diff --git a/src/webui/templates/base.html b/src/webui/templates/base.html index d002fbc..d58658d 100644 --- a/src/webui/templates/base.html +++ b/src/webui/templates/base.html @@ -136,6 +136,10 @@ Proposals + + + Blog Drafts + {% endif %} diff --git a/src/webui/templates/blog_detail.html b/src/webui/templates/blog_detail.html new file mode 100644 index 0000000..3dfb89d --- /dev/null +++ b/src/webui/templates/blog_detail.html @@ -0,0 +1,170 @@ +{% extends "base.html" %} +{% set active_page = "blog" %} + +{% block title %}{{ draft.title }} — Blog Drafts{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} + + + + +
+
+
+

{{ draft.title }}

+ {% if draft.subtitle %} +

{{ draft.subtitle }}

+ {% endif %} +
+ + {{ draft.status | upper }} + +
+ + +
+ {% if draft.style %} +
+

Style

+

{{ draft.style }}

+
+ {% endif %} + {% if draft.topic %} +
+

Topic

+

{{ draft.topic[:80] }}

+
+ {% endif %} + {% if draft.model_used %} +
+

Model

+

{{ draft.model_used }}

+
+ {% endif %} +
+

Cost

+

+ {% if draft.cost_usd %}${{ "%.4f" | format(draft.cost_usd) }}{% else %}N/A{% endif %} + {% if draft.input_tokens %} + ({{ "{:,}".format(draft.input_tokens) }} in / {{ "{:,}".format(draft.output_tokens) }} out) + {% endif %} +

+
+
+ + {% if draft.tags %} +
+ {% for tag in draft.tags %} + #{{ tag }} + {% endfor %} +
+ {% endif %} + +
+ Created: {{ draft.created_at[:10] if draft.created_at else 'N/A' }} + · Updated: {{ draft.updated_at[:10] if draft.updated_at else 'N/A' }} +
+ + +
+ + + Edit + + + + Export .md + + +
+
+ +
+
+
+ + +
+
+

Content

+
+ + +
+
+ +
+ +
+{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/src/webui/templates/blog_drafts.html b/src/webui/templates/blog_drafts.html new file mode 100644 index 0000000..7d19ca4 --- /dev/null +++ b/src/webui/templates/blog_drafts.html @@ -0,0 +1,138 @@ +{% extends "base.html" %} +{% set active_page = "blog" %} + +{% block title %}Blog Drafts — IETF Draft Analyzer{% endblock %} + +{% block content %} +
+
+

Blog Drafts

+

{{ drafts | length }} blog draft{{ 's' if drafts | length != 1 }}. Generate Medium-style posts from project data.

+
+ + + Generate New + +
+ + +
+ + {% 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 %} + + + + +
+ + + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/src/webui/templates/blog_edit.html b/src/webui/templates/blog_edit.html new file mode 100644 index 0000000..56ad1ae --- /dev/null +++ b/src/webui/templates/blog_edit.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% set active_page = "blog" %} + +{% block title %}Edit: {{ draft.title }} — Blog Drafts{% endblock %} + +{% block content %} + + + +
+
+ +
+
+

Edit Blog Draft

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + +
+
+

Metadata

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + {% if draft.model_used %} +
+

Generation Info

+
+
+ Model + {{ draft.model_used }} +
+
+ Input tokens + {{ "{:,}".format(draft.input_tokens or 0) }} +
+
+ Output tokens + {{ "{:,}".format(draft.output_tokens or 0) }} +
+
+ Cost + ${{ "%.4f" | format(draft.cost_usd or 0) }} +
+
+ + + + +
+ {% endif %} + + +
+ + + Cancel + +
+
+
+
+{% endblock %} diff --git a/src/webui/templates/blog_generate.html b/src/webui/templates/blog_generate.html new file mode 100644 index 0000000..13d75f5 --- /dev/null +++ b/src/webui/templates/blog_generate.html @@ -0,0 +1,228 @@ +{% extends "base.html" %} +{% set active_page = "blog" %} + +{% block title %}Generate Blog Post — IETF Draft Analyzer{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} + + + +
+ +
+

Generate Blog Post

+

Claude will use your project data (gaps, proposals, drafts, stats) to write a Medium-style blog post.

+ +
+
+ + +

What should the post be about? Reference gaps, proposals, or general topics.

+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + + + + + +
+ + +
+
+

Preview

+ +
+ +
+ +

Your generated blog post will appear here.

+
+ + + + +
+
+{% endblock %} + +{% block extra_scripts %} + + +{% endblock %}