Skip to content

symfonic.services.conversation.values

values

Frozen value types shared by every conversation service.

These are the vocabulary the rest of the package speaks. Two of them encode a compatibility contract rather than a convenience:

  • :class:SessionIdentity reproduces the legacy engine's thread_id derivation exactly, because a transcript written before the migration must be read back under the key that wrote it, and
  • :class:SessionRecord renders to the legacy manager's five-key row, because a rollback reads those rows with the legacy reader.

The checkpoint-shaped values (:class:CheckpointRef, :class:SafeBoundaryMarker) live in :mod:symfonic.services.conversation.refs, which imports this module rather than the other way round: the checkpoint vocabulary is built on the thread-key vocabulary, never the reverse.

SessionIdentity dataclass

SessionIdentity(tenant_id: str, session_id: str, sub_tenant_id: str | None = None)

Tenant + sub-tenant + session, and the thread key they derive.

thread_id property

thread_id: str

The legacy derivation, character for character.

as_configurable

as_configurable(*, checkpoint_id: str | None = None) -> dict[str, Any]

The graph-runner config shape. checkpoint_id only when resuming.

Source code in src/symfonic/services/conversation/values.py
def as_configurable(self, *, checkpoint_id: str | None = None) -> dict[str, Any]:
    """The graph-runner config shape. ``checkpoint_id`` only when resuming."""
    configurable: dict[str, Any] = {"thread_id": self.thread_id}
    if checkpoint_id is not None:
        configurable["checkpoint_id"] = checkpoint_id
    return {"configurable": configurable}

SessionRecord dataclass

SessionRecord(session_id: str, tenant_id: str, created_at: datetime, last_active: datetime, message_count: int = 0, extra: tuple[tuple[str, Any], ...] = ())

One session row, in the migrated shape, with a legacy projection.

extra carries any key the legacy path wrote that this package does not model. Dropping it would make a rollback lossy, which is the one thing the bidirectional assumption forbids.

to_legacy_dict

to_legacy_dict() -> dict[str, Any]

Exactly the keys the legacy SessionManager wrote, same types.

Source code in src/symfonic/services/conversation/values.py
def to_legacy_dict(self) -> dict[str, Any]:
    """Exactly the keys the legacy ``SessionManager`` wrote, same types."""
    row: dict[str, Any] = {
        "session_id": self.session_id,
        "tenant_id": self.tenant_id,
        "created_at": self.created_at.isoformat(),
        "last_active": self.last_active.isoformat(),
        "message_count": self.message_count,
    }
    row.update(dict(self.extra))
    return row

TranscriptRow dataclass

TranscriptRow(index: int, role: TranscriptRole, content: str, message_id: str | None = None, timestamp: datetime | None = None)

One verbatim transcript row on the public surface.

index is the ordinal within the speaker-filtered view that produced it, and timestamp is checkpoint granularity — None when the source cannot resolve one. Both were load-bearing on the legacy surface.

tenant_segment_is_provable

tenant_segment_is_provable(thread_id: str) -> bool

Whether this key's tenant segment is provably the whole tenant id.

The derivation puts exactly two separators in a key. A key carrying more could have come from either an exempt session id (t:_:https://x) or a separator-bearing tenant the legacy path never validated (acme:eu:_:s1), and nothing in the key itself distinguishes the two. Positional parsing stays deterministic for both — but attribution does not, so this predicate gates naming a tenant, never reading the state.

Source code in src/symfonic/services/conversation/values.py
def tenant_segment_is_provable(thread_id: str) -> bool:
    """Whether this key's tenant segment is provably the *whole* tenant id.

    The derivation puts exactly two separators in a key. A key carrying more
    could have come from either an exempt session id (``t:_:https://x``) or a
    separator-bearing tenant the legacy path never validated
    (``acme:eu:_:s1``), and nothing in the key itself distinguishes the two.
    Positional parsing stays deterministic for both — but *attribution* does
    not, so this predicate gates naming a tenant, never reading the state.
    """
    return thread_id.count(_THREAD_SEPARATOR) == _DERIVED_SEPARATORS