238 lines
12 KiB
Python

"""HUX-09 restrained contextual suggestions with server-owned suppression.
Clients submit a bounded context signal; they cannot create suggestion text,
change cooldowns, or claim that a suggestion was clicked. Private and
sensitive conversations return an explicit no-store result before any
suggestion state or idempotency record is written.
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from typing import Any
from hux import contracts
from hux.errors import Conflict, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.store import now_iso
CARD = "HUX-09"
FAMILY = "suggestion_states"
MAX_STATES = 1000
CONTEXTS = ("first_session", "empty_project", "after_artifact", "after_research", "after_approval", "idle")
CATALOG: dict[str, dict[str, Any]] = {
"first_session": {"id": "sug_first_session", "kind": "tip", "title": "Choose how Hermes works", "body": "Pick Fast, Thoughtful, Research, Create, or Private/local for this conversation.", "action": {"type": "open_mode"}, "priority": 80},
"empty_project": {"id": "sug_empty_project", "kind": "project", "title": "Give this project a starting point", "body": "Add a goal or move a related conversation here when it is useful.", "action": {"type": "create_project"}, "priority": 50},
"after_artifact": {"id": "sug_after_artifact", "kind": "feature", "title": "Keep working on this artifact", "body": "Open the artifact workspace to compare versions or continue editing.", "action": {"type": "open_artifacts"}, "priority": 70},
"after_research": {"id": "sug_after_research", "kind": "workflow", "title": "Keep the research together", "body": "Save the sources, assumptions, and open questions as a reusable workflow.", "action": {"type": "start_workflow"}, "priority": 65},
"after_approval": {"id": "sug_after_approval", "kind": "tip", "title": "Review autonomy controls", "body": "You can adjust what Hermes asks before doing for this conversation.", "action": {"type": "none"}, "priority": 40},
"idle": {"id": "sug_idle_workflow", "kind": "workflow", "title": "Make repeated work reusable", "body": "If this repeats, Hermes can turn it into a workflow without running it now.", "action": {"type": "start_workflow"}, "priority": 20},
}
SCHEMAS = contracts.load_all()
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc)
def _body(request: Request, allowed: set[str]) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - allowed
if extra:
raise Invalid("unexpected suggestion fields", sorted(extra))
return request.body
def _scope(request: Request) -> tuple[str, dict[str, Any]]:
from hux import organization
project_id, conversation_id = request.params["project_id"], request.params["id"]
try:
conversation = request.store.get(organization.CONVERSATIONS, conversation_id)
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id):
raise NotFound("project or conversation not found")
return project_id, conversation
def _privacy_gate(request: Request, conversation: dict[str, Any], body: dict[str, Any]) -> str | None:
from hux import privacy
if conversation.get("mode") == "private":
return "private_mode"
state = privacy.conversation_state(request.store, conversation["id"])
if state.get("forgotten") or state.get("memory_disabled"):
return "conversation_no_store"
if state.get("topics"):
return "sensitive_topic"
if body.get("no_store") is True or body.get("sensitivity") in {"sensitive", "restricted"}:
return "client_no_store"
return None
def _suggestion(context: str, surface: str) -> dict[str, Any]:
base = CATALOG[context]
record = {
"schema": "hux.suggestion.v1", **base,
"trigger": {"surface": surface, "context": context},
"suppression": {"dismissable": True, "max_shows": 3, "cooldown_seconds": 86400, "never_again_supported": True},
}
problems = contracts.validate("suggestion.schema.json", record, SCHEMAS, "/$defs/suggestion")
if problems:
raise Invalid("suggestion failed contract validation", problems)
return record
def _state_id(suggestion_id: str, project_id: str, conversation_id: str) -> str:
value = f"{suggestion_id}:{project_id}:{conversation_id}".encode()
return f"sugs_{hashlib.sha256(value).hexdigest()[:28]}"
def _public(state: dict[str, Any]) -> dict[str, Any]:
result = {key: state[key] for key in ("schema", "owner", "suggestion_id", "shows", "never_again")}
for key in ("last_shown_at", "dismissed_at", "acted_at"):
if key in state:
result[key] = state[key]
problems = contracts.validate("suggestion.schema.json", result, SCHEMAS, "/$defs/state")
if problems:
raise Invalid("suggestion state failed contract validation", problems)
return result
def _eligible(suggestion: dict[str, Any], state: dict[str, Any] | None, now: datetime) -> str | None:
if state is None:
return None
if state.get("never_again"):
return "never_again"
rules = suggestion["suppression"]
if state.get("shows", 0) >= rules["max_shows"]:
return "max_shows"
last = state.get("last_shown_at")
if last and now - datetime.fromisoformat(last.replace("Z", "+00:00")) < timedelta(seconds=rules["cooldown_seconds"]):
return "cooldown"
return None
def _key(request: Request) -> str:
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
return key
def _fingerprint(body: dict[str, Any], path: str) -> str:
return hashlib.sha256(json.dumps({"body": body, "path": path}, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
for row in request.store.read(FAMILY, "idempotency"):
if row.get("key") != key:
continue
if row.get("fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for a different suggestion action")
return row["result"]
return None
def evaluate(request: Request) -> Response:
"""Evaluate and atomically count one eligible suggestion display."""
body = _body(request, {"context", "no_store", "sensitivity"})
project_id, conversation = _scope(request)
context = body.get("context")
if context not in CONTEXTS:
raise Invalid("unknown suggestion context")
gated = _privacy_gate(request, conversation, body)
if gated:
request.audit("suggestions.evaluate", conversation["id"], reason=gated)
return Response(200, {"suggestion": None, "reason": gated, "stored": False}, {"Cache-Control": "no-store"})
key, fingerprint = _key(request), _fingerprint(body, request.path)
suggestion = _suggestion(context, request.identity.surface)
state_id = _state_id(suggestion["id"], project_id, conversation["id"])
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
state = request.store.get(FAMILY, state_id) if request.store.exists(FAMILY, state_id) else None
reason = _eligible(suggestion, state, clock())
if reason:
result = {"suggestion": None, "reason": reason, "stored": False}
else:
if state is None and request.store.count(FAMILY) >= MAX_STATES:
result = {"suggestion": None, "reason": "state_limit", "stored": False}
else:
stamp = now_iso()
record = {
"id": state_id, "schema": "hux.suggestion_state.v1", "owner": request.identity.subject,
"suggestion_id": suggestion["id"], "shows": (state or {}).get("shows", 0) + 1,
"last_shown_at": stamp, "never_again": False,
}
stored = request.store.put(FAMILY, record, expected_revision=state["revision"] if state else 0)
result = {"suggestion": suggestion, "state": _public(stored), "revision": stored["revision"], "stored": True}
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": now_iso()})
request.audit("suggestions.evaluate", suggestion["id"], reason=result.get("reason", "shown"))
headers = {"Cache-Control": "no-store"}
if result.get("revision"):
headers["ETag"] = str(result["revision"])
return Response(200, result, headers)
def decide(request: Request) -> Response:
"""Record only an explicit user click, guarded by If-Match and idempotency."""
body = _body(request, {"decision", "clicked"})
_, conversation = _scope(request)
gated = _privacy_gate(request, conversation, body)
if gated:
raise NotFound("suggestion state not found")
if body.get("clicked") is not True or body.get("decision") not in {"dismissed", "acted", "never_again"}:
raise Invalid("an explicit clicked decision is required")
key, fingerprint = _key(request), _fingerprint(body, request.path)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
state_id = _state_id(request.params["suggestion_id"], request.params["project_id"], conversation["id"])
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
if not request.store.exists(FAMILY, state_id):
raise NotFound("suggestion state not found")
current = request.store.get(FAMILY, state_id)
if current["revision"] != expected:
raise Conflict("suggestion state revision does not match If-Match")
stamp, decision = now_iso(), body["decision"]
updated = {**current}
if decision in {"dismissed", "never_again"}:
updated["dismissed_at"] = stamp
if decision == "acted":
updated["acted_at"] = stamp
if decision == "never_again":
updated["never_again"] = True
stored = request.store.put(FAMILY, updated, expected_revision=expected)
result = {"state": _public(stored), "revision": stored["revision"], "decision": decision}
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": stamp})
request.audit("suggestions.decide", request.params["suggestion_id"], reason=decision)
return Response(200, result, {"ETag": str(stored["revision"]), "Cache-Control": "no-store"})
def list_states(request: Request) -> Response:
"""Inspect suppression state for exactly one project/conversation scope."""
project_id, conversation = _scope(request)
if _privacy_gate(request, conversation, {}):
return Response(200, {"items": [], "next": None}, {"Cache-Control": "no-store"})
prefix = lambda row: row["id"] == _state_id(row["suggestion_id"], project_id, conversation["id"])
items = [_public(row) | {"revision": row["revision"]} for row in request.store.scan(FAMILY) if prefix(row)]
request.audit("suggestions.list", conversation["id"])
response = page(items)
response.headers["Cache-Control"] = "no-store"
return response
def register(router: Router) -> None:
"""Attach HUX-09 routes."""
base = "/hux/v1/projects/{project_id}/conversations/{id}/suggestions"
router.add("POST", base + "/evaluate", CARD, "suggestions.evaluate", evaluate)
router.add("POST", base + "/{suggestion_id}/decisions", CARD, "suggestions.decide", decide)
router.add("GET", base + "/states", CARD, "suggestions.list", list_states)