feat: proposal intake pipeline with AI-powered generation on /proposals/new

Add full proposal system: DB schema (proposals + proposal_gaps tables),
CLI `ietf intake` command, and web UI with Quick Generate on /proposals/new.
The new page merges AI intake (paste URL/text → Haiku generates multiple
proposals auto-linked to gaps) with manual form entry. Generated proposals
are clickable cards that fill the editor below for refinement.

Uses claude_model_cheap (Haiku) for cost-efficient web intake. Includes
CaML-inspired draft proposals from arXiv:2503.18813 analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 03:15:11 +01:00
parent ae5e5f8cbf
commit 5ec7410b89
20 changed files with 3316 additions and 2 deletions

View File

@@ -193,6 +193,30 @@ CREATE TABLE IF NOT EXISTS gap_history (
recorded_at TEXT
);
-- Draft proposals (user's own IETF draft ideas)
CREATE TABLE IF NOT EXISTS proposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
status TEXT DEFAULT 'idea',
description TEXT DEFAULT '',
content_md TEXT DEFAULT '',
source_paper TEXT DEFAULT '',
source_url TEXT DEFAULT '',
intended_wg TEXT DEFAULT '',
draft_name TEXT DEFAULT '',
created_at TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS proposal_gaps (
proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
gap_id INTEGER NOT NULL REFERENCES gaps(id),
PRIMARY KEY (proposal_id, gap_id)
);
CREATE INDEX IF NOT EXISTS idx_proposal_gaps_gap ON proposal_gaps(gap_id);
-- Annotations (user notes + tags per draft)
CREATE TABLE IF NOT EXISTS annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -903,6 +927,102 @@ class Database:
"category": r["category"], "evidence": r["evidence"],
"severity": r["severity"]} for r in rows]
# --- Proposals ---
def all_proposals(self) -> list[dict]:
rows = self.conn.execute(
"SELECT * FROM proposals ORDER BY updated_at DESC"
).fetchall()
result = []
for r in rows:
p = dict(r)
gap_rows = self.conn.execute(
"SELECT gap_id FROM proposal_gaps WHERE proposal_id = ?", (r["id"],)
).fetchall()
p["gap_ids"] = [gr["gap_id"] for gr in gap_rows]
result.append(p)
return result
def get_proposal(self, proposal_id: int) -> dict | None:
row = self.conn.execute(
"SELECT * FROM proposals WHERE id = ?", (proposal_id,)
).fetchone()
if not row:
return None
p = dict(row)
gap_rows = self.conn.execute(
"SELECT gap_id FROM proposal_gaps WHERE proposal_id = ?", (proposal_id,)
).fetchall()
p["gap_ids"] = [gr["gap_id"] for gr in gap_rows]
return p
def get_proposal_by_slug(self, slug: str) -> dict | None:
row = self.conn.execute(
"SELECT * FROM proposals WHERE slug = ?", (slug,)
).fetchone()
if not row:
return None
p = dict(row)
gap_rows = self.conn.execute(
"SELECT gap_id FROM proposal_gaps WHERE proposal_id = ?", (p["id"],)
).fetchall()
p["gap_ids"] = [gr["gap_id"] for gr in gap_rows]
return p
def upsert_proposal(self, proposal: dict) -> int:
"""Insert or update a proposal. Returns the proposal ID."""
now = datetime.now(timezone.utc).isoformat()
if proposal.get("id"):
self.conn.execute(
"""UPDATE proposals SET title=?, slug=?, status=?, description=?,
content_md=?, source_paper=?, source_url=?, intended_wg=?,
draft_name=?, updated_at=?
WHERE id=?""",
(proposal["title"], proposal["slug"], proposal.get("status", "idea"),
proposal.get("description", ""), proposal.get("content_md", ""),
proposal.get("source_paper", ""), proposal.get("source_url", ""),
proposal.get("intended_wg", ""), proposal.get("draft_name", ""),
now, proposal["id"]),
)
pid = proposal["id"]
else:
cur = self.conn.execute(
"""INSERT INTO proposals (title, slug, status, description, content_md,
source_paper, source_url, intended_wg, draft_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(proposal["title"], proposal["slug"], proposal.get("status", "idea"),
proposal.get("description", ""), proposal.get("content_md", ""),
proposal.get("source_paper", ""), proposal.get("source_url", ""),
proposal.get("intended_wg", ""), proposal.get("draft_name", ""),
now, now),
)
pid = cur.lastrowid
# Update gap links
self.conn.execute("DELETE FROM proposal_gaps WHERE proposal_id = ?", (pid,))
for gid in proposal.get("gap_ids", []):
self.conn.execute(
"INSERT OR IGNORE INTO proposal_gaps (proposal_id, gap_id) VALUES (?, ?)",
(pid, gid),
)
self.conn.commit()
return pid
def delete_proposal(self, proposal_id: int) -> bool:
self.conn.execute("DELETE FROM proposal_gaps WHERE proposal_id = ?", (proposal_id,))
cur = self.conn.execute("DELETE FROM proposals WHERE id = ?", (proposal_id,))
self.conn.commit()
return cur.rowcount > 0
def get_proposals_for_gap(self, gap_id: int) -> list[dict]:
rows = self.conn.execute(
"""SELECT p.* FROM proposals p
JOIN proposal_gaps pg ON p.id = pg.proposal_id
WHERE pg.gap_id = ?
ORDER BY p.updated_at DESC""",
(gap_id,),
).fetchall()
return [dict(r) for r in rows]
# --- Refs ---
def insert_refs(self, draft_name: str, refs: list[tuple[str, str]]) -> None: