UserProfileManager & UserProfile

On this page

Purpose

Tracks per-user personalization state — explicit preferences, inferred topics of interest, running behavioral averages (query length, importance), and a "memory retention boost" hint that AIMemoryManager adds to every new memory's importance for that user. UserProfile wraps a single profile's plain-dict data with typed accessors; UserProfileManager handles multi-user CRUD and optional JSON-file persistence.

Default profile shape

Python · 8 lines
_DEFAULT_PROFILE = {
    "user_id": "", "created_at": 0.0, "last_seen": 0.0,
    "session_count": 0, "total_interactions": 0,
    "preferred_language": "en", "verbosity": "medium", "response_style": "neutral",
    "topics_of_interest": [], "explicit_preferences": {},
    "topic_frequency": {}, "avg_query_length": 0.0, "avg_importance": 0.5,
    "memory_retention_boost": 0.0,
}

UserProfile

Constructor

Python
def __init__(self, data: Dict[str, Any]) -> None

Wraps data directly (no copy) — mutations via UserProfile methods mutate the passed-in dict in place.

Read properties

user_id, preferred_language, verbosity, topics_of_interest, memory_retention_boost — all simple .get() accessors with sensible defaults, except user_id which uses self._d["user_id"] (a plain subscript, not .get()) — if data is missing the "user_id" key entirely (e.g. a hand-constructed or malformed dict), accessing .user_id raises KeyError, unlike every other property on this class.

get_preference(key, default=None) -> Any

self._d["explicit_preferences"].get(key, default) — note the outer subscript is also a plain [...], not .get(...): if data was constructed without an "explicit_preferences" key at all (bypassing _DEFAULT_PROFILE's guaranteed presence of that key), this raises KeyError rather than falling back to default.

to_dict() -> Dict[str, Any]

dict(self._d) — a shallow copy.

to_context_string() -> str

Builds a compact multi-line string for prompt injection: language, verbosity/response_style, top-5 topics by frequency (only if any exist), and up to the first 4 explicit preferences as key: value lines. Silently omits interests/preferences beyond these caps rather than truncating with an indicator.

set_preference(key, value) -> None

self._d["explicit_preferences"][key] = value.

record_interaction(query, topics=None, importance=0.5) -> None

Increments total_interactions, updates last_seen, and updates two running (incremental) averages using Welford-style online mean updates:

Python · 3 lines
n = total_interactions
avg_query_length += (len(query) - avg_query_length) / n
avg_importance += (importance - avg_importance) / n

Also increments topic_frequency[topic] for each topic in topics, then recomputes topics_of_interest from scratch as the top-10 topics by frequency, every single call — this is O(topics × log(topics)) per interaction due to the sorted() call, not an incremental top-k update.

start_session() -> None

Increments session_count, updates last_seen. Not automatically called by UserProfileManager/AIMemoryManager anywhere in this source tree — you must call it yourself if you want session-count tracking (e.g. at the start of a new conversation/connection).

update_retention_boost(boost) -> None

self._d["memory_retention_boost"] = max(0.0, min(0.5, boost)) — clamped to [0.0, 0.5]. Not called by any other class in this source tree — AIMemoryManager reads profile.memory_retention_boost (via save_interaction) but nothing ever writes it via this method automatically; if you want the retention boost to actually change over time, you must call update_retention_boost() yourself based on your own signal (e.g. from profile.avg_importance or session_count).

UserProfileManager

Constructor

Python
def __init__(self, persist_dir: Optional[str] = None, *, auto_save: bool = True) -> None
ParameterTypeRequiredDefaultNotes
persist_dirOptional[str]NoNoneIf given, one JSON file per user ({persist_dir}/{user_id}.json) is created/loaded from. If None, profiles exist in memory only — lost on process restart.
auto_saveboolNoTrueIf True (and persist_dir is set), update_from_interaction() and set_preference() write to disk on every call.

get_or_create(user_id) -> UserProfile

Thread-safe (guarded by self._lock, an RLock). Checks the in-memory cache first; if absent, attempts to load from disk (if persist_dir set); if still absent, constructs a fresh profile from _DEFAULT_PROFILE with user_id and created_at filled in.

update_from_interaction(user_id, query, topics=None, importance=0.5) -> UserProfile

get_or_create + profile.record_interaction(...), then persists to disk if auto_save.

set_preference(user_id, key, value) -> None

get_or_create + profile.set_preference(...), then persists to disk if auto_save.

delete(user_id) -> bool

Removes from the in-memory cache (thread-safe) and deletes the on-disk JSON file (if any) — returns whether the in-memory profile existed before removal (the on-disk deletion's success/failure isn't reflected in the return value).

list_users() -> List[str]

Union of in-memory user IDs and on-disk *.json filenames' stems (if persist_dir is set) — sorted alphabetically. Note: this means a user who exists only on disk (never get_or_create'd this session) shows up in list_users(), but calling aggregate_stats() (below) will then transparently load them via get_or_create as a side effect.

