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:
@@ -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
|
# Comma-separated list of provider keys to enable. Per the §19.2
|
||||||
# per-RFC-model topic, this is app-wide until that topic lands.
|
# per-RFC-model topic, this is app-wide until that topic lands.
|
||||||
ENABLED_MODELS=claude
|
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=
|
ANTHROPIC_API_KEY=
|
||||||
GOOGLE_API_KEY=
|
GOOGLE_API_KEY=
|
||||||
OPENAI_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) ---
|
# --- Email (§15.4) ---
|
||||||
# Leave SMTP_HOST unset to use the stdout fallback — the integration
|
# Leave SMTP_HOST unset to use the stdout fallback — the integration
|
||||||
# tests rely on it, and a dev environment without a real SMTP provider
|
# tests rely on it, and a dev environment without a real SMTP provider
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from . import (
|
|||||||
notify,
|
notify,
|
||||||
philosophy,
|
philosophy,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
|
tag_suggest,
|
||||||
)
|
)
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
from .config import Config
|
from .config import Config
|
||||||
@@ -58,6 +59,17 @@ class ProposeBody(BaseModel):
|
|||||||
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
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):
|
class DeclineBody(BaseModel):
|
||||||
comment: str = Field(min_length=1, max_length=4000)
|
comment: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
@@ -843,6 +855,33 @@ def make_router(
|
|||||||
|
|
||||||
return {"pr_number": pr["number"], "slug": slug}
|
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
|
# §9.3: merge / decline / withdraw an idea PR
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
|
|||||||
return providers
|
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]:
|
def load_from_config(config) -> dict[str, BaseProvider]:
|
||||||
"""Convenience adapter so callers can pass our Config dataclass directly."""
|
"""Convenience adapter so callers can pass our Config dataclass directly."""
|
||||||
env = {
|
env = {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"]) == []
|
||||||
@@ -202,6 +202,30 @@ export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase })
|
|||||||
return jsonOrThrow(res)
|
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) {
|
export async function mergeProposal(prNumber) {
|
||||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
//
|
//
|
||||||
// Title (required) and pitch (required textarea), with a slug field
|
// Title (required) and pitch (required textarea), with a slug field
|
||||||
// that auto-fills from the title via the same deterministic kebab-case
|
// 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
|
// the backend uses. Tags are chip-input (free-form), with the §9.1
|
||||||
// AI-suggested chips of §9.1 are deferred to Slice 2 when the AI surface
|
// Slice 2 AI-suggested chips (roadmap #27) wired in: as the draft fills
|
||||||
// is wired up).
|
// 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;
|
// The submit button drives the §17 POST /api/rfcs/propose endpoint;
|
||||||
// success navigates the proposer to the pending-idea view per §9.3.
|
// success navigates the proposer to the pending-idea view per §9.3.
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { proposeRFC } from '../api'
|
import { proposeRFC, suggestTags } from '../api'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
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) {
|
function slugify(title) {
|
||||||
return title
|
return title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -32,17 +39,50 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
const [tags, setTags] = useState([])
|
const [tags, setTags] = useState([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!slugEdited) setSlug(slugify(title))
|
if (!slugEdited) setSlug(slugify(title))
|
||||||
}, [title, slugEdited])
|
}, [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() {
|
function addTag() {
|
||||||
const t = tagInput.trim()
|
const t = tagInput.trim()
|
||||||
if (t && !tags.includes(t)) setTags([...tags, t])
|
if (t && !tags.includes(t)) setTags([...tags, t])
|
||||||
setTagInput('')
|
setTagInput('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addSuggested(tag) {
|
||||||
|
if (!tags.includes(tag)) setTags([...tags, tag])
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!title.trim() || !slug || !pitch.trim()) return
|
if (!title.trim() || !slug || !pitch.trim()) return
|
||||||
@@ -152,6 +192,45 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* #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)) && (
|
||||||
|
<div style={{ marginTop: 4, marginBottom: 14 }}>
|
||||||
|
{freshSuggestions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="field-help" style={{ marginTop: 0, marginBottom: 4 }}>
|
||||||
|
Suggested tags — click to add:
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
{freshSuggestions.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.tag}
|
||||||
|
type="button"
|
||||||
|
className="entry-tag"
|
||||||
|
onClick={() => addSuggested(s.tag)}
|
||||||
|
title={`Add "${s.tag}"`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginRight: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
border: '1px dashed var(--color-border, #ccc)',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>+ {s.tag}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="field-help" style={{ marginTop: 4, marginBottom: 0, fontStyle: 'italic' }}>
|
||||||
|
Suggestions are generated by Claude (Anthropic). The text you've
|
||||||
|
entered above is sent to Anthropic to produce them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{viewer && (
|
{viewer && (
|
||||||
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
|
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
|
||||||
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> — you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
|
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> — you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
|
||||||
|
|||||||
Reference in New Issue
Block a user