v0.24.0: Claude Haiku tag suggestions on propose-RFC (roadmap #27)

The §9.1 Slice-2 AI-suggested tag chips, deferred since the propose
modal landed, now wired up. As the propose-RFC draft fills in, the
backend asks Claude Haiku for tags drawn ONLY from the corpus's
existing tag set (the de-facto taxonomy — v1 tags are free-form chip
input, there is no curated list), surfaced as clickable suggestion
chips. Nothing auto-applies; the user clicks to add.

Backend:
- tag_suggest.py: universe gather (distinct corpus tags, most-common
  first), Haiku prompt + tolerant reply parser (drops invented tags,
  dedupes, clamps confidence), in-process per-user rate limit.
- providers.construct_haiku(): dedicated Haiku provider from the
  operator key, independent of ENABLED_MODELS — tag suggestion always
  uses the cheap+fast model. No RFC slug at propose time, so the §6.7
  funder path does not apply.
- POST /api/rfcs/suggest-tags: contributor-gated, rate-limited.
  Degrades to an empty list (never an error) when no Anthropic key is
  bound, the corpus has no tags, or the draft is empty.

Frontend:
- ProposeModal: debounced suggestion fetch (700ms, stale-response
  guarded), clickable suggestion chips, and the required inline
  disclosure that the draft text is sent to Anthropic.
- api.suggestTags(): forgiving — any non-OK resolves to [].

Tests: 11 new (vertical contributor-gating / filtering / no-key /
empty-corpus / 429, plus units for gather, parser tolerance, max,
short-circuit, provider-failure). Full suite 351 green.

New secret on deploy: ANTHROPIC_API_KEY (see CHANGELOG Upgrade steps).
Disclosure copy wants a counsel pass before deploy, per #22 discipline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 13:25:39 -07:00
parent daebb54f47
commit fb9b4fa422
7 changed files with 682 additions and 5 deletions
+39
View File
@@ -39,6 +39,7 @@ from . import (
notify,
philosophy,
providers as providers_mod,
tag_suggest,
)
from .bot import Bot
from .config import Config
@@ -58,6 +59,17 @@ class ProposeBody(BaseModel):
proposed_use_case: str | None = Field(default=None, max_length=8000)
class SuggestTagsBody(BaseModel):
# Roadmap #27: the partial propose-RFC draft, sent as the user types
# (debounced on the frontend). All fields optional — suggestions
# refine as the draft fills in. `pitch` is the "why is this needed"
# rationale; `use_case` is the #26 optional ground-truth field.
# Bounds mirror the propose body's free-text caps.
title: str = Field(default="", max_length=200)
pitch: str = Field(default="", max_length=8000)
use_case: str = Field(default="", max_length=8000)
class DeclineBody(BaseModel):
comment: str = Field(min_length=1, max_length=4000)
@@ -843,6 +855,33 @@ def make_router(
return {"pr_number": pr["number"], "slug": slug}
# ---------------------------------------------------------------
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
# propose-RFC fields fill in. The modal debounce-posts the partial
# draft; we constrain Haiku to the corpus's existing tag set and
# return a short ranked list of clickable chips. Gated to
# contributors (same gate as propose) so the cost surface is bounded
# to people who can actually file an RFC; rate-limited per user as a
# backstop. Degrades to an empty list (no error) when no Anthropic
# key is bound, the corpus has no tags yet, or the draft is empty —
# so the modal simply shows nothing extra.
# ---------------------------------------------------------------
@router.post("/api/rfcs/suggest-tags")
async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
if not tag_suggest.rate_limit_ok(user.user_id):
raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
provider = tag_suggest.haiku_provider(config)
if provider is None:
return {"suggestions": []}
universe = tag_suggest.gather_tag_universe()
draft = tag_suggest.Draft(
title=payload.title, pitch=payload.pitch, use_case=payload.use_case
)
suggestions = tag_suggest.suggest(provider, draft, universe)
return {"suggestions": suggestions}
# ---------------------------------------------------------------
# §9.3: merge / decline / withdraw an idea PR
# ---------------------------------------------------------------
+15
View File
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
return providers
def construct_haiku(api_key: str) -> AnthropicProvider:
"""A dedicated Claude Haiku provider, independent of the
`ENABLED_MODELS` chat-picker universe.
The §9.1 tag-suggestion surface (roadmap #27) always wants the
cheap + fast model regardless of which models the operator surfaces
in the §8.12 picker, so it constructs Haiku directly from the
operator's Anthropic key rather than going through `load_providers`.
The model id is sourced from the same `_CLAUDE_VARIANTS` table the
picker uses, so a model-string bump lands in one place.
"""
model, name = _CLAUDE_VARIANTS["claude-haiku"]
return AnthropicProvider(api_key=api_key, model=model, display_name=name)
def load_from_config(config) -> dict[str, BaseProvider]:
"""Convenience adapter so callers can pass our Config dataclass directly."""
env = {
+244
View File
@@ -0,0 +1,244 @@
"""Roadmap #27 — Claude Haiku tag suggestions for the propose-RFC modal.
A cheap, fast assist: given the partial RFC draft a user is typing, ask
Claude Haiku to recommend tags drawn ONLY from the tags already in use
across the corpus. v1 has no curated tag list tags are free-form chip
input (§9.1) so "the taxonomy" is the de-facto set of distinct tags
the existing RFCs already carry. The model is constrained to that set
and MUST NOT invent new tags; taxonomy extension (letting the model
propose genuinely new tags) is a deferred follow-up per the roadmap.
Why Haiku specifically: cost. Picking a few reasonable tags from a known
set is well within Haiku's range, and the modal fires this repeatedly as
the user types, so the per-call price has to stay small.
The whole surface degrades to silence rather than error: no Anthropic
key, no provider, an empty corpus, a rate-limited caller, an empty
draft, or an unparseable model reply all yield an empty suggestion list.
The propose modal hides its suggestion row on an empty list, so the
fallback is simply the existing free-form chip input with nothing extra
shown.
"""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass
from . import db
from .providers import BaseProvider, construct_haiku
log = logging.getLogger(__name__)
# Bound the universe handed to the model so a large corpus can't blow up
# the prompt size (and the cost). The most-common tags matter most.
_UNIVERSE_CAP = 200
# How many suggestions we ever return to the modal.
DEFAULT_MAX_SUGGESTIONS = 6
@dataclass
class Draft:
"""The partial propose-RFC draft. `pitch` is the "why is this needed"
rationale; `use_case` is the #26 optional ground-truth field."""
title: str = ""
pitch: str = ""
use_case: str = ""
def is_empty(self) -> bool:
return not (self.title.strip() or self.pitch.strip() or self.use_case.strip())
def haiku_provider(config) -> BaseProvider | None:
"""Construct a dedicated Claude Haiku provider from the operator's
Anthropic key, or return None when no key is configured.
None means "suggestions unavailable" the caller returns an empty
list and the modal shows nothing. This is the seam tests monkeypatch
to inject a fake provider without a real key. There is no RFC slug at
propose time, so the §6.7 per-RFC funder path deliberately does not
apply: tag suggestion always runs on the operator's own key.
"""
key = getattr(config, "anthropic_api_key", "") or ""
if not key:
return None
return construct_haiku(key)
def gather_tag_universe(cap: int = _UNIVERSE_CAP) -> list[str]:
"""Every distinct tag in use across the cached corpus, most-common
first (ties broken alphabetically for determinism), capped.
This is the universe the model is constrained to. An empty corpus
yields an empty list, which short-circuits suggestion to silence.
"""
rows = db.conn().execute("SELECT tags_json FROM cached_rfcs").fetchall()
counts: dict[str, int] = {}
for r in rows:
try:
tags = json.loads(r["tags_json"] or "[]")
except (ValueError, TypeError):
continue
if not isinstance(tags, list):
continue
for t in tags:
if not isinstance(t, str):
continue
tag = t.strip()
if tag:
counts[tag] = counts.get(tag, 0) + 1
ranked = sorted(counts, key=lambda t: (-counts[t], t.lower()))
return ranked[:cap]
# ---------------------------------------------------------------------------
# Rate limiting — in-process, per-user sliding window.
#
# Cost control, not security: the `require_contributor` gate already
# bounds callers to authenticated beta users, and the modal debounces.
# This is the backstop against a stuck/abusive client hammering the
# endpoint. In-memory is sufficient (single-process uvicorn on the VM)
# and resets on restart, which is fine for a cost guard.
# ---------------------------------------------------------------------------
_CALLS: dict[int, list[float]] = {}
def _rate_config() -> tuple[int, float]:
try:
max_calls = int(os.environ.get("TAG_SUGGEST_RATE_MAX", "30"))
except ValueError:
max_calls = 30
try:
window = float(os.environ.get("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60"))
except ValueError:
window = 60.0
return max_calls, window
def rate_limit_ok(user_id: int, *, now: float | None = None) -> bool:
"""True if this call is within the per-user window; records the call.
`now` is injectable for tests (defaults to a monotonic clock)."""
max_calls, window = _rate_config()
t = time.monotonic() if now is None else now
calls = _CALLS.setdefault(user_id, [])
cutoff = t - window
calls[:] = [c for c in calls if c > cutoff]
if len(calls) >= max_calls:
return False
calls.append(t)
return True
def reset_rate_limits() -> None:
"""Test seam — clear the in-process window state."""
_CALLS.clear()
# ---------------------------------------------------------------------------
# Prompt + parse.
# ---------------------------------------------------------------------------
_SYSTEM = (
"You suggest topic tags for a draft RFC — a structured proposal "
"document in a collection. You are given the draft's title, its "
"rationale, an optional use case, and the exact set of tags already "
"in use across the collection. Choose the tags from that set that "
"best fit the draft.\n"
"Rules:\n"
"- Choose ONLY from the provided tag set. Never invent a tag.\n"
"- Order best-fit first. Omit weak fits rather than padding the list.\n"
"- Return at most {max} tags.\n"
"Respond with ONLY a JSON array, no prose, of the form:\n"
'[{{"tag": "<exact tag from the set>", "confidence": <number between 0 and 1>}}]\n'
"If no tag in the set fits the draft, return []."
)
def _build_messages(draft: Draft, universe: list[str], max_suggestions: int):
system = _SYSTEM.format(max=max_suggestions)
parts: list[str] = []
if draft.title.strip():
parts.append(f"Title: {draft.title.strip()[:300]}")
if draft.pitch.strip():
parts.append(f"Why this RFC is needed:\n{draft.pitch.strip()[:4000]}")
if draft.use_case.strip():
parts.append(f"What it will be used for:\n{draft.use_case.strip()[:4000]}")
parts.append(
"Tags already in use (choose only from these):\n" + ", ".join(universe)
)
return system, [{"role": "user", "content": "\n\n".join(parts)}]
def _extract_json_array(text: str):
start = text.find("[")
end = text.rfind("]")
if start == -1 or end == -1 or end < start:
return None
try:
return json.loads(text[start : end + 1])
except ValueError:
return None
def parse_reply(text: str, universe: list[str], max_suggestions: int) -> list[dict]:
"""Parse the model reply into a clean, universe-constrained list.
Tolerant of the model returning bare strings or objects, extra prose
around the JSON, unknown/invented tags (dropped), duplicate tags
(deduped), and missing/garbage confidences (defaulted + clamped).
"""
data = _extract_json_array(text or "")
if not isinstance(data, list):
return []
# Map back to the canonical spelling in the universe, case-insensitively,
# so a model that lowercases a tag still resolves to the real one.
canonical = {t.lower(): t for t in universe}
out: list[dict] = []
seen: set[str] = set()
for item in data:
if isinstance(item, dict):
raw = item.get("tag")
conf = item.get("confidence")
elif isinstance(item, str):
raw, conf = item, None
else:
continue
if not isinstance(raw, str):
continue
tag = canonical.get(raw.strip().lower())
if tag is None or tag in seen:
continue
try:
c = float(conf) if conf is not None else 0.5
except (ValueError, TypeError):
c = 0.5
c = max(0.0, min(1.0, c))
out.append({"tag": tag, "confidence": round(c, 3)})
seen.add(tag)
if len(out) >= max_suggestions:
break
return out
def suggest(
provider: BaseProvider,
draft: Draft,
universe: list[str],
max_suggestions: int = DEFAULT_MAX_SUGGESTIONS,
) -> list[dict]:
"""Orchestrate one suggestion call. Returns [] for an empty draft or
empty universe (no model call), and for any provider/parse failure."""
if draft.is_empty() or not universe:
return []
system, history = _build_messages(draft, universe, max_suggestions)
try:
text = provider.send(system, history)
except Exception as exc: # provider/network failure → silent empty
log.warning("tag-suggest provider failed: %s", exc)
return []
return parse_reply(text, universe, max_suggestions)