Step 1: Who is asking
Hollow Desk runs one support chatbot for three businesses: a dental clinic, a bike shop and an accountancy. The same person can be a customer of two of them. Everything the bot keeps is filed under a tenant and a user, so the first job is to know both for certain. They come from a session token the business's website signed, never from anything typed into the chat.
A token is "<tenant>.<user, base64url>.<expiry>.<signature>", and the signature is HMAC-SHA256 over the first
three parts with PF.SECRET (see make_token() in hollowdesk.py).
1. Write scope_from_token(token, now=None) in chatbot.py. Return a Scope(tenant, user), or raise
AuthError with the reason, checked in this order:
"malformed": not four parts;"bad signature": compare withhmac.compare_digest;"expired": past the expiry (usenowif given);"unknown tenant".
2. Write provider_user_id(scope): the id the model provider sees for this end user. Take "u_" plus the
first 24 hex characters of HMAC-SHA256, keyed by the tenant's salt, over "<tenant>:<user>".
3. Run. It checks real tokens and five forged or broken ones.
chatbot.py, the file you edit114 lines
"""Hollow Desk's chatbot: one bot, many businesses, and nothing crosses between them."""
import hashlib
import hmac
import json
import re
import time
from dataclasses import dataclass
import harness as H
import hollowdesk as PF
class AuthError(Exception):
pass
@dataclass(frozen=True)
class Scope:
tenant: str
user: str
def first_json(reply, opener):
"""The first JSON value starting with opener ("[" or "{") in a reply, or None."""
i = reply.find(opener)
while i != -1:
try:
return json.JSONDecoder().raw_decode(reply[i:])[0]
except ValueError:
i = reply.find(opener, i + 1)
return None
# ---------- Step 1: who is asking ----------
def scope_from_token(token, now=None):
"""The Scope a session token proves, or AuthError with a reason: "malformed" (not four dot-separated parts, or a
user that does not decode), "bad signature" (compare with hmac.compare_digest), "expired", "unknown tenant"."""
# TODO (Step 1): split into 4 parts, recompute the HMAC over the first three with PF.SECRET,
# hmac.compare_digest, then expiry, then the tenant; PF.b64decode() the user.
raise NotImplementedError("Step 1: write scope_from_token()")
def provider_user_id(scope):
"""The end-user id the model provider sees: "u_" + the first 24 hex characters of HMAC-SHA256 keyed by the
tenant's salt over "<tenant>:<user>". Stable for one user, different across tenants, never the email."""
# TODO (Step 1): "u_" + HMAC-SHA256(tenant salt, "<tenant>:<user>").hexdigest()[:24].
raise NotImplementedError("Step 1: write provider_user_id()")
# ---------- Step 2: redact before the model, restore for the owner ----------
def redact(scope, text):
"""text with every PF.detect() span replaced by a placeholder "[KIND_n]". The same value always gets the same
placeholder for this user; a new value gets the next n for its kind and is stored in the vault under the scope."""
raise NotImplementedError("redact() arrives in Step 2")
def restore(scope, text):
"""text with each placeholder from this user's vault replaced by its value. Placeholders the vault does not hold
for this user stay as they are."""
raise NotImplementedError("restore() arrives in Step 2")
def log_turn(scope, role, text):
"""Append one JSON line to PF.LOG: {"tenant", "user": provider_user_id(scope), "role", "text": redact(...)}.
The log never holds the user's email or any detected personal data."""
raise NotImplementedError("log_turn() arrives in Step 2")
# ---------- Step 3: memory that belongs to one tenant and one user ----------
def remember(scope, fact):
"""Store a fact about this user, redacted, under the scope."""
raise NotImplementedError("remember() arrives in Step 3")
def recall(scope):
"""The facts stored for this tenant and user, oldest first, as stored (redacted)."""
raise NotImplementedError("recall() arrives in Step 3")
def extract_facts(scope, message):
"""Ask the model which durable facts about the customer the (already redacted) message states, worth keeping for
later chats (preferences, bookings, conditions). A JSON list of short strings; [] if none or unreadable."""
raise NotImplementedError("extract_facts() arrives in Step 3")
def build_messages(scope, history, message):
"""The chat for the model: a system message with the tenant's name and FAQ and this user's remembered facts,
then history (a list of {"role", "content"}), then the message."""
raise NotImplementedError("build_messages() arrives in Step 3")
# ---------- Step 4: the chatbot ----------
def chat(token, history, message):
"""One turn: scope from the token, redact the message, log it, remember its facts, build the messages, ask the
model as provider_user_id(scope), log the reply, and return the reply restored for this user. history holds
earlier turns as sent to the model (redacted)."""
raise NotImplementedError("chat() arrives in Step 4")
# ---------- Step 5: export and forget ----------
def export_user(scope):
"""Everything kept about one user, restored: {"memory": [facts], "log": [{"role", "text"}]} (the user's log lines,
in order)."""
raise NotImplementedError("export_user() arrives in Step 5")
def forget_user(scope):
"""Delete the user's memory, vault and log lines. Returns {"memory": n, "vault": n, "log": n} deleted."""
raise NotImplementedError("forget_user() arrives in Step 5")harness.pyhollowdesk.pytenants.jsontry_it.py