jenkins dc034cb738 hermes(hux): bounded message-text search with privacy enforcement
HUX-03: GET /hux/v1/search now accepts include=message_text, an
explicit opt-in that scans the stored message events of the 100 most
recently active candidate conversations. Forgotten (tombstoned)
conversations, private-mode conversations, restricted events and fully
redacted events never match; the default indexed-fields search and its
response contract are unchanged (the shipped UI keeps requiring
message_text in not_indexed). Paginated mode uses one deterministic
total order (score desc, updated_at desc, id) with an offset cursor.
organization.py at 98% branch coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:43:08 -03:00

385 lines
18 KiB
Python

"""HUX-03 organisation: projects, conversations, branch lineage and search.
Projects own conversations; conversations carry tags, pins, a mode and an
optional branch pointer to the conversation they forked from. Search covers
only the ``project.schema.json#/$defs/search_index`` fields this increment
indexes (title, tags, project_name, and artifact titles when the artifacts
lane is present); message text is not indexed here and the response says so.
"""
from __future__ import annotations
import re
from typing import Any
from hux import contracts, redaction
from hux.errors import Conflict, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.store import TenantStore, check_id, new_id, now_iso
CARD = "HUX-03"
PROJECTS = "projects"
CONVERSATIONS = "conversations"
MAX_PROJECTS = 200
MAX_CONVERSATIONS = 2000
PROJECT_FIELDS = ("name", "description", "tags", "pinned", "archived", "default_mode")
CONVERSATION_FIELDS = ("title", "tags", "pinned", "archived", "mode", "project_id")
INDEXED = ("title", "tags", "project_name", "artifact_titles")
NOT_INDEXED = ("message_text",)
MESSAGE_KINDS = ("message.user", "message.assistant")
MESSAGE_SCAN_CONVERSATIONS = 100
MESSAGE_SCAN_EVENTS = 300
SEARCH_PAGE = 50
SCHEMAS = contracts.load_all()
TOKEN_RE = re.compile(r"[a-z0-9]+")
def project_exists(store: TenantStore, project_id: str) -> bool:
"""True when this tenant owns a project with that id (helper for other lanes)."""
return isinstance(project_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", project_id)) and store.exists(PROJECTS, project_id)
def conversation_exists(store: TenantStore, conversation_id: str) -> bool:
"""True when this tenant owns a conversation with that id (helper for other lanes)."""
return isinstance(conversation_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", conversation_id)) and store.exists(CONVERSATIONS, conversation_id)
def project_of(store: TenantStore, conversation_id: str | None) -> str | None:
"""The project a conversation belongs to, or None when unknown or unfiled."""
if not conversation_id or not conversation_exists(store, conversation_id):
return None
return store.get(CONVERSATIONS, conversation_id).get("project_id")
def checked(record: dict[str, Any]) -> dict[str, Any]:
"""Raise Invalid unless ``record`` satisfies its contract."""
problems = contracts.validate_record(record, SCHEMAS)
if problems:
raise Invalid("record fails contract", problems)
return record
def body_dict(request: Request) -> dict[str, Any]:
"""The JSON object body or Invalid."""
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
return request.body
def pick(body: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
"""Only the client-settable fields, secret-scrubbed (F9); ids, owner, timestamps and revision are server-set."""
return redaction.scrub_value({k: body[k] for k in fields if k in body}, [])
def replay(store: TenantStore, family: str, key: str) -> dict[str, Any] | None:
"""The record an Idempotency-Key already created in this family, if any."""
for row in store.read(family, "idempotency"):
if row["key"] == key:
return store.get(family, row["id"])
return None
def create(request: Request, family: str, record: dict[str, Any], key: str, cap: int) -> tuple[dict[str, Any], int]:
"""Create under the family lock honouring Idempotency-Key and the family count cap."""
with request.store.lock(family):
if key:
existing = replay(request.store, family, key)
if existing is not None:
return existing, 200
if request.store.count(family) >= cap:
raise Conflict(f"{family} cap of {cap} reached")
stored = request.store.put(family, checked({**record, "revision": 1}))
if key:
request.store.append(family, "idempotency", {"key": key, "id": stored["id"]})
return stored, 201
def update(request: Request, family: str, fields: tuple[str, ...]) -> dict[str, Any]:
"""PATCH under If-Match; a missing If-Match is accepted but audited as unconditional (SO-44)."""
changes = pick(body_dict(request), fields)
expected = request.if_match()
with request.store.lock(family):
current = request.store.get(family, check_id(request.params["id"]))
if "project_id" in changes and changes["project_id"] is not None and not project_exists(request.store, changes["project_id"]):
raise NotFound("project not found")
record = {**current, **{k: v for k, v in changes.items() if v is not None}, "updated_at": now_iso()}
if changes.get("project_id", "") is None:
record.pop("project_id", None)
stored = request.store.put(family, checked(record), expected_revision=expected)
request.audit(f"{family}.update", stored["id"], reason="" if expected is not None else "unconditional_write")
return stored
def etag(record: dict[str, Any]) -> dict[str, str]:
"""Revision as ETag so clients can send it back in If-Match."""
return {"ETag": str(record["revision"])}
# -- projects ------------------------------------------------------------------
def create_project(request: Request) -> Response:
"""``POST /hux/v1/projects``."""
body = pick(body_dict(request), PROJECT_FIELDS)
stamp = now_iso()
record = {
"schema": "hux.project.v1", "id": new_id("prj"), "owner": request.identity.subject,
"tags": [], "pinned": False, "archived": False, **body, "created_at": stamp, "updated_at": stamp,
}
stored, status = create(request, PROJECTS, record, request.idempotency_key(), MAX_PROJECTS)
request.audit("projects.create", stored["id"], reason="replayed" if status == 200 else "")
return Response(status, stored, etag(stored))
def list_projects(request: Request) -> Response:
"""``GET /hux/v1/projects?archived=``: pinned first, then most recently updated."""
archived = request.query.get("archived")
items = [p for p in request.store.scan(PROJECTS) if archived is None or p["archived"] == (archived == "true")]
items.sort(key=lambda p: p["updated_at"], reverse=True)
items.sort(key=lambda p: not p["pinned"])
request.audit("projects.list", "projects")
return page(items)
def get_project(request: Request) -> Response:
"""``GET /hux/v1/projects/{id}``."""
record = request.store.get(PROJECTS, check_id(request.params["id"]))
request.audit("projects.read", record["id"])
return Response(200, record, etag(record))
def patch_project(request: Request) -> Response:
"""``PATCH /hux/v1/projects/{id}`` with If-Match."""
stored = update(request, PROJECTS, PROJECT_FIELDS)
return Response(200, stored, etag(stored))
# -- conversations -------------------------------------------------------------
def new_conversation(request: Request, body: dict[str, Any], extra: dict[str, Any] | None = None) -> Response:
"""Build, validate and store a conversation from client fields plus server-set extras."""
if body.get("project_id") is not None and not project_exists(request.store, body["project_id"]):
raise NotFound("project not found")
stamp = now_iso()
record = {
"schema": "hux.conversation.v1", "id": new_id("conv"), "owner": request.identity.subject,
"tags": [], "pinned": False, "archived": False, **{k: v for k, v in body.items() if v is not None},
**(extra or {}), "artifact_ids": [], "created_at": stamp, "updated_at": stamp,
}
stored, status = create(request, CONVERSATIONS, record, request.idempotency_key(), MAX_CONVERSATIONS)
request.audit("conversations.create", stored["id"], reason="replayed" if status == 200 else "")
return Response(status, stored, etag(stored))
def create_conversation(request: Request) -> Response:
"""``POST /hux/v1/conversations``."""
return new_conversation(request, pick(body_dict(request), CONVERSATION_FIELDS))
def list_conversations(request: Request) -> Response:
"""``GET /hux/v1/conversations?project_id=&tag=&pinned=&archived=``: newest activity first."""
q = request.query
flags = {k: q[k] == "true" for k in ("pinned", "archived") if k in q}
items = []
for index, record in enumerate(request.store.scan(CONVERSATIONS)):
if "project_id" in q and record.get("project_id") != q["project_id"]:
continue
if "tag" in q and q["tag"] not in record["tags"]:
continue
if any(record[k] != v for k, v in flags.items()):
continue
items.append((record.get("last_message_at", record["updated_at"]), index, record))
items.sort(key=lambda item: item[:2], reverse=True)
request.audit("conversations.list", "conversations")
return page([record for _, _, record in items])
def get_conversation(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}``."""
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
request.audit("conversations.read", record["id"])
return Response(200, record, etag(record))
def patch_conversation(request: Request) -> Response:
"""``PATCH /hux/v1/conversations/{id}`` with If-Match."""
stored = update(request, CONVERSATIONS, CONVERSATION_FIELDS)
return Response(200, stored, etag(stored))
def branch_conversation(request: Request) -> Response:
"""``POST /hux/v1/conversations/{id}/branch``: fork at a message, keeping project, tags and mode."""
body = body_dict(request)
point = body.get("branch_point_message_id")
if not isinstance(point, str) or not 1 <= len(point) <= 120:
raise Invalid("branch_point_message_id required")
parent = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
fields = {"title": body.get("title") or f"{parent['title']} (branch)"[:200], "tags": list(parent["tags"]),
"project_id": parent.get("project_id"), "mode": parent.get("mode")}
return new_conversation(request, fields, {"branch": {"parent_conversation_id": parent["id"], "branch_point_message_id": point}})
def lineage(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/lineage``: ancestors root-first plus direct children."""
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
ancestors: list[dict[str, Any]] = []
cursor, seen = record, {record["id"]}
while "branch" in cursor and len(ancestors) < 64:
parent_id = cursor["branch"]["parent_conversation_id"]
if parent_id in seen or not request.store.exists(CONVERSATIONS, parent_id):
break
cursor = request.store.get(CONVERSATIONS, parent_id)
seen.add(parent_id)
ancestors.insert(0, cursor)
children = [c for c in request.store.scan(CONVERSATIONS) if c.get("branch", {}).get("parent_conversation_id") == record["id"]]
request.audit("conversations.lineage", record["id"])
return Response(200, {"conversation": record, "ancestors": ancestors, "children": children})
# -- search --------------------------------------------------------------------
def artifact_index(store: TenantStore) -> dict[str, list[str]]:
"""Conversation id -> titles of the artifacts filed under it (F13c): both the conversation's ``artifact_ids`` and artifacts that name the conversation."""
try:
from hux import artifacts
except ModuleNotFoundError:
return {}
index: dict[str, list[str]] = {}
for artifact in store.scan(artifacts.FAMILY):
if isinstance(artifact.get("conversation_id"), str) and isinstance(artifact.get("title"), str):
index.setdefault(artifact["conversation_id"], []).append(artifact["title"])
for conversation in store.scan(CONVERSATIONS):
ids = [i for i in conversation.get("artifact_ids", []) if isinstance(i, str)]
titles = artifacts.artifact_titles(store, ids).values() if ids else []
for title in titles:
index.setdefault(conversation["id"], []).append(title)
return index
def artifact_titles(store: TenantStore, conversation: dict[str, Any], index: dict[str, list[str]] | None = None) -> list[str]:
"""Artifact titles for a conversation via the artifacts lane, or nothing when it is absent."""
index = artifact_index(store) if index is None else index
return list(dict.fromkeys(index.get(conversation["id"], [])))
def mark_forgotten(store: TenantStore, conversation_id: str) -> bool:
"""Blank a forgotten conversation's title and tags and archive it (F9); False when there is no document."""
with store.lock(CONVERSATIONS):
if not conversation_exists(store, conversation_id):
return False
current = store.get(CONVERSATIONS, conversation_id)
record = {**current, "title": "[forgotten]", "tags": [], "archived": True, "updated_at": now_iso()}
store.put(CONVERSATIONS, checked(record), expected_revision=current["revision"])
return True
def tokens(text: str) -> list[str]:
"""Lowercased alphanumeric terms."""
return TOKEN_RE.findall(text.lower())
def score(record: dict[str, Any], terms: list[str], project_name: str, titles: list[str]) -> int:
"""Title hit beats tag hit beats project/artifact hit; every term must match somewhere."""
fields = {"title": tokens(record["title"]), "tags": [t for tag in record["tags"] for t in tokens(tag)],
"project_name": tokens(project_name), "artifact_titles": [t for title in titles for t in tokens(title)]}
weight = {"title": 4, "tags": 3, "project_name": 2, "artifact_titles": 1}
total = 0
for term in terms:
hit = sum(weight[f] * words.count(term) for f, words in fields.items())
if not hit:
return 0
total += hit
return total
def message_text_score(store: TenantStore, conversation: dict[str, Any], terms: list[str]) -> int:
"""Bounded match over this conversation's stored message events.
Privacy wins: forgotten conversations were already excluded, private-mode
conversations are never scanned, and restricted or fully redacted events
stay invisible to search exactly as they are on the timeline.
"""
from hux import events
if events.is_private(store, conversation["id"]):
return 0
rows = store.read(events.FAMILY, conversation["id"])[-MESSAGE_SCAN_EVENTS:]
total = 0
for row in rows:
if row.get("kind") not in MESSAGE_KINDS or row.get("sensitivity") == "restricted":
continue
if (row.get("redaction") or {}).get("level") == "full":
continue
words = tokens(str(row.get("summary", "")))
total += sum(words.count(term) for term in terms)
return total
def search(request: Request) -> Response:
"""``GET /hux/v1/search?q=&project_id=&include=&cursor=``: best match first.
The default scope is the indexed fields only. ``include=message_text``
additionally scans the stored message events of the most recently active
``MESSAGE_SCAN_CONVERSATIONS`` candidates (bounded, privacy-enforced) and
pages deterministically: rank order is (score desc, updated_at desc, id),
the cursor is the offset into that total order.
"""
terms = tokens(request.query.get("q", "")[:200])
if not terms:
raise Invalid("q is required")
include = request.query.get("include")
if include not in (None, "message_text"):
raise Invalid("include supports only message_text")
cursor = request.query.get("cursor", "0")
if not cursor.isdigit():
raise Invalid("cursor must be a non-negative integer")
project_filter = request.query.get("project_id")
names = {p["id"]: p["name"] for p in request.store.scan(PROJECTS)}
index = artifact_index(request.store)
candidates = [
record for record in request.store.scan(CONVERSATIONS)
if not project_filter or record.get("project_id") == project_filter
]
scanned = set()
if include:
recent = sorted(candidates, key=lambda r: (r["updated_at"], r["id"]), reverse=True)
scanned = {record["id"] for record in recent[:MESSAGE_SCAN_CONVERSATIONS]}
ranked = []
for record in candidates:
points = score(record, terms, names.get(record.get("project_id", ""), ""), artifact_titles(request.store, record, index))
if record["id"] in scanned and not record.get("archived"):
points += message_text_score(request.store, record, terms)
if points:
ranked.append((points, record))
if include:
# Paginated mode needs one total order; ids break updated_at ties.
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"], pair[1]["id"]))
else:
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"]))
request.audit("search.query", "conversations")
if not include:
return Response(200, {"items": [r for _, r in ranked], "next": None, "scores": {r["id"]: s for s, r in ranked},
"indexed": list(INDEXED), "not_indexed": list(NOT_INDEXED)})
start = int(cursor)
window = ranked[start : start + SEARCH_PAGE]
return Response(200, {
"items": [r for _, r in window], "scores": {r["id"]: s for s, r in window},
"next": str(start + SEARCH_PAGE) if len(ranked) > start + SEARCH_PAGE else None,
"indexed": list(INDEXED) + ["message_text"], "not_indexed": [],
"message_scan_limit": MESSAGE_SCAN_CONVERSATIONS,
})
def register(router: Router) -> None:
"""Attach HUX-03 routes."""
router.add("POST", "/hux/v1/projects", CARD, "projects.create", create_project)
router.add("GET", "/hux/v1/projects", CARD, "projects.list", list_projects)
router.add("GET", "/hux/v1/projects/{id}", CARD, "projects.read", get_project)
router.add("PATCH", "/hux/v1/projects/{id}", CARD, "projects.update", patch_project)
router.add("POST", "/hux/v1/conversations", CARD, "conversations.create", create_conversation)
router.add("GET", "/hux/v1/conversations", CARD, "conversations.list", list_conversations)
router.add("GET", "/hux/v1/conversations/{id}", CARD, "conversations.read", get_conversation)
router.add("PATCH", "/hux/v1/conversations/{id}", CARD, "conversations.update", patch_conversation)
router.add("POST", "/hux/v1/conversations/{id}/branch", CARD, "conversations.branch", branch_conversation)
router.add("GET", "/hux/v1/conversations/{id}/lineage", CARD, "conversations.lineage", lineage)
router.add("GET", "/hux/v1/search", CARD, "search.query", search)