238 lines
12 KiB
Python
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)
|