_save_to_disk / _load_from_disk (internal)

Plain json.dump/json.load against {persist_dir}/{user_id}.json, with ensure_ascii=False on save (preserves non-ASCII characters like Arabic text directly in the file rather than \uXXXX-escaping them).

aggregate_stats() -> Dict[str, Any]

Calls get_or_create for every user returned by list_users() — meaning this method has the side effect of loading every on-disk profile into the in-memory cache, even ones you haven't otherwise touched this session. Returns user_count, total_interactions (summed), avg_interactions_per_user, top_global_topics (top 10, aggregated via collections.Counter across all users' topic_frequency).

Raises

ExceptionWhen
KeyErrorUserProfile.user_id or UserProfile.get_preference() if the underlying dict is missing the corresponding required key — only possible if you construct a UserProfile from a hand-built or corrupted dict that doesn't match _DEFAULT_PROFILE's shape; profiles created via UserProfileManager.get_or_create() always have these keys.
json.JSONDecodeError_load_from_disk if an on-disk profile file is corrupted/not valid JSON — propagates uncaught.
OSErrorFilesystem issues writing/reading profile files.

Thread safety

UserProfileManager._profiles mutation (in get_or_create, delete, and the in_memory snapshot inside list_users) is guarded by self._lock (RLock). However, UserProfile.record_interaction/set_preference/etc. themselves are not guarded by any lock — once you have a UserProfile reference back from get_or_create, concurrent calls to its mutator methods from multiple threads race on the underlying dict (e.g. total_interactions += 1 is a non-atomic read-modify-write).

Complexity

  • get_or_create/delete: O(1) in-memory, plus O(disk I/O) if persistence is active.
  • record_interaction: O(t log t) where t = number of distinct topics ever seen for that user (due to the full re-sort of topic_frequency every call).
  • aggregate_stats: O(users × topics) — loads and aggregates every user.

Examples

Python · 27 lines
from zerda.memory import UserProfileManager

pm = UserProfileManager(persist_dir="./data/profiles", auto_save=True)

# 1. Basic
profile = pm.get_or_create("alice")
print(profile.preferred_language)  # "en"

# 2. Intermediate — record an interaction
pm.update_from_interaction("alice", "What's the weather?", topics=["weather"], importance=0.4)
print(profile.topics_of_interest)  # ["weather"]

# 3. Advanced — explicit preferences + context string for prompt injection
pm.set_preference("alice", "units", "metric")
print(profile.to_context_string())

# 4. Error handling — corrupted on-disk JSON
# (if ./data/profiles/bob.json contains invalid JSON, this raises json.JSONDecodeError)
try:
    pm.get_or_create("bob")
except Exception as e:
    print(type(e).__name__, e)

# 5. Production — periodic global stats for observability dashboards
stats = pm.aggregate_stats()
print(f"{stats['user_count']} users, {stats['total_interactions']} total interactions")
print("Top topics:", stats["top_global_topics"])

Best practices

  • Call profile.start_session() yourself at the start of each new conversation/session if you want session_count tracked — it is not called automatically anywhere in this package.
  • Call profile.update_retention_boost(...) yourself if you want personalization to affect importance boosting over time — AIMemoryManager only reads this value, it never writes it.
  • Be aware that aggregate_stats() eagerly loads every on-disk profile — avoid calling it on a very large user base inside a hot request path.

Common mistakes

  • Assuming session counts or retention boosts update automatically — both require explicit calls you must add to your own application logic.
  • Constructing a bare UserProfile({}) directly (bypassing UserProfileManager) and calling .user_id or .get_preference() — raises KeyError due to missing required keys.
  • AIMemoryManager — the primary consumer, reading profile.memory_retention_boost and calling update_from_interaction on every save_interaction.
  • ContextBuilder — consumes profile.to_context_string() as the user_profile_text section.

Related pages