mechramc's picture
ChittiOS v1.0.0 β€” full app: identity + voice conversation + on-robot onboarding (vendored core + bundled voice)
549c3f0 verified
Raw
History Blame Contribute Delete
7.1 kB
"""Resolving the active identity into a context profile (spec Β§5, Β§6.3).
This is the join between Phase 5's identity runtime and Phase 4's conversation
loop. The runtime decides *who* is being talked to and hands out a
:class:`~chittios_core.identity.session.Session`; this turns that session into
the :class:`~chittios_core.context.profile.Profile` the conversation runs
under β€” the persona for the system prompt, the role for the tool gate, the
namespace for memory.
Pure, then stateful
-------------------
Two objects, because there are two jobs. :class:`ProfileResolver` is a pure
function of an identity: same session in, same profile out, no state, no I/O.
:class:`ContextManager` is the thin stateful shell the runtime drives β€” it
remembers the *current* profile and swaps it when the active identity changes,
so ``current_profile`` is always the one in force. Splitting them keeps the
policy testable in isolation and the state trivial.
Guest is the safe default
-------------------------
Before anyone is recognised, and any time recognition drops, the profile is the
guest one: no private memory, minimal tools, child-safe content. A
:class:`ContextManager` therefore *starts* on the guest profile rather than on
nothing, so there is never a window where the active persona is undefined.
Why a Protocol and not ``Session`` directly
--------------------------------------------
The resolver needs three facts about the active identity β€” is it a guest, what
band is it, what namespace does it own β€” and nothing else. Depending on the
whole :class:`~chittios_core.identity.session.Session` would couple context
resolution to fields it never reads and make it awkward to test with a fake.
:class:`ActiveIdentity` names exactly the three facts; ``Session`` satisfies it
structurally, and a test can satisfy it with three lines.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Final, Protocol, runtime_checkable
from chittios_core.context.profile import (
ADULT_PROFILE,
CHILD_PROFILE,
GUEST_PROFILE,
Profile,
)
from chittios_core.identity.types import AgeBand
@runtime_checkable
class ActiveIdentity(Protocol):
"""The slice of the active identity that context resolution reads.
Exactly the three facts a :class:`Profile` derives from.
:class:`~chittios_core.identity.session.Session` satisfies this structurally
β€” its ``is_guest``, ``age_band``, and ``memory_namespace`` line up β€” so the
runtime passes its session straight in, and a test passes any object with
the same three members.
"""
@property
def is_guest(self) -> bool:
"""Whether this identity was *not* backed by a biometric match.
The gate between the guest profile and a member profile. A guest is
anyone the robot could not verify, however friendly or familiar.
"""
...
@property
def age_band(self) -> AgeBand:
"""The identity's own band, selecting the child or adult persona.
This is the active speaker's band, not the room's. Content-policy
tightening for a child *present* while an adult speaks stays on the
session (``requires_child_safe_content``) and is deliberately not folded
into persona selection: the adult keeps their own voice, tools, and
memory, exactly as the session boundary keeps their scopes.
"""
...
@property
def memory_namespace(self) -> str:
"""The namespace whose private memory is in scope β€” the member's id."""
...
@dataclass(frozen=True, slots=True)
class ProfileResolver:
"""Turns an active identity into its context profile. Pure and injectable.
The three templates are fields, not hardcoded lookups, so a household can
supply its own personas without touching this logic::
resolver = ProfileResolver(adult=my_tuned_adult_profile)
The defaults are the age-band profiles from :mod:`chittios_core.context.profile`.
"""
adult: Profile = ADULT_PROFILE
child: Profile = CHILD_PROFILE
guest: Profile = GUEST_PROFILE
def resolve(self, active: ActiveIdentity | None) -> Profile:
"""Return the profile for ``active``; the guest profile for no one.
A missing identity (``None``) and a guest session resolve identically β€”
both mean "nobody is verified" β€” and both return the guest profile
*as-is*, ignoring any namespace the caller may have attached. That is
the guarantee behind "a guest never sees a member's memory": the guest
profile is a constant with an empty namespace, and no verified member's
id can be substituted into it here.
For a verified member, the persona and role come from the age-band
template and the memory namespace is re-keyed to *this* member's own
namespace, so the returned profile opens their memory and no one else's.
"""
if active is None or active.is_guest:
return self.guest
template = self.child if active.age_band is AgeBand.CHILD else self.adult
return replace(template, memory_namespace=active.memory_namespace)
class ContextManager:
"""Holds the profile currently in force and swaps it when identity changes.
The stateful counterpart to the pure resolver, and the object Phase 4 reads
``current_profile`` from when building a turn. It is driven by the identity
runtime: on each step where the active identity changed, call
:meth:`on_active_changed` with the new session.
Starts on the guest profile so the active persona is defined from the first
moment β€” before anyone is recognised, ChittiOS is already a guest-safe
companion rather than a companion with no persona at all.
"""
def __init__(
self,
resolver: ProfileResolver | None = None,
*,
initial: Profile = GUEST_PROFILE,
) -> None:
"""Build a manager.
Args:
resolver: The resolution policy. Defaults to a standard
:class:`ProfileResolver`; inject one to supply custom personas.
initial: The profile in force before any identity is seen. Defaults
to the guest profile, the least-privilege starting point.
"""
self._resolver: Final = resolver if resolver is not None else ProfileResolver()
self._current: Profile = initial
@property
def current_profile(self) -> Profile:
"""The profile in force right now β€” persona, role, and memory scope."""
return self._current
def on_active_changed(self, active: ActiveIdentity | None) -> Profile:
"""Switch the current profile to match a newly active identity.
Wired to the identity runtime's ``active_changed`` signal: when the
recognised person changes, the persona, tools, and memory change with
them. Returns the profile now in force so a caller can react to the swap
in one call.
"""
self._current = self._resolver.resolve(active)
return self._current