oc_chat
Loading...
Searching...
No Matches
session_models.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/session_models.py
4
5"""
6Pydantic models for the two JSON-sidecar files of a session.
7
8Purpose
9-------
10The session-meta.json and chat-log.json files have grown organically
11over the project's lifetime. Until now they were typed only as raw
12``dict`` at the persistence boundary, so corrupt or unexpected
13payloads (a missing required key, a type mismatch from an external
14edit, an old format from a pre-Phase J session) propagated all the
15way into the runtime as silent ``KeyError``s or ``AttributeError``s.
16
17This module gives those JSON shapes a validated home: every key has
18a declared type and a sensible default. ``extra="allow"`` keeps
19forward-compat with future keys we have not yet enumerated.
20
21Responsibilities
22----------------
23- :class:`SessionMetadata` — mirror of the dict returned by
24 :func:`oc_chat.core.persistence.load_session_metadata`.
25- :class:`ChatLog` — mirror of the dict returned by
26 :func:`oc_chat.core.persistence.load_chat_log`.
27- :class:`UsageBlock` — sub-model for the chat-log ``usage`` block.
28
29Diagnostics
30-----------
31Domain: OC_CHAT.CTX
32Levels:
33 L2 — validation failures (logged and surfaced as ValidationError)
34 L3 — model construction details
35 L4 — deep tracing (per-field validation, if ever enabled)
36
37Contracts
38---------
39- Every model is forgiving: all fields have defaults, so an old file
40 with missing keys validates cleanly with the defaults applied.
41- ``extra="allow"`` means unknown keys are kept on the model (and
42 round-tripped through ``model_dump()``) — newer formats never lose
43 data when read by older code.
44- ``ValidationError`` is only raised on hard type mismatches (e.g.
45 a list where a string is expected). Callers should catch it at the
46 JSON-load boundary.
47
48Dependencies
49------------
50- oc_diagnostics (structured debug logging via _dbg)
51- pydantic (BaseModel, Field, ConfigDict and field_validator, version 2 or later)
52- oc_chat.core.event_bootstrap (lazy; _valid_alias to validate the legacy event slugs)
53"""
54from __future__ import annotations
55
56from typing import Any
57
58from oc_diagnostics import _dbg
59from pydantic import BaseModel, ConfigDict, Field, field_validator
60
61_dbg("OC_CHAT.CTX", "module", "loaded", 4)
62
63
64class UsageBlock(BaseModel):
65 """Per-session aggregated usage counters embedded in the chat log."""
66
67 model_config = ConfigDict(extra="allow")
68
69 prompt_tokens: int = 0
70 completion_tokens: int = 0
71 total_tokens: int = 0
72 prompt_cache_hit_tokens: int = 0
73 prompt_cache_miss_tokens: int = 0
74
75
76class SessionMetadata(BaseModel):
77 """Validated mirror of the ``*_meta.json`` sidecar."""
78
79 model_config = ConfigDict(extra="allow")
80
81 session_name: str = ""
82 created: str = ""
83 last_modified: str = ""
84 model: str = ""
85 reasoning_effort: str = ""
86 system_prompt: str = ""
87 stats: dict[str, Any] = Field(default_factory=dict)
88 chat_logs: list[str] = Field(default_factory=list)
89 ui: dict[str, Any] = Field(default_factory=dict)
90 last_turn_max_prompt_tokens: int = 0
91 rollover_hint_shown: bool = False
92 # OCT-LINT: disable=no_hardcoded_secrets reason="Metadata field defaults and names, not secrets"
93 token_profile: str = "balanced"
94 max_react_iterations: int | None = None
95 cache_turn_snapshots: list[Any] = Field(default_factory=list)
96 active_plan_id: str | None = None
97 provider_name: str = Field(default="")
98 generation_options: dict = Field(default_factory=dict)
99 endpoint_name: str = Field(default="")
100 sandbox_root_override: str | None = None
101
102 # ── Plan 152 D8 — event-dir rename continuity (additive keys) ───────────
103 legacy_event_slugs: list[str] = Field(default_factory=list)
104 # OCT-LINT: disable=no_hardcoded_secrets reason="Metadata field defaults and names, not secrets"
105 """Ordered, validated, deduplicated name-slugs this session wrote events
106 under before earlier renames.
107
108 CONSTRAINED to the canonical ``event_bootstrap._slug`` grammar by
109 :meth:`_validate_legacy_event_slugs` — these are persisted PATH SEGMENTS, so
110 an unconstrained value would be untrusted path input.
111
112 Declaring it here is necessary but NOT sufficient:
113 ``load_session_metadata`` returns the RAW dict when model validation fails,
114 so this contract is bypassable. Every consumer re-validates at use — see
115 ``event_bootstrap.event_read_dirs`` and ``session._validated_event_slugs``.
116 """
117
118 superseded_by: str | None = None
119 """Set on the OLD record when a rename supersedes it (never deleted).
120
121 A rename writes both files under the NEW name while the old record stays on
122 disk with the SAME ``session_id``. Marking the old one at rename-commit time
123 makes it non-canonical by construction, so the current record can never lose
124 its identity — or its event history — to its own stale snapshot by load
125 order.
126 """
127
128 @field_validator("legacy_event_slugs", mode="before")
129 @classmethod
130 def _validate_legacy_event_slugs(cls, value: Any) -> list[str]:
131 """Keep only canonical slugs, in order, deduplicated. Never raises.
132
133 Fail-closed by dropping: an invalid alias is ignored rather than
134 rejecting the whole record, because a single bad alias must not make a
135 user's session unloadable.
136 """
137 if not isinstance(value, (list, tuple)):
138 return []
139 from oc_chat.core.event_bootstrap import _valid_alias
140 out: list[str] = []
141 for item in value:
142 if _valid_alias(item) and item not in out:
143 out.append(item)
144 return out
145
146
147class ChatLog(BaseModel):
148 """Validated mirror of the ``*_chat_log.json`` sidecar."""
149
150 model_config = ConfigDict(extra="allow")
151
152 session_name: str = ""
153 timestamp: str = ""
154 model: str = ""
155 reasoning_effort: str = ""
156 messages: list[dict[str, Any]] = Field(default_factory=list)
157 usage: UsageBlock = Field(default_factory=UsageBlock)
158
159
160__all__ = [
161 "ChatLog",
162 "SessionMetadata",
163 "UsageBlock",
164]
list[str] _validate_legacy_event_slugs(cls, Any value)