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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user