diff --git a/backend/.env.example b/backend/.env.example index b5bb476..4164dce 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -38,10 +38,22 @@ GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret # Comma-separated list of provider keys to enable. Per the §19.2 # per-RFC-model topic, this is app-wide until that topic lands. ENABLED_MODELS=claude +# ANTHROPIC_API_KEY also powers the §9.1 propose-RFC tag suggestions +# (roadmap #27) — that surface always uses Claude Haiku for cost, +# independent of ENABLED_MODELS. With no key set, tag suggestions are +# simply unavailable (the modal hides the row); the rest of the app is +# unaffected. ANTHROPIC_API_KEY= GOOGLE_API_KEY= OPENAI_API_KEY= +# --- Tag suggestions (§9.1 / roadmap #27) --- +# Per-user rate limit on the suggest-tags endpoint (cost backstop; the +# modal debounces and the endpoint is contributor-gated). Optional — +# these defaults apply when unset. +TAG_SUGGEST_RATE_MAX=30 +TAG_SUGGEST_RATE_WINDOW_SECONDS=60 + # --- Email (§15.4) --- # Leave SMTP_HOST unset to use the stdout fallback — the integration # tests rely on it, and a dev environment without a real SMTP provider diff --git a/backend/app/api.py b/backend/app/api.py index 32756fa..91b8bf8 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -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 # --------------------------------------------------------------- diff --git a/backend/app/providers.py b/backend/app/providers.py index 03af245..017add8 100644 --- a/backend/app/providers.py +++ b/backend/app/providers.py @@ -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 = { diff --git a/backend/app/tag_suggest.py b/backend/app/tag_suggest.py new file mode 100644 index 0000000..0c86662 --- /dev/null +++ b/backend/app/tag_suggest.py @@ -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": "", "confidence": }}]\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) diff --git a/backend/tests/test_tag_suggest_vertical.py b/backend/tests/test_tag_suggest_vertical.py new file mode 100644 index 0000000..663be03 --- /dev/null +++ b/backend/tests/test_tag_suggest_vertical.py @@ -0,0 +1,264 @@ +"""Vertical + unit coverage for roadmap #27 (rfc-app v0.24.0): Claude +Haiku tag suggestions on the propose-RFC modal. + +Reuses the FakeGitea + session helpers from test_propose_vertical.py. +The Anthropic call is never made for real — tests monkeypatch the +`tag_suggest.haiku_provider` seam with a stub provider whose `send` +returns canned text, so the HTTP contract is exercised without a key. + +Proves: + (a) the endpoint is contributor-gated (anon → 401); + (b) a contributor gets suggestions, filtered to the corpus tag + universe, with invented tags dropped; + (c) no Anthropic key bound → empty list, not an error; + (d) an empty corpus → empty list (model is never even called); + (e) the per-user rate limit surfaces as a 429; +plus unit coverage of the universe gather, the reply parser's tolerance, +and the suggest() orchestration short-circuits. +""" +from __future__ import annotations + +import json + +import pytest + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class StubProvider: + """A BaseProvider stand-in whose send() returns a fixed string (or + raises, to exercise the graceful-failure path).""" + + def __init__(self, reply: str = "[]", *, raises: bool = False): + self.reply = reply + self.raises = raises + self.calls: list[tuple[str, list]] = [] + + def send(self, system, history): + self.calls.append((system, history)) + if self.raises: + raise RuntimeError("boom") + return self.reply + + +def _seed_tags(slug: str, title: str, tags: list[str], state: str = "active") -> None: + from app import db + + db.conn().execute( + "INSERT OR REPLACE INTO cached_rfcs (slug, title, state, tags_json) VALUES (?, ?, ?, ?)", + (slug, title, state, json.dumps(tags)), + ) + + +def _use_provider(monkeypatch, provider) -> None: + monkeypatch.setattr("app.tag_suggest.haiku_provider", lambda config: provider) + + +@pytest.fixture(autouse=True) +def _reset_rate_limits(): + from app import tag_suggest + + tag_suggest.reset_rate_limits() + yield + tag_suggest.reset_rate_limits() + + +# --------------------------------------------------------------------------- +# Endpoint (vertical) +# --------------------------------------------------------------------------- + + +def test_anonymous_cannot_suggest(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.post("/api/rfcs/suggest-tags", json={"title": "X"}) + assert r.status_code == 401 + + +def test_contributor_gets_filtered_suggestions(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + _seed_tags("ohm", "OHM", ["identity", "schema", "consent"]) + _seed_tags("other", "Other", ["identity", "governance"]) + + # Model returns two real tags (one lowercased to test canonical + # mapping is exact-set anyway), plus one invented tag that MUST + # be dropped. + reply = json.dumps([ + {"tag": "identity", "confidence": 0.9}, + {"tag": "consent", "confidence": 0.7}, + {"tag": "totally-invented", "confidence": 0.99}, + ]) + stub = StubProvider(reply=reply) + _use_provider(monkeypatch, stub) + + r = client.post("/api/rfcs/suggest-tags", json={ + "title": "Consent and identity", + "pitch": "We need a shared definition of consent tied to identity.", + }) + assert r.status_code == 200, r.text + tags = [s["tag"] for s in r.json()["suggestions"]] + assert tags == ["identity", "consent"] + # the model was actually invoked + assert len(stub.calls) == 1 + # the universe (deduped distinct tags) was handed to the model + user_msg = stub.calls[0][1][0]["content"] + assert "identity" in user_msg and "governance" in user_msg + + +def test_no_api_key_returns_empty(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + _seed_tags("ohm", "OHM", ["identity"]) + + # No key bound (test env has no ANTHROPIC_API_KEY) → provider None. + # (Explicitly assert the seam returns None given the test config.) + from app import tag_suggest + assert tag_suggest.haiku_provider(app.state.config) is None + + r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"}) + assert r.status_code == 200, r.text + assert r.json()["suggestions"] == [] + + +def test_empty_corpus_returns_empty_without_calling_model(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + stub = StubProvider(reply=json.dumps([{"tag": "x", "confidence": 1}])) + _use_provider(monkeypatch, stub) + + # No cached_rfcs rows → empty universe → suggest() short-circuits. + r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"}) + assert r.status_code == 200, r.text + assert r.json()["suggestions"] == [] + assert stub.calls == [] # model never invoked on an empty universe + + +def test_rate_limit_surfaces_429(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + + monkeypatch.setenv("TAG_SUGGEST_RATE_MAX", "2") + monkeypatch.setenv("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60") + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + _seed_tags("ohm", "OHM", ["identity"]) + _use_provider(monkeypatch, StubProvider(reply="[]")) + + body = {"title": "X", "pitch": "y"} + assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200 + assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200 + # Third call inside the window trips the limit. + assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 429 + + +# --------------------------------------------------------------------------- +# Units +# --------------------------------------------------------------------------- + + +def test_gather_tag_universe_dedupes_and_ranks(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import tag_suggest + + app, _fake = app_with_fake_gitea + # The db is initialized in the app's lifespan; enter the client + # context so cached_rfcs exists before we seed it directly. + with TestClient(app): + _seed_tags("a", "A", ["identity", "schema"]) + _seed_tags("b", "B", ["identity", " schema ", "consent", ""]) # whitespace + empty + _seed_tags("c", "C", ["identity"]) + + universe = tag_suggest.gather_tag_universe() + # identity (3) > schema (2) > consent (1); whitespace trimmed/merged, + # empties dropped. + assert universe == ["identity", "schema", "consent"] + + +def test_parse_reply_tolerates_junk(): + from app import tag_suggest + + universe = ["identity", "schema", "consent"] + + # Prose around the JSON, an invented tag, a bare string, a dup, a + # missing confidence, and a garbage confidence. + text = ( + "Sure! Here are the tags:\n" + '[{"tag": "identity", "confidence": 0.9}, ' + '{"tag": "invented", "confidence": 1}, ' + '"schema", ' + '{"tag": "identity", "confidence": 0.5}, ' + '{"tag": "consent"}, ' + '{"tag": "consent", "confidence": "high"}]\n' + "Hope that helps!" + ) + out = tag_suggest.parse_reply(text, universe, max_suggestions=6) + tags = [s["tag"] for s in out] + assert tags == ["identity", "schema", "consent"] # invented dropped, deduped + by_tag = {s["tag"]: s["confidence"] for s in out} + assert by_tag["identity"] == 0.9 + assert by_tag["schema"] == 0.5 # bare string defaults to 0.5 + assert by_tag["consent"] == 0.5 # missing/garbage confidence → 0.5 + + +def test_parse_reply_empty_on_unparseable(): + from app import tag_suggest + + assert tag_suggest.parse_reply("no json here", ["a"], 6) == [] + assert tag_suggest.parse_reply("", ["a"], 6) == [] + assert tag_suggest.parse_reply("[]", ["a"], 6) == [] + + +def test_parse_reply_respects_max(): + from app import tag_suggest + + universe = ["a", "b", "c", "d", "e"] + text = json.dumps([{"tag": t, "confidence": 0.5} for t in universe]) + out = tag_suggest.parse_reply(text, universe, max_suggestions=3) + assert [s["tag"] for s in out] == ["a", "b", "c"] + + +def test_suggest_short_circuits_empty_draft(): + from app import tag_suggest + + stub = StubProvider(reply=json.dumps([{"tag": "a", "confidence": 1}])) + draft = tag_suggest.Draft(title=" ", pitch="", use_case="") + assert tag_suggest.suggest(stub, draft, ["a"]) == [] + assert stub.calls == [] # never called for an empty draft + + +def test_suggest_returns_empty_on_provider_failure(): + from app import tag_suggest + + stub = StubProvider(raises=True) + draft = tag_suggest.Draft(title="Real title", pitch="a reason") + assert tag_suggest.suggest(stub, draft, ["identity"]) == [] diff --git a/frontend/src/api.js b/frontend/src/api.js index 7b7b174..76ef5da 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -202,6 +202,30 @@ export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) return jsonOrThrow(res) } +// Roadmap #27: Claude Haiku tag suggestions for the propose-RFC modal. +// Returns a (possibly empty) array of { tag, confidence }. Deliberately +// forgiving — any non-OK response (rate limit, transient error, no key +// configured server-side) resolves to [] so the modal just shows nothing +// rather than surfacing an error for what is a best-effort assist. +export async function suggestTags({ title, pitch, useCase }) { + try { + const res = await fetch('/api/rfcs/suggest-tags', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: title || '', + pitch: pitch || '', + use_case: useCase || '', + }), + }) + if (!res.ok) return [] + const data = await res.json() + return Array.isArray(data.suggestions) ? data.suggestions : [] + } catch { + return [] + } +} + export async function mergeProposal(prNumber) { const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' }) return jsonOrThrow(res) diff --git a/frontend/src/components/ProposeModal.jsx b/frontend/src/components/ProposeModal.jsx index 78deee9..b473f9b 100644 --- a/frontend/src/components/ProposeModal.jsx +++ b/frontend/src/components/ProposeModal.jsx @@ -2,17 +2,24 @@ // // Title (required) and pitch (required textarea), with a slug field // that auto-fills from the title via the same deterministic kebab-case -// the backend uses. Tags are chip-input (free-form for slice 1; the -// AI-suggested chips of §9.1 are deferred to Slice 2 when the AI surface -// is wired up). +// the backend uses. Tags are chip-input (free-form), with the §9.1 +// Slice 2 AI-suggested chips (roadmap #27) wired in: as the draft fills +// in, the backend asks Claude Haiku for tags drawn from the corpus's +// existing tag set, surfaced as clickable suggestion chips. The assist +// is best-effort — it stays silent when unavailable. // // The submit button drives the §17 POST /api/rfcs/propose endpoint; // success navigates the proposer to the pending-idea view per §9.3. -import { useEffect, useState } from 'react' -import { proposeRFC } from '../api' +import { useEffect, useRef, useState } from 'react' +import { proposeRFC, suggestTags } from '../api' import { EVENTS, track } from '../lib/analytics' +// How long the draft must sit unchanged before we ask for suggestions — +// long enough to fire on typing pauses / field-blur, not on every +// keystroke (the backend is also per-user rate-limited as a backstop). +const SUGGEST_DEBOUNCE_MS = 700 + function slugify(title) { return title .toLowerCase() @@ -32,17 +39,50 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) { const [tags, setTags] = useState([]) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + // #27: Claude Haiku tag suggestions. `suggestions` is the latest + // ranked list from the backend ({ tag, confidence }); we render the + // subset not already chosen. `suggestedOnce` gates the disclosure + + // row so they only appear after the assist has actually run. + const [suggestions, setSuggestions] = useState([]) + const [suggestedOnce, setSuggestedOnce] = useState(false) useEffect(() => { if (!slugEdited) setSlug(slugify(title)) }, [title, slugEdited]) + // #27: debounced tag-suggestion fetch. Fires after the draft sits + // unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go + // on (a title). A stale-response guard keeps an earlier in-flight + // request from clobbering a newer one. + const suggestSeq = useRef(0) + useEffect(() => { + if (!title.trim()) { + setSuggestions([]) + return + } + const handle = setTimeout(async () => { + const seq = ++suggestSeq.current + const result = await suggestTags({ title, pitch, useCase }) + if (seq !== suggestSeq.current) return // a newer request superseded us + setSuggestions(Array.isArray(result) ? result : []) + if (result && result.length) setSuggestedOnce(true) + }, SUGGEST_DEBOUNCE_MS) + return () => clearTimeout(handle) + }, [title, pitch, useCase]) + + // Suggestions the user hasn't already added. + const freshSuggestions = suggestions.filter(s => !tags.includes(s.tag)) + function addTag() { const t = tagInput.trim() if (t && !tags.includes(t)) setTags([...tags, t]) setTagInput('') } + function addSuggested(tag) { + if (!tags.includes(tag)) setTags([...tags, tag]) + } + async function handleSubmit(e) { e.preventDefault() if (!title.trim() || !slug || !pitch.trim()) return @@ -152,6 +192,45 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) { )} + {/* #27: Claude Haiku suggested tags. Clickable chips that add + to the tag list; nothing auto-applies. The disclosure is + required — the draft text is sent to Anthropic to generate + these — so it renders whenever the suggestion row does. */} + {(freshSuggestions.length > 0 || (suggestedOnce && suggestions.length > 0)) && ( +
+ {freshSuggestions.length > 0 && ( + <> +

+ Suggested tags — click to add: +

+
+ {freshSuggestions.map(s => ( + + ))} +
+ + )} +

+ Suggestions are generated by Claude (Anthropic). The text you've + entered above is sent to Anthropic to produce them. +

+
+ )} + {viewer && (

Owner: {viewer.display_name || viewer.gitea_login} — you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).