Coverage for astrocyte/pipeline/mental_model.py: 98%
42 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
1"""Mental model service — first-class facade over :class:`MentalModelStore`.
3Curated, refreshable saved-reflect summaries — the durable artifacts that
4outlive any single recall and serve as the authoritative summary when the
5recall pipeline elects to use the compiled layer.
7History
8-------
10This service originally piggybacked on :class:`WikiStore`, distinguishing
11mental models from wiki pages by ``kind="concept"`` and a
12``metadata["_mental_model"] = True`` discriminator. That pattern overloaded
13the wiki layer's lifecycle (revisions, lint issues, cross_links) for a
14fundamentally different concept and required undocumented metadata-key
15conventions. v1.x cut to a dedicated :class:`MentalModelStore` SPI with its
16own table — this service now takes a store via dependency injection and is
17agnostic to the underlying implementation.
19The :class:`MentalModel` dataclass lives in :mod:`astrocyte.types` so the
20SPI and consumer modules share a single source of truth; it's re-exported
21from this module for backward compatibility with callers that import it
22from :mod:`astrocyte.pipeline.mental_model`.
23"""
25from __future__ import annotations
27from dataclasses import replace
28from datetime import UTC, datetime
29from typing import TYPE_CHECKING
31# Re-export so existing ``from astrocyte.pipeline.mental_model import MentalModel``
32# imports keep working after the dataclass moved to ``astrocyte.types``.
33from astrocyte.types import MentalModel
35if TYPE_CHECKING:
36 from astrocyte.provider import MentalModelStore
38__all__ = ["MentalModel", "MentalModelService"]
41class MentalModelService:
42 """First-class facade over a :class:`MentalModelStore`.
44 All persistence behaviour (revision bumping, history archival, soft-
45 delete, scope filtering) is delegated to the configured store. This
46 service is the user-facing API surface — gateway endpoints and any
47 in-process caller should use it rather than invoking the store
48 directly.
50 The store can be any :class:`~astrocyte.provider.MentalModelStore`
51 implementation: ``InMemoryMentalModelStore`` for tests,
52 ``PostgresMentalModelStore`` for production.
53 """
55 def __init__(self, store: "MentalModelStore") -> None:
56 self._store = store
58 async def create(
59 self,
60 *,
61 bank_id: str,
62 model_id: str,
63 title: str,
64 content: str,
65 scope: str = "bank",
66 source_ids: list[str] | None = None,
67 kind: str = "general",
68 structured_doc: dict | None = None,
69 source_timestamps: list[datetime] | None = None,
70 ) -> MentalModel:
71 """Create or refresh a mental model.
73 Upsert semantics — calling create with an existing ``model_id``
74 bumps the revision and archives the prior version. Use
75 :meth:`refresh` when you specifically want to fail on missing.
77 ``kind`` and ``structured_doc`` (M21) let callers populate the
78 sub-type discriminator and the typed-document representation
79 in the single initial upsert — avoids a double-bump when
80 creating ``directive``-kind models or sections-shaped docs.
82 ``source_timestamps`` (M40) is an optional list of per-source
83 evidence timestamps, positionally aligned with ``source_ids``.
84 When omitted, the store persists ``NULL`` and trend computation
85 downstream falls back to ``refreshed_at`` as a single-point
86 anchor. When provided, must equal ``len(source_ids)`` in length
87 (the store drops mismatched arrays rather than persist a
88 wrong-alignment record).
89 """
90 # ``revision`` and ``refreshed_at`` are placeholders the store
91 # overwrites — pass anything valid here.
92 draft = MentalModel(
93 model_id=model_id,
94 bank_id=bank_id,
95 title=title,
96 content=content,
97 scope=scope,
98 source_ids=list(source_ids or []),
99 revision=0,
100 refreshed_at=datetime.now(UTC),
101 kind=kind,
102 structured_doc=structured_doc,
103 source_timestamps=source_timestamps,
104 )
105 await self._store.upsert(draft, bank_id)
106 stored = await self._store.get(model_id, bank_id)
107 # If the store implementation returns ``None`` between upsert and
108 # get (e.g. eventual consistency, hypothetical race), fall back to
109 # the draft we sent — at least the caller sees the data they wrote.
110 return stored or draft
112 async def list(
113 self,
114 bank_id: str,
115 *,
116 scope: str | None = None,
117 ) -> list[MentalModel]:
118 return await self._store.list(bank_id, scope=scope)
120 async def get(self, bank_id: str, model_id: str) -> MentalModel | None:
121 return await self._store.get(model_id, bank_id)
123 async def refresh(
124 self,
125 *,
126 bank_id: str,
127 model_id: str,
128 content: str,
129 source_ids: list[str] | None = None,
130 ) -> MentalModel | None:
131 """Refresh an EXISTING mental model. Returns ``None`` if missing.
133 Distinct from :meth:`create` in that it requires the model to
134 already exist — protects against silent creation on typo'd IDs.
135 Source IDs default to the existing set (use :meth:`create` to
136 replace with an empty list).
137 """
138 existing = await self._store.get(model_id, bank_id)
139 if existing is None:
140 return None
141 next_sources = list(source_ids) if source_ids is not None else list(existing.source_ids)
142 draft = replace(
143 existing,
144 content=content,
145 source_ids=next_sources,
146 refreshed_at=datetime.now(UTC),
147 )
148 await self._store.upsert(draft, bank_id)
149 return await self._store.get(model_id, bank_id)
151 async def delete(self, bank_id: str, model_id: str) -> bool:
152 return await self._store.delete(model_id, bank_id)
154 async def update_via_ops(
155 self,
156 *,
157 bank_id: str,
158 model_id: str,
159 operations: list[dict],
160 ) -> "tuple[MentalModel, dict] | None":
161 """M21 — apply structured delta operations to a mental model.
163 Thin wrapper around
164 :meth:`astrocyte.provider.MentalModelStore.update_via_ops`
165 that re-fetches the post-update model for the caller. Returns
166 ``None`` when the model doesn't exist; otherwise
167 ``(updated_model, applied_delta_summary)``.
169 ``applied_delta_summary`` is the audit trail from
170 :class:`~astrocyte.pipeline.delta_ops.AppliedDelta` —
171 ``{"applied": [...], "skipped": [...], "changed": bool}``
172 — exposed so callers (incl. the MCP tool) can surface which
173 ops landed and which the validator dropped.
174 """
175 result = await self._store.update_via_ops(model_id, bank_id, operations)
176 if result is None:
177 return None
178 _new_revision, summary = result
179 updated = await self._store.get(model_id, bank_id)
180 if updated is None:
181 return None
182 return (updated, summary)
184 async def refresh_from_sources(
185 self,
186 *,
187 bank_id: str,
188 model_id: str,
189 new_source_ids: list[str],
190 ) -> MentalModel | None:
191 """M28 — re-derive a model from an extended source set.
193 Thin wrapper around
194 :meth:`astrocyte.provider.MentalModelStore.refresh` that first
195 verifies the model exists (so the caller can distinguish
196 "missing model" from "store refresh returned None"). Returns
197 the refreshed :class:`MentalModel` or ``None`` if the model
198 doesn't exist.
200 Hindsight parity: this is the per-model analogue of the
201 ``mental_model_compile`` pipeline — after retain adds new
202 memories that pertain to an existing model, this re-runs the
203 compile against the extended ``source_ids`` and bumps the
204 revision.
205 """
206 existing = await self._store.get(model_id, bank_id)
207 if existing is None:
208 return None
209 return await self._store.refresh(model_id, bank_id, list(new_source_ids))