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:
@@ -2383,6 +2383,64 @@ def draft_gen(gap_topic: str, output: str | None):
|
||||
db.close()
|
||||
|
||||
|
||||
# ── proposal intake ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@main.command("intake")
|
||||
@click.argument("input_text", required=False)
|
||||
@click.option("--file", "-f", type=click.Path(exists=True), help="Read input from a file")
|
||||
@click.option("--dry-run", is_flag=True, help="Parse and show proposals without storing")
|
||||
def intake(input_text: str | None, file: str | None, dry_run: bool):
|
||||
"""Generate draft proposals from text/URLs.
|
||||
|
||||
Paste article text, URLs, or notes. Claude analyzes against all gaps
|
||||
and generates structured IETF draft proposals automatically.
|
||||
|
||||
Examples:
|
||||
|
||||
ietf intake "https://arxiv.org/abs/2503.18813"
|
||||
|
||||
ietf intake -f notes.txt
|
||||
|
||||
echo "interesting paper about agent security" | ietf intake -
|
||||
"""
|
||||
from .proposal_intake import ProposalIntake
|
||||
|
||||
if input_text == "-":
|
||||
import sys
|
||||
input_text = sys.stdin.read()
|
||||
elif file:
|
||||
input_text = Path(file).read_text()
|
||||
elif not input_text:
|
||||
# Interactive: read from stdin until EOF
|
||||
console.print("[dim]Paste text/URLs, then Ctrl+D to submit:[/]")
|
||||
import sys
|
||||
input_text = sys.stdin.read()
|
||||
|
||||
if not input_text or not input_text.strip():
|
||||
console.print("[red]No input provided.[/]")
|
||||
raise SystemExit(1)
|
||||
|
||||
cfg = _get_config()
|
||||
db = Database(cfg)
|
||||
try:
|
||||
pipeline = ProposalIntake(cfg, db)
|
||||
proposals, usage = pipeline.process(input_text, dry_run=dry_run)
|
||||
|
||||
if proposals:
|
||||
console.print(f"\n[bold green]{len(proposals)} proposal(s) generated[/]")
|
||||
for p in proposals:
|
||||
pid = p.get("id", "—")
|
||||
gaps = ", ".join(f"#{g}" for g in p.get("gap_ids", []))
|
||||
console.print(f" [blue]#{pid}[/] {p['title']} [dim]gaps: {gaps}[/]")
|
||||
if not dry_run:
|
||||
console.print(f"\nView in web UI: [bold]http://localhost:5000/proposals[/]")
|
||||
else:
|
||||
console.print("[yellow]No proposals generated from this input.[/]")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
296
src/ietf_analyzer/proposal_intake.py
Normal file
296
src/ietf_analyzer/proposal_intake.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""Proposal Intake Pipeline.
|
||||
|
||||
Accepts raw text (articles, URLs, notes) and automatically generates
|
||||
IETF draft proposals cross-referenced with existing gaps.
|
||||
|
||||
Usage:
|
||||
from ietf_analyzer.proposal_intake import ProposalIntake
|
||||
intake = ProposalIntake(config, db)
|
||||
proposals = intake.process("paste article text or URLs here")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
# Load .env from project root (same pattern as analyzer.py)
|
||||
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
|
||||
load_dotenv()
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
from rich.console import Console
|
||||
|
||||
from .config import Config
|
||||
from .db import Database
|
||||
|
||||
console = Console()
|
||||
|
||||
INTAKE_SYSTEM_PROMPT = """\
|
||||
You are an IETF standards expert who identifies potential Internet-Draft proposals \
|
||||
from research papers, articles, and technical discussions.
|
||||
|
||||
You will receive:
|
||||
1. A list of existing GAPS in IETF AI/agent standards coverage
|
||||
2. Input text (articles, papers, notes, or summaries)
|
||||
|
||||
Your job: identify concrete IETF Internet-Draft proposals that could address one or \
|
||||
more of the gaps, inspired by the input material.
|
||||
|
||||
For EACH proposal you identify, output a JSON object with these fields:
|
||||
- title: descriptive title for the proposed draft
|
||||
- slug: URL-friendly identifier (lowercase, hyphens, e.g. "capability-security-policies")
|
||||
- status: always "idea" for new proposals
|
||||
- description: 2-3 sentence summary of what this draft would standardize
|
||||
- content_md: full proposal outline in markdown (kramdown-compatible), including:
|
||||
- Problem Statement (what gap does this address, why is it needed)
|
||||
- Scope (what the draft defines and what's out of scope)
|
||||
- Key Concepts (core technical ideas from the source material)
|
||||
- Proposed Approach (wire formats, protocols, data models — be specific)
|
||||
- Integration Points (how this fits with WIMSE, ECT, MCP, A2A, OAuth, etc.)
|
||||
- Security Considerations
|
||||
- Open Questions
|
||||
- References (to the source material and relevant existing standards)
|
||||
- source_paper: title of the source paper/article
|
||||
- source_url: URL if available, empty string otherwise
|
||||
- intended_wg: suggested IETF working group (e.g. "SECDISPATCH", "WIMSE", "new WG")
|
||||
- draft_name: suggested draft filename (e.g. "draft-nennemann-ai-agent-capability-policies-00")
|
||||
- gap_ids: array of gap IDs (integers) that this proposal addresses
|
||||
|
||||
Guidelines:
|
||||
- Be SPECIFIC about wire formats, protocol messages, data structures
|
||||
- Include JSON examples in the content_md where applicable
|
||||
- Cross-reference with existing IETF work (WIMSE, ECT, MCP, A2A, GNAP, OAuth)
|
||||
- One paper/article might produce MULTIPLE proposals — don't merge distinct ideas
|
||||
- Don't create proposals that duplicate existing IETF drafts
|
||||
- The content_md should be substantial (500+ words) — this is a working document
|
||||
- Use kramdown-compatible markdown (YAML front matter is optional, not required in content)
|
||||
|
||||
Output ONLY a JSON array of proposal objects. No other text."""
|
||||
|
||||
GAPS_TEMPLATE = """\
|
||||
## Existing Gaps in IETF AI/Agent Standards
|
||||
|
||||
{gap_list}
|
||||
|
||||
## Input Material
|
||||
|
||||
{input_text}
|
||||
|
||||
---
|
||||
|
||||
Analyze the input material against the gaps above. Generate IETF draft proposals \
|
||||
as a JSON array. Each proposal should be a concrete, actionable draft idea that \
|
||||
addresses one or more gaps."""
|
||||
|
||||
|
||||
def _prompt_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
class ProposalIntake:
|
||||
"""Process raw text/URLs into structured IETF draft proposals."""
|
||||
|
||||
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 fetch_url(self, url: str) -> str:
|
||||
"""Fetch a URL and return its text content (best-effort)."""
|
||||
try:
|
||||
resp = httpx.get(url, follow_redirects=True, timeout=30,
|
||||
headers={"User-Agent": "IETF-Draft-Analyzer/1.0"})
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "html" in content_type:
|
||||
# Simple HTML → text extraction
|
||||
text = resp.text
|
||||
# Strip tags
|
||||
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'<[^>]+>', ' ', text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text[:50000] # Cap at 50k chars
|
||||
elif "pdf" in content_type:
|
||||
return f"[PDF at {url} — paste the text content directly for better results]"
|
||||
else:
|
||||
return resp.text[:50000]
|
||||
except Exception as e:
|
||||
return f"[Failed to fetch {url}: {e}]"
|
||||
|
||||
def extract_urls(self, text: str) -> list[str]:
|
||||
"""Extract URLs from text."""
|
||||
return re.findall(r'https?://[^\s<>"\')\]]+', text)
|
||||
|
||||
def prepare_input(self, raw_input: str) -> str:
|
||||
"""Fetch any URLs in the input and combine with the raw text."""
|
||||
urls = self.extract_urls(raw_input)
|
||||
parts = []
|
||||
|
||||
if urls:
|
||||
# Fetch each URL
|
||||
for url in urls:
|
||||
console.print(f" Fetching [blue]{url}[/]...")
|
||||
content = self.fetch_url(url)
|
||||
parts.append(f"### Source: {url}\n\n{content}\n")
|
||||
|
||||
# Also include any non-URL text
|
||||
non_url_text = raw_input
|
||||
for url in urls:
|
||||
non_url_text = non_url_text.replace(url, "").strip()
|
||||
if non_url_text:
|
||||
parts.append(f"### Additional notes\n\n{non_url_text}\n")
|
||||
else:
|
||||
parts.append(raw_input)
|
||||
|
||||
return "\n---\n".join(parts)
|
||||
|
||||
def build_prompt(self, input_text: str) -> str:
|
||||
"""Build the full prompt with gaps context."""
|
||||
gaps = self.db.all_gaps()
|
||||
gap_lines = []
|
||||
for g in gaps:
|
||||
gap_lines.append(
|
||||
f"- **Gap #{g['id']}** [{g['severity'].upper()}] ({g['category']}): "
|
||||
f"{g['topic']} — {g['description']}"
|
||||
)
|
||||
gap_list = "\n".join(gap_lines)
|
||||
return GAPS_TEMPLATE.format(gap_list=gap_list, input_text=input_text)
|
||||
|
||||
def call_claude(self, prompt: str, cheap: bool = False) -> tuple[str, dict]:
|
||||
"""Call Claude with the intake prompt.
|
||||
|
||||
Returns (response_text, usage_info) where usage_info has
|
||||
keys: model, input_tokens, output_tokens, cost_usd.
|
||||
"""
|
||||
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=INTAKE_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
text = resp.content[0].text.strip()
|
||||
in_tok = resp.usage.input_tokens
|
||||
out_tok = resp.usage.output_tokens
|
||||
# Haiku: ~$0.80/M in, $4/M out; Sonnet: ~$3/M in, $15/M out
|
||||
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})"
|
||||
)
|
||||
usage = {
|
||||
"model": model,
|
||||
"input_tokens": in_tok,
|
||||
"output_tokens": out_tok,
|
||||
"cost_usd": round(cost, 4),
|
||||
}
|
||||
return text, usage
|
||||
|
||||
def parse_proposals(self, response: str) -> list[dict]:
|
||||
"""Parse Claude's JSON response into proposal dicts."""
|
||||
# Strip markdown fences if present
|
||||
text = response.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1]
|
||||
if text.rstrip().endswith("```"):
|
||||
text = text.rstrip()[:-3]
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
proposals = json.loads(text)
|
||||
if isinstance(proposals, dict):
|
||||
proposals = [proposals]
|
||||
return proposals
|
||||
except json.JSONDecodeError as e:
|
||||
console.print(f"[red]Failed to parse JSON: {e}[/]")
|
||||
console.print(f"[dim]Response was: {text[:500]}...[/]")
|
||||
return []
|
||||
|
||||
def store_proposals(self, proposals: list[dict]) -> list[int]:
|
||||
"""Store parsed proposals in the database. Returns list of proposal IDs."""
|
||||
ids = []
|
||||
for p in proposals:
|
||||
# Ensure gap_ids are ints
|
||||
gap_ids = [int(g) for g in p.get("gap_ids", []) if str(g).isdigit()]
|
||||
proposal = {
|
||||
"title": p.get("title", "Untitled"),
|
||||
"slug": p.get("slug", f"proposal-{_prompt_hash(p.get('title', ''))}"),
|
||||
"status": p.get("status", "idea"),
|
||||
"description": p.get("description", ""),
|
||||
"content_md": p.get("content_md", ""),
|
||||
"source_paper": p.get("source_paper", ""),
|
||||
"source_url": p.get("source_url", ""),
|
||||
"intended_wg": p.get("intended_wg", ""),
|
||||
"draft_name": p.get("draft_name", ""),
|
||||
"gap_ids": gap_ids,
|
||||
}
|
||||
try:
|
||||
pid = self.db.upsert_proposal(proposal)
|
||||
ids.append(pid)
|
||||
console.print(
|
||||
f" [green]✓[/] Stored: {proposal['title']} "
|
||||
f"(#{pid}, {len(gap_ids)} gaps linked)"
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f" [red]✗[/] Failed to store '{proposal['title']}': {e}")
|
||||
return ids
|
||||
|
||||
def process(
|
||||
self, raw_input: str, dry_run: bool = False, cheap: bool = False
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Full pipeline: input → fetch URLs → build prompt → Claude → store.
|
||||
|
||||
Args:
|
||||
raw_input: text with URLs, article content, or notes
|
||||
dry_run: if True, return proposals without storing
|
||||
cheap: if True, use Haiku instead of Sonnet
|
||||
|
||||
Returns:
|
||||
(list of proposal dicts with 'id' set if stored, usage_info dict)
|
||||
"""
|
||||
console.print("[bold]Proposal Intake Pipeline[/]")
|
||||
console.print(f" Input: {len(raw_input)} chars, {len(self.extract_urls(raw_input))} URLs")
|
||||
|
||||
# Step 1: Prepare input (fetch URLs)
|
||||
input_text = self.prepare_input(raw_input)
|
||||
console.print(f" Prepared: {len(input_text)} chars")
|
||||
|
||||
# Step 2: Build prompt with gaps
|
||||
prompt = self.build_prompt(input_text)
|
||||
|
||||
# Step 3: Call Claude
|
||||
response, usage = self.call_claude(prompt, cheap=cheap)
|
||||
|
||||
# Step 4: Parse
|
||||
proposals = self.parse_proposals(response)
|
||||
console.print(f" Parsed: {len(proposals)} proposal(s)")
|
||||
|
||||
if not proposals:
|
||||
return [], usage
|
||||
|
||||
# Step 5: Store (unless dry run)
|
||||
if not dry_run:
|
||||
ids = self.store_proposals(proposals)
|
||||
for p, pid in zip(proposals, ids):
|
||||
p["id"] = pid
|
||||
else:
|
||||
console.print(" [yellow]Dry run — not stored[/]")
|
||||
|
||||
return proposals, usage
|
||||
147
src/webui/app.py
147
src/webui/app.py
@@ -19,7 +19,7 @@ import time
|
||||
import functools
|
||||
from collections import defaultdict
|
||||
|
||||
from flask import Flask, render_template, request, jsonify, abort, g, Response
|
||||
from flask import Flask, render_template, request, jsonify, abort, g, Response, redirect, url_for
|
||||
|
||||
from webui.auth import admin_required, init_auth
|
||||
from webui.analytics import init_analytics, get_analytics_data
|
||||
@@ -63,6 +63,9 @@ from webui.data import (
|
||||
get_trends_data,
|
||||
get_complexity_data,
|
||||
get_idea_analysis,
|
||||
get_all_proposals,
|
||||
get_proposal_detail,
|
||||
get_proposals_for_gap,
|
||||
)
|
||||
|
||||
app = Flask(
|
||||
@@ -243,7 +246,8 @@ def gap_detail(gap_id: int):
|
||||
if not gap:
|
||||
abort(404)
|
||||
generated = get_generated_drafts()
|
||||
return render_template("gap_detail.html", gap=gap, generated_drafts=generated)
|
||||
gap_proposals = get_proposals_for_gap(db(), gap_id)
|
||||
return render_template("gap_detail.html", gap=gap, generated_drafts=generated, proposals=gap_proposals)
|
||||
|
||||
|
||||
@app.route("/gaps/<int:gap_id>/generate", methods=["POST"])
|
||||
@@ -793,6 +797,145 @@ def api_complexity():
|
||||
return jsonify(get_complexity_data(db()))
|
||||
|
||||
|
||||
# ── Proposals (dev-only) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/proposals")
|
||||
@admin_required
|
||||
def proposals():
|
||||
proposal_list = get_all_proposals(db())
|
||||
gap_list = get_all_gaps(db())
|
||||
return render_template("proposals.html", proposals=proposal_list, gaps=gap_list)
|
||||
|
||||
|
||||
@app.route("/proposals/new", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def proposal_new():
|
||||
if request.method == "POST":
|
||||
data = request.form
|
||||
slug = data.get("slug", "").strip()
|
||||
if not slug:
|
||||
import re
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', data["title"].lower()).strip('-')
|
||||
gap_ids = [int(g) for g in request.form.getlist("gap_ids") if g]
|
||||
proposal = {
|
||||
"title": data["title"],
|
||||
"slug": slug,
|
||||
"status": data.get("status", "idea"),
|
||||
"description": data.get("description", ""),
|
||||
"content_md": data.get("content_md", ""),
|
||||
"source_paper": data.get("source_paper", ""),
|
||||
"source_url": data.get("source_url", ""),
|
||||
"intended_wg": data.get("intended_wg", ""),
|
||||
"draft_name": data.get("draft_name", ""),
|
||||
"gap_ids": gap_ids,
|
||||
}
|
||||
pid = db().upsert_proposal(proposal)
|
||||
return redirect(url_for("proposal_detail", proposal_id=pid))
|
||||
gap_list = get_all_gaps(db())
|
||||
return render_template("proposal_edit.html", proposal=None, gaps=gap_list)
|
||||
|
||||
|
||||
@app.route("/proposals/<int:proposal_id>")
|
||||
@admin_required
|
||||
def proposal_detail(proposal_id):
|
||||
proposal = get_proposal_detail(db(), proposal_id)
|
||||
if not proposal:
|
||||
abort(404)
|
||||
return render_template("proposal_detail.html", proposal=proposal)
|
||||
|
||||
|
||||
@app.route("/proposals/<int:proposal_id>/edit", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def proposal_edit(proposal_id):
|
||||
if request.method == "POST":
|
||||
data = request.form
|
||||
slug = data.get("slug", "").strip()
|
||||
if not slug:
|
||||
import re
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', data["title"].lower()).strip('-')
|
||||
gap_ids = [int(g) for g in request.form.getlist("gap_ids") if g]
|
||||
proposal = {
|
||||
"id": proposal_id,
|
||||
"title": data["title"],
|
||||
"slug": slug,
|
||||
"status": data.get("status", "idea"),
|
||||
"description": data.get("description", ""),
|
||||
"content_md": data.get("content_md", ""),
|
||||
"source_paper": data.get("source_paper", ""),
|
||||
"source_url": data.get("source_url", ""),
|
||||
"intended_wg": data.get("intended_wg", ""),
|
||||
"draft_name": data.get("draft_name", ""),
|
||||
"gap_ids": gap_ids,
|
||||
}
|
||||
db().upsert_proposal(proposal)
|
||||
return redirect(url_for("proposal_detail", proposal_id=proposal_id))
|
||||
proposal = get_proposal_detail(db(), proposal_id)
|
||||
if not proposal:
|
||||
abort(404)
|
||||
gap_list = get_all_gaps(db())
|
||||
return render_template("proposal_edit.html", proposal=proposal, gaps=gap_list)
|
||||
|
||||
|
||||
@app.route("/proposals/<int:proposal_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def proposal_delete(proposal_id):
|
||||
db().delete_proposal(proposal_id)
|
||||
return redirect(url_for("proposals"))
|
||||
|
||||
|
||||
@app.route("/api/proposals")
|
||||
@admin_required
|
||||
def api_proposals():
|
||||
data = get_all_proposals(db())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/proposals/<int:proposal_id>")
|
||||
@admin_required
|
||||
def api_proposal_detail(proposal_id):
|
||||
p = get_proposal_detail(db(), proposal_id)
|
||||
if not p:
|
||||
return jsonify({"error": "Proposal not found"}), 404
|
||||
return jsonify(p)
|
||||
|
||||
|
||||
@app.route("/proposals/intake", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def proposal_intake():
|
||||
"""Paste text/URLs → Claude generates proposals automatically."""
|
||||
if request.method == "POST":
|
||||
raw_input = request.form.get("input_text", "").strip()
|
||||
if not raw_input:
|
||||
return jsonify({"error": "No input provided"}), 400
|
||||
|
||||
try:
|
||||
from ietf_analyzer.config import Config
|
||||
from ietf_analyzer.proposal_intake import ProposalIntake
|
||||
|
||||
cfg = Config.load()
|
||||
intake = ProposalIntake(cfg, db())
|
||||
proposals, usage = intake.process(raw_input, cheap=True)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"count": len(proposals),
|
||||
"proposals": [
|
||||
{"id": p.get("id"), "title": p.get("title"), "slug": p.get("slug"),
|
||||
"gap_ids": p.get("gap_ids", []), "description": p.get("description", ""),
|
||||
"content_md": p.get("content_md", ""),
|
||||
"intended_wg": p.get("intended_wg", ""), "draft_name": p.get("draft_name", ""),
|
||||
"source_paper": p.get("source_paper", ""), "source_url": p.get("source_url", "")}
|
||||
for p in proposals
|
||||
],
|
||||
"usage": usage,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
return render_template("proposal_intake.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
|
||||
@@ -4332,3 +4332,29 @@ def get_ask_synthesize(db: Database, question: str, top_k: int = 5, cheap: bool
|
||||
config = Config.load()
|
||||
searcher = HybridSearch(config, db)
|
||||
return searcher.ask(question, top_k=top_k, cheap=cheap)
|
||||
|
||||
|
||||
# --- Proposals ---
|
||||
|
||||
def get_all_proposals(db: Database) -> list[dict]:
|
||||
"""Return all proposals with linked gap info."""
|
||||
proposals = db.all_proposals()
|
||||
gaps = {g["id"]: g for g in db.all_gaps()}
|
||||
for p in proposals:
|
||||
p["gaps"] = [gaps[gid] for gid in p.get("gap_ids", []) if gid in gaps]
|
||||
return proposals
|
||||
|
||||
|
||||
def get_proposal_detail(db: Database, proposal_id: int) -> dict | None:
|
||||
"""Return a single proposal with full gap details."""
|
||||
p = db.get_proposal(proposal_id)
|
||||
if not p:
|
||||
return None
|
||||
gaps = {g["id"]: g for g in db.all_gaps()}
|
||||
p["gaps"] = [gaps[gid] for gid in p.get("gap_ids", []) if gid in gaps]
|
||||
return p
|
||||
|
||||
|
||||
def get_proposals_for_gap(db: Database, gap_id: int) -> list[dict]:
|
||||
"""Return proposals linked to a specific gap."""
|
||||
return db.get_proposals_for_gap(gap_id)
|
||||
|
||||
@@ -132,6 +132,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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
Gap Explorer
|
||||
</a>
|
||||
<a href="/proposals" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'proposals' }}">
|
||||
<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
|
||||
</a>
|
||||
{% 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' }}">
|
||||
<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>
|
||||
|
||||
@@ -86,6 +86,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked Proposals -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Linked Proposals</h2>
|
||||
<a href="/proposals/new?gap_id={{ gap.id }}" class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
New Proposal
|
||||
</a>
|
||||
</div>
|
||||
{% if proposals %}
|
||||
<div class="space-y-3">
|
||||
{% for p in proposals %}
|
||||
<a href="/proposals/{{ p.id }}" class="block bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 p-4 transition group">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white group-hover:text-blue-400 transition">{{ p.title }}</h3>
|
||||
{% if p.description %}
|
||||
<p class="text-xs text-slate-400 mt-1">{{ p.description[:120] }}{% if p.description | length > 120 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold whitespace-nowrap shrink-0
|
||||
{% if p.status == 'idea' %}bg-purple-500/20 text-purple-400
|
||||
{% elif p.status == 'outline' %}bg-blue-500/20 text-blue-400
|
||||
{% elif p.status == 'draft' %}bg-yellow-500/20 text-yellow-400
|
||||
{% elif p.status == 'submitted' %}bg-green-500/20 text-green-400
|
||||
{% elif p.status == 'merged' %}bg-emerald-500/20 text-emerald-400
|
||||
{% else %}bg-slate-600/20 text-slate-500{% endif %}">
|
||||
{{ p.status | upper }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">No proposals yet -- <a href="/proposals/new?gap_id={{ gap.id }}" class="text-blue-400 hover:text-blue-300 transition">create one?</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Draft Generation Section -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
|
||||
119
src/webui/templates/proposal_detail.html
Normal file
119
src/webui/templates/proposal_detail.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "proposals" %}
|
||||
|
||||
{% block title %}{{ proposal.title }} — Proposals{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">{{ proposal.title }}</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-4">
|
||||
<h1 class="text-2xl font-bold text-white">{{ proposal.title }}</h1>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap
|
||||
{% if proposal.status == 'idea' %}bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30
|
||||
{% elif proposal.status == 'outline' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
|
||||
{% elif proposal.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
|
||||
{% elif proposal.status == 'submitted' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
|
||||
{% elif proposal.status == 'merged' %}bg-emerald-500/20 text-emerald-400 ring-1 ring-emerald-500/30
|
||||
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
|
||||
{{ proposal.status | upper }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if proposal.description %}
|
||||
<p class="text-sm text-slate-300 leading-relaxed mb-4">{{ proposal.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Metadata grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{% if proposal.intended_wg %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Working Group</h3>
|
||||
<p class="text-sm text-slate-300">{{ proposal.intended_wg }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if proposal.draft_name %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Draft Name</h3>
|
||||
<p class="text-sm text-slate-300 font-mono text-xs">{{ proposal.draft_name }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if proposal.source_paper %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Source Paper</h3>
|
||||
{% if proposal.source_url %}
|
||||
<a href="{{ proposal.source_url }}" target="_blank" class="text-sm text-blue-400 hover:text-blue-300 transition">{{ proposal.source_paper }}</a>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-300">{{ proposal.source_paper }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Dates</h3>
|
||||
<p class="text-xs text-slate-400">Created: {{ proposal.created_at[:10] if proposal.created_at else 'N/A' }}</p>
|
||||
<p class="text-xs text-slate-400">Updated: {{ proposal.updated_at[:10] if proposal.updated_at else 'N/A' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 pt-4 border-t border-slate-800/50">
|
||||
<a href="/proposals/{{ proposal.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>
|
||||
<form method="POST" action="/proposals/{{ proposal.id }}/delete" onsubmit="return confirm('Delete this proposal? 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>
|
||||
|
||||
<!-- Linked Gaps -->
|
||||
{% if proposal.gaps %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Linked Gaps ({{ proposal.gaps | length }})</h2>
|
||||
<div class="space-y-3">
|
||||
{% for gap in proposal.gaps %}
|
||||
<a href="/gaps/{{ gap.id }}" class="block bg-slate-800/50 rounded-lg border
|
||||
{% if gap.severity == 'critical' %}border-red-500/30 hover:border-red-500/50
|
||||
{% elif gap.severity == 'high' %}border-orange-500/20 hover:border-orange-500/40
|
||||
{% elif gap.severity == 'medium' %}border-yellow-500/15 hover:border-yellow-500/30
|
||||
{% else %}border-slate-700 hover:border-slate-600{% endif %}
|
||||
p-4 transition group">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white group-hover:text-blue-400 transition">{{ gap.topic }}</h3>
|
||||
<p class="text-xs text-slate-400 mt-1">{{ gap.description[:120] }}{% if gap.description | length > 120 %}...{% endif %}</p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold whitespace-nowrap shrink-0
|
||||
{% if gap.severity == 'critical' %}bg-red-500/20 text-red-400
|
||||
{% elif gap.severity == 'high' %}bg-orange-500/20 text-orange-400
|
||||
{% elif gap.severity == 'medium' %}bg-yellow-500/20 text-yellow-400
|
||||
{% else %}bg-green-500/20 text-green-400{% endif %}">
|
||||
{{ gap.severity | upper }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Content -->
|
||||
{% if proposal.content_md %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Content</h2>
|
||||
<div class="bg-slate-950 rounded-lg border border-slate-800 p-4">
|
||||
<pre class="text-sm text-slate-300 font-mono leading-relaxed whitespace-pre-wrap">{{ proposal.content_md }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
470
src/webui/templates/proposal_edit.html
Normal file
470
src/webui/templates/proposal_edit.html
Normal file
@@ -0,0 +1,470 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "proposals" %}
|
||||
|
||||
{% block title %}{% if proposal %}Edit {{ proposal.title }}{% else %}New Proposal{% endif %} — Proposals{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.intake-spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.gen-card {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gen-card:hover { border-color: rgba(59, 130, 246, 0.5); }
|
||||
.gen-card.selected { border-color: rgba(59, 130, 246, 0.7); background: rgba(59, 130, 246, 0.05); }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
{% if proposal %}
|
||||
<a href="/proposals/{{ proposal.id }}" class="text-blue-400 hover:text-blue-300 transition">{{ proposal.title }}</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">Edit</span>
|
||||
{% else %}
|
||||
<span class="text-slate-400">New Proposal</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% if not proposal %}
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- AI Generate Section (only on new, not edit) -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-400" 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>
|
||||
Quick Generate
|
||||
</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Paste a URL, article text, or notes — Claude generates multiple proposals automatically, linked to gaps.</p>
|
||||
</div>
|
||||
<button type="button" id="toggleManual" onclick="toggleManualForm()" class="text-xs text-slate-500 hover:text-slate-300 transition">
|
||||
Skip to manual form ↓
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<textarea id="intakeInput" rows="6"
|
||||
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-200 font-mono placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 resize-y"
|
||||
placeholder="Paste one or more of: • A URL (https://arxiv.org/..., blog post, RFC) • Article text or paper abstract • Your own notes or rough ideas URLs are fetched automatically. Multiple proposals will be generated and cross-referenced with existing gaps."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-slate-500">
|
||||
<span id="charCount">0</span> chars
|
||||
<span id="urlCount" class="ml-3 hidden">
|
||||
<span class="text-blue-400"><span id="urlNum">0</span> URL(s)</span> detected
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" id="generateBtn" onclick="runGenerate()"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg id="genIcon" 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="genText">Generate Proposals</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div id="genStatus" class="hidden mt-4 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<div class="flex items-center gap-3 text-sm text-blue-400">
|
||||
<span class="intake-spinner"></span>
|
||||
<div>
|
||||
<span>Analyzing input and generating proposals...</span>
|
||||
<p class="text-xs text-blue-400/60 mt-0.5">Uses Haiku for cost efficiency. May take 15-30s.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div id="genError" class="hidden mt-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<p class="text-sm text-red-400" id="genErrorText"></p>
|
||||
</div>
|
||||
|
||||
<!-- Generated proposals (pick one to fill the form below) -->
|
||||
<div id="genResults" class="hidden mt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm text-slate-300">
|
||||
<span id="genCount">0</span> proposal(s) generated
|
||||
<span id="genUsage" class="ml-2 text-xs text-slate-600"></span>
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">Click a proposal to fill the form below, or save all directly.</p>
|
||||
</div>
|
||||
<div id="genList" class="space-y-3"></div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button type="button" onclick="saveAllProposals()" id="saveAllBtn"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-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="M5 13l4 4L19 7"/></svg>
|
||||
Save All Proposals
|
||||
</button>
|
||||
<span id="saveAllStatus" class="text-sm text-slate-400 self-center"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="manualDivider" class="flex items-center gap-4 mb-6">
|
||||
<div class="flex-1 border-t border-slate-800"></div>
|
||||
<span class="text-xs text-slate-600 uppercase tracking-wider">or create manually</span>
|
||||
<div class="flex-1 border-t border-slate-800"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- Manual Form (also used for edit) -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<form method="POST" class="space-y-6" id="proposalForm">
|
||||
<!-- Title & Slug -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{% if proposal %}Edit Proposal{% else %}Manual Entry{% endif %}</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="title" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Title *</label>
|
||||
<input type="text" name="title" id="title" required
|
||||
value="{{ proposal.title if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="e.g., Agent Capability Discovery Protocol"
|
||||
oninput="autoSlug()">
|
||||
</div>
|
||||
<div>
|
||||
<label for="slug" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Slug</label>
|
||||
<input type="text" name="slug" id="slug"
|
||||
value="{{ proposal.slug if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="auto-generated-from-title">
|
||||
<p class="text-[10px] text-slate-600 mt-1">Leave blank to auto-generate from title</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="status" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Status</label>
|
||||
<select name="status" id="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">
|
||||
{% set current_status = proposal.status if proposal else 'idea' %}
|
||||
<option value="idea" {% if current_status == 'idea' %}selected{% endif %}>Idea</option>
|
||||
<option value="outline" {% if current_status == 'outline' %}selected{% endif %}>Outline</option>
|
||||
<option value="draft" {% if current_status == 'draft' %}selected{% endif %}>Draft</option>
|
||||
<option value="submitted" {% if current_status == 'submitted' %}selected{% endif %}>Submitted</option>
|
||||
<option value="merged" {% if current_status == 'merged' %}selected{% endif %}>Merged</option>
|
||||
<option value="abandoned" {% if current_status == 'abandoned' %}selected{% endif %}>Abandoned</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="intended_wg" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Intended Working Group</label>
|
||||
<input type="text" name="intended_wg" id="intended_wg"
|
||||
value="{{ proposal.intended_wg if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="e.g., opsawg, httpbis">
|
||||
</div>
|
||||
<div>
|
||||
<label for="draft_name" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Draft Name</label>
|
||||
<input type="text" name="draft_name" id="draft_name"
|
||||
value="{{ proposal.draft_name if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition font-mono text-xs"
|
||||
placeholder="draft-nennemann-ai-agent-capability-00">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Description</label>
|
||||
<textarea name="description" id="description" rows="3"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="Brief summary of the proposal idea...">{{ proposal.description if proposal else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-4">Source Reference</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="source_paper" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Source Paper / Document</label>
|
||||
<input type="text" name="source_paper" id="source_paper"
|
||||
value="{{ proposal.source_paper if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="e.g., RFC 9999, research paper title">
|
||||
</div>
|
||||
<div>
|
||||
<label for="source_url" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Source URL</label>
|
||||
<input type="url" name="source_url" id="source_url"
|
||||
value="{{ proposal.source_url if proposal else '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
|
||||
placeholder="https://...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-4">Content (Markdown)</h3>
|
||||
<textarea name="content_md" id="content_md" rows="20"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-slate-300 placeholder-slate-500 focus:outline-none focus:border-blue-500 transition font-mono leading-relaxed"
|
||||
placeholder="# Abstract
|
||||
|
||||
Write your proposal content here in Markdown...
|
||||
|
||||
## Introduction
|
||||
|
||||
## Problem Statement
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
## Security Considerations">{{ proposal.content_md if proposal else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Gap Links -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-4">Linked Gaps</h3>
|
||||
<p class="text-xs text-slate-500 mb-4">Select the gaps this proposal addresses.</p>
|
||||
|
||||
{% set selected_gap_ids = proposal.gap_ids if proposal else [] %}
|
||||
|
||||
<!-- Group by severity -->
|
||||
{% set severities = ['critical', 'high', 'medium', 'low'] %}
|
||||
{% for sev in severities %}
|
||||
{% set sev_gaps = gaps | selectattr('severity', 'equalto', sev) | list %}
|
||||
{% if sev_gaps %}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider mb-2
|
||||
{% if sev == 'critical' %}text-red-400
|
||||
{% elif sev == 'high' %}text-orange-400
|
||||
{% elif sev == 'medium' %}text-yellow-400
|
||||
{% else %}text-green-400{% endif %}">{{ sev }} ({{ sev_gaps | length }})</h4>
|
||||
<div class="space-y-2">
|
||||
{% for gap in sev_gaps %}
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg bg-slate-800/30 hover:bg-slate-800/60 transition cursor-pointer border border-transparent hover:border-slate-700">
|
||||
<input type="checkbox" name="gap_ids" value="{{ gap.id }}"
|
||||
{% if gap.id in selected_gap_ids %}checked{% endif %}
|
||||
class="mt-0.5 rounded border-slate-600 text-blue-500 focus:ring-blue-500 bg-slate-900">
|
||||
<div>
|
||||
<span class="text-sm text-white">{{ gap.topic }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-[10px] font-semibold
|
||||
{% if sev == 'critical' %}bg-red-500/20 text-red-400
|
||||
{% elif sev == 'high' %}bg-orange-500/20 text-orange-400
|
||||
{% elif sev == 'medium' %}bg-yellow-500/20 text-yellow-400
|
||||
{% else %}bg-green-500/20 text-green-400{% endif %}">{{ sev | upper }}</span>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ gap.description[:100] }}{% if gap.description | length > 100 %}...{% endif %}</p>
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-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="M5 13l4 4L19 7"/></svg>
|
||||
{% if proposal %}Save Changes{% else %}Create Proposal{% endif %}
|
||||
</button>
|
||||
{% if proposal %}
|
||||
<a href="/proposals/{{ proposal.id }}" class="px-4 py-2.5 text-sm text-slate-400 hover:text-white transition">Cancel</a>
|
||||
{% else %}
|
||||
<a href="/proposals" class="px-4 py-2.5 text-sm text-slate-400 hover:text-white transition">Cancel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let slugManuallyEdited = {{ 'true' if proposal and proposal.slug else 'false' }};
|
||||
|
||||
document.getElementById('slug').addEventListener('input', function() {
|
||||
slugManuallyEdited = this.value.length > 0;
|
||||
});
|
||||
|
||||
function autoSlug() {
|
||||
if (slugManuallyEdited) return;
|
||||
const title = document.getElementById('title').value;
|
||||
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
document.getElementById('slug').value = slug;
|
||||
}
|
||||
|
||||
{% if not proposal %}
|
||||
// Pre-select gap from URL parameter
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const preGapId = params.get('gap_id');
|
||||
if (preGapId) {
|
||||
const checkbox = document.querySelector(`input[name="gap_ids"][value="${preGapId}"]`);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
|
||||
// ── Quick Generate ──────────────────────────────────────────────────
|
||||
const intakeInput = document.getElementById('intakeInput');
|
||||
const charCountEl = document.getElementById('charCount');
|
||||
const urlCountEl = document.getElementById('urlCount');
|
||||
const urlNumEl = document.getElementById('urlNum');
|
||||
|
||||
// Store generated proposals for "Save All"
|
||||
let generatedProposals = [];
|
||||
|
||||
intakeInput.addEventListener('input', () => {
|
||||
charCountEl.textContent = intakeInput.value.length;
|
||||
const urls = intakeInput.value.match(/https?:\/\/[^\s<>"')\]]+/g) || [];
|
||||
if (urls.length > 0) {
|
||||
urlCountEl.classList.remove('hidden');
|
||||
urlNumEl.textContent = urls.length;
|
||||
} else {
|
||||
urlCountEl.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function toggleManualForm() {
|
||||
const form = document.getElementById('proposalForm');
|
||||
const divider = document.getElementById('manualDivider');
|
||||
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function runGenerate() {
|
||||
const input = intakeInput.value.trim();
|
||||
if (!input) return;
|
||||
|
||||
const btn = document.getElementById('generateBtn');
|
||||
const icon = document.getElementById('genIcon');
|
||||
const text = document.getElementById('genText');
|
||||
const status = document.getElementById('genStatus');
|
||||
const error = document.getElementById('genError');
|
||||
const results = document.getElementById('genResults');
|
||||
|
||||
btn.disabled = true;
|
||||
icon.innerHTML = '';
|
||||
icon.classList.add('intake-spinner');
|
||||
text.textContent = 'Processing...';
|
||||
status.classList.remove('hidden');
|
||||
error.classList.add('hidden');
|
||||
results.classList.add('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('input_text', input);
|
||||
|
||||
fetch('/proposals/intake', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
status.classList.add('hidden');
|
||||
icon.classList.remove('intake-spinner');
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
|
||||
if (data.error) {
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('genErrorText').textContent = data.error;
|
||||
text.textContent = 'Retry';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
generatedProposals = data.proposals;
|
||||
document.getElementById('genCount').textContent = data.count;
|
||||
|
||||
// Show usage info
|
||||
if (data.usage) {
|
||||
const u = data.usage;
|
||||
document.getElementById('genUsage').textContent =
|
||||
`${u.model} · ${u.input_tokens.toLocaleString()} in / ${u.output_tokens.toLocaleString()} out · $${u.cost_usd.toFixed(3)}`;
|
||||
}
|
||||
|
||||
const list = document.getElementById('genList');
|
||||
list.innerHTML = '';
|
||||
|
||||
data.proposals.forEach((p, i) => {
|
||||
const gapPills = (p.gap_ids || []).map(gid =>
|
||||
`<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">#${gid}</span>`
|
||||
).join(' ');
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'gen-card bg-slate-950 rounded-lg border border-slate-700 p-4';
|
||||
card.style.animationDelay = `${i * 0.1}s`;
|
||||
card.dataset.index = i;
|
||||
card.onclick = () => fillFormFromProposal(i);
|
||||
card.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-3 mb-1.5">
|
||||
<h3 class="text-sm font-semibold text-white">${p.title}</h3>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30 whitespace-nowrap">SAVED</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-2">${p.description || ''}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap text-[10px]">
|
||||
<span class="text-slate-500">Gaps: ${gapPills || '<span class="text-slate-600">none</span>'}</span>
|
||||
${p.intended_wg ? `<span class="text-slate-500">WG: <span class="text-slate-400">${p.intended_wg}</span></span>` : ''}
|
||||
${p.draft_name ? `<span class="text-slate-500 font-mono">${p.draft_name}</span>` : ''}
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400/60 mt-2">Click to load into editor below ↓</p>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
results.classList.remove('hidden');
|
||||
text.textContent = 'Generate More';
|
||||
btn.disabled = false;
|
||||
|
||||
// Update Save All button text
|
||||
document.getElementById('saveAllBtn').querySelector('span') ||
|
||||
(document.getElementById('saveAllBtn').textContent = `All ${data.count} saved`);
|
||||
// Proposals were already saved by the intake endpoint
|
||||
document.getElementById('saveAllBtn').classList.add('hidden');
|
||||
document.getElementById('saveAllStatus').textContent = `All ${data.count} proposals saved automatically.`;
|
||||
})
|
||||
.catch(err => {
|
||||
status.classList.add('hidden');
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('genErrorText').textContent = 'Network error: ' + err.message;
|
||||
icon.classList.remove('intake-spinner');
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
text.textContent = 'Retry';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function fillFormFromProposal(index) {
|
||||
const p = generatedProposals[index];
|
||||
if (!p) return;
|
||||
|
||||
// Highlight selected card
|
||||
document.querySelectorAll('.gen-card').forEach(c => c.classList.remove('selected'));
|
||||
document.querySelectorAll('.gen-card')[index].classList.add('selected');
|
||||
|
||||
// Fill form fields
|
||||
document.getElementById('title').value = p.title || '';
|
||||
slugManuallyEdited = false;
|
||||
autoSlug();
|
||||
if (p.slug) {
|
||||
document.getElementById('slug').value = p.slug;
|
||||
slugManuallyEdited = true;
|
||||
}
|
||||
document.getElementById('description').value = p.description || '';
|
||||
document.getElementById('intended_wg').value = p.intended_wg || '';
|
||||
document.getElementById('draft_name').value = p.draft_name || '';
|
||||
document.getElementById('source_paper').value = p.source_paper || '';
|
||||
document.getElementById('source_url').value = p.source_url || '';
|
||||
document.getElementById('content_md').value = p.content_md || '';
|
||||
|
||||
// Check matching gap checkboxes
|
||||
document.querySelectorAll('input[name="gap_ids"]').forEach(cb => {
|
||||
cb.checked = (p.gap_ids || []).includes(parseInt(cb.value));
|
||||
});
|
||||
|
||||
// Scroll to form
|
||||
document.getElementById('proposalForm').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function saveAllProposals() {
|
||||
// Proposals are already saved by the intake endpoint, this is a no-op
|
||||
document.getElementById('saveAllStatus').textContent = 'All proposals were saved during generation.';
|
||||
}
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
190
src/webui/templates/proposal_intake.html
Normal file
190
src/webui/templates/proposal_intake.html
Normal file
@@ -0,0 +1,190 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "proposals" %}
|
||||
|
||||
{% block title %}Proposal Intake — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.intake-spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.result-card {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">Intake</span>
|
||||
</nav>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Proposal Intake</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Paste article text, URLs, or notes below. Claude will analyze the input against all current gaps and generate structured IETF draft proposals automatically.</p>
|
||||
</div>
|
||||
|
||||
<!-- Input form -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<div class="mb-4">
|
||||
<label for="inputText" class="block text-sm font-medium text-slate-300 mb-2">Input Material</label>
|
||||
<textarea id="inputText" rows="12"
|
||||
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-200 font-mono placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 resize-y"
|
||||
placeholder="Paste one or more of: • Article text or paper abstract • URLs (https://arxiv.org/..., blog posts, etc.) • Your own notes or ideas • A mix of all the above URLs will be fetched automatically. The system will cross-reference everything with the 12 existing gaps and generate draft proposals."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-slate-500">
|
||||
<span id="charCount">0</span> chars
|
||||
<span id="urlCount" class="ml-3 hidden">
|
||||
<span class="text-blue-400"><span id="urlNum">0</span> URL(s)</span> detected — will be fetched
|
||||
</span>
|
||||
</div>
|
||||
<button id="submitBtn" onclick="runIntake()"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg id="submitIcon" 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="submitText">Generate Proposals</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div id="statusArea" class="hidden mb-6 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
|
||||
<div class="flex items-center gap-3 text-sm text-blue-400">
|
||||
<span class="intake-spinner"></span>
|
||||
<div>
|
||||
<span id="statusText">Analyzing input and generating proposals...</span>
|
||||
<p class="text-xs text-blue-400/60 mt-1">This may take 30-60 seconds. Claude is reading the input, cross-referencing gaps, and writing full proposal outlines.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div id="errorArea" class="hidden mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<p class="text-sm text-red-400" id="errorText"></p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="resultsArea" class="hidden">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">
|
||||
Generated <span id="resultCount">0</span> Proposal(s)
|
||||
</h2>
|
||||
<a href="/proposals" class="text-sm text-blue-400 hover:text-blue-300 transition">View all proposals →</a>
|
||||
</div>
|
||||
<div id="resultsList" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const textarea = document.getElementById('inputText');
|
||||
const charCount = document.getElementById('charCount');
|
||||
const urlCount = document.getElementById('urlCount');
|
||||
const urlNum = document.getElementById('urlNum');
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
charCount.textContent = textarea.value.length;
|
||||
const urls = textarea.value.match(/https?:\/\/[^\s<>"')\]]+/g) || [];
|
||||
if (urls.length > 0) {
|
||||
urlCount.classList.remove('hidden');
|
||||
urlNum.textContent = urls.length;
|
||||
} else {
|
||||
urlCount.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function runIntake() {
|
||||
const input = textarea.value.trim();
|
||||
if (!input) return;
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const icon = document.getElementById('submitIcon');
|
||||
const text = document.getElementById('submitText');
|
||||
const status = document.getElementById('statusArea');
|
||||
const error = document.getElementById('errorArea');
|
||||
const results = document.getElementById('resultsArea');
|
||||
|
||||
btn.disabled = true;
|
||||
icon.innerHTML = '';
|
||||
icon.classList.add('intake-spinner');
|
||||
text.textContent = 'Processing...';
|
||||
status.classList.remove('hidden');
|
||||
error.classList.add('hidden');
|
||||
results.classList.add('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('input_text', input);
|
||||
|
||||
fetch('/proposals/intake', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
status.classList.add('hidden');
|
||||
icon.classList.remove('intake-spinner');
|
||||
|
||||
if (data.error) {
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('errorText').textContent = data.error;
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
text.textContent = 'Retry';
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
document.getElementById('resultCount').textContent = data.count;
|
||||
const list = document.getElementById('resultsList');
|
||||
list.innerHTML = '';
|
||||
|
||||
data.proposals.forEach((p, i) => {
|
||||
const gapPills = (p.gap_ids || []).map(gid =>
|
||||
`<a href="/gaps/${gid}" class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 hover:text-blue-400 transition">#${gid}</a>`
|
||||
).join(' ');
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'result-card bg-slate-900 rounded-xl border border-green-500/30 p-5';
|
||||
card.style.animationDelay = `${i * 0.1}s`;
|
||||
card.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<a href="/proposals/${p.id}" class="text-base font-semibold text-white hover:text-blue-400 transition">${p.title}</a>
|
||||
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30 whitespace-nowrap">IDEA</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400 mb-3">${p.description}</p>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-[10px] text-slate-500">Gaps:</span>
|
||||
${gapPills || '<span class="text-[10px] text-slate-600">none linked</span>'}
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
results.classList.remove('hidden');
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>';
|
||||
text.textContent = 'Done — Generate More?';
|
||||
btn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
status.classList.add('hidden');
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('errorText').textContent = 'Network error: ' + err.message;
|
||||
icon.classList.remove('intake-spinner');
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
text.textContent = 'Retry';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
175
src/webui/templates/proposals.html
Normal file
175
src/webui/templates/proposals.html
Normal file
@@ -0,0 +1,175 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "proposals" %}
|
||||
|
||||
{% block title %}Proposals — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.status-pill { cursor: pointer; transition: all 0.2s; }
|
||||
.status-pill:hover { opacity: 0.9; }
|
||||
.status-pill.active { ring: 2px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Proposals</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">{{ proposals | length }} draft proposal{{ 's' if proposals | length != 1 }} tracking ideas for new Internet-Drafts linked to identified gaps.</p>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<a href="/proposals/intake" 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>
|
||||
Intake
|
||||
</a>
|
||||
<a href="/proposals/new" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-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="M12 4v16m8-8H4"/></svg>
|
||||
New
|
||||
</a>
|
||||
</div>
|
||||
</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 ({{ proposals | length }})
|
||||
</button>
|
||||
{% set ns = namespace(idea=0, outline=0, draft=0, submitted=0, merged=0, abandoned=0) %}
|
||||
{% for p in proposals %}
|
||||
{% if p.status == 'idea' %}{% set ns.idea = ns.idea + 1 %}
|
||||
{% elif p.status == 'outline' %}{% set ns.outline = ns.outline + 1 %}
|
||||
{% elif p.status == 'draft' %}{% set ns.draft = ns.draft + 1 %}
|
||||
{% elif p.status == 'submitted' %}{% set ns.submitted = ns.submitted + 1 %}
|
||||
{% elif p.status == 'merged' %}{% set ns.merged = ns.merged + 1 %}
|
||||
{% elif p.status == 'abandoned' %}{% set ns.abandoned = ns.abandoned + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button onclick="filterStatus('idea')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30" data-status="idea">
|
||||
Idea ({{ ns.idea }})
|
||||
</button>
|
||||
<button onclick="filterStatus('outline')" 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="outline">
|
||||
Outline ({{ ns.outline }})
|
||||
</button>
|
||||
<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('submitted')" 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="submitted">
|
||||
Submitted ({{ ns.submitted }})
|
||||
</button>
|
||||
<button onclick="filterStatus('merged')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-emerald-500/20 text-emerald-400 ring-1 ring-emerald-500/30" data-status="merged">
|
||||
Merged ({{ ns.merged }})
|
||||
</button>
|
||||
<button onclick="filterStatus('abandoned')" 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="abandoned">
|
||||
Abandoned ({{ ns.abandoned }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-6">
|
||||
<input type="text" id="searchInput" placeholder="Search proposals..." oninput="filterProposals()"
|
||||
class="w-full md:w-96 px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition">
|
||||
</div>
|
||||
|
||||
<!-- Proposal cards -->
|
||||
<div class="space-y-4" id="proposalList">
|
||||
{% for p in proposals %}
|
||||
<a href="/proposals/{{ p.id }}" class="proposal-card block bg-slate-900 rounded-xl border border-slate-800 hover:border-slate-600 p-5 transition group"
|
||||
data-status="{{ p.status }}" data-title="{{ p.title | lower }}" data-desc="{{ p.description | lower }}">
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<h2 class="text-base font-semibold text-white group-hover:text-blue-400 transition">{{ p.title }}</h2>
|
||||
<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 p.status == 'idea' %}bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30
|
||||
{% elif p.status == 'outline' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
|
||||
{% elif p.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
|
||||
{% elif p.status == 'submitted' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
|
||||
{% elif p.status == 'merged' %}bg-emerald-500/20 text-emerald-400 ring-1 ring-emerald-500/30
|
||||
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
|
||||
{{ p.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>
|
||||
|
||||
{% if p.description %}
|
||||
<p class="text-sm text-slate-400 leading-relaxed mb-3">{{ p.description[:200] }}{% if p.description | length > 200 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
{% if p.gaps %}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for gap in p.gaps %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-slate-800 text-slate-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full
|
||||
{% if gap.severity == 'critical' %}bg-red-400
|
||||
{% elif gap.severity == 'high' %}bg-orange-400
|
||||
{% elif gap.severity == 'medium' %}bg-yellow-400
|
||||
{% else %}bg-green-400{% endif %}"></span>
|
||||
{{ gap.topic[:30] }}{% if gap.topic | length > 30 %}...{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if p.source_paper %}
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>{{ p.source_paper }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if p.intended_wg %}
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>WG: {{ p.intended_wg }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if p.updated_at %}
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>{{ p.updated_at[:10] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if not proposals %}
|
||||
<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="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>
|
||||
<p class="text-slate-400 mb-4">No proposals yet. Start tracking your draft ideas.</p>
|
||||
<a href="/proposals/new" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-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="M12 4v16m8-8H4"/></svg>
|
||||
Create First Proposal
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let currentStatus = 'all';
|
||||
|
||||
function filterStatus(status) {
|
||||
currentStatus = status;
|
||||
// Update pill styling
|
||||
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';
|
||||
}
|
||||
});
|
||||
filterProposals();
|
||||
}
|
||||
|
||||
function filterProposals() {
|
||||
const query = document.getElementById('searchInput').value.toLowerCase();
|
||||
document.querySelectorAll('.proposal-card').forEach(card => {
|
||||
const matchStatus = currentStatus === 'all' || card.dataset.status === currentStatus;
|
||||
const matchSearch = !query || card.dataset.title.includes(query) || card.dataset.desc.includes(query);
|
||||
card.style.display = (matchStatus && matchSearch) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize with "all" active
|
||||
filterStatus('all');
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user