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

1"""Mental model service — first-class facade over :class:`MentalModelStore`. 

2 

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. 

6 

7History 

8------- 

9 

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. 

18 

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""" 

24 

25from __future__ import annotations 

26 

27from dataclasses import replace 

28from datetime import UTC, datetime 

29from typing import TYPE_CHECKING 

30 

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 

34 

35if TYPE_CHECKING: 

36 from astrocyte.provider import MentalModelStore 

37 

38__all__ = ["MentalModel", "MentalModelService"] 

39 

40 

41class MentalModelService: 

42 """First-class facade over a :class:`MentalModelStore`. 

43 

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. 

49 

50 The store can be any :class:`~astrocyte.provider.MentalModelStore` 

51 implementation: ``InMemoryMentalModelStore`` for tests, 

52 ``PostgresMentalModelStore`` for production. 

53 """ 

54 

55 def __init__(self, store: "MentalModelStore") -> None: 

56 self._store = store 

57 

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. 

72 

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. 

76 

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. 

81 

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 

111 

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) 

119 

120 async def get(self, bank_id: str, model_id: str) -> MentalModel | None: 

121 return await self._store.get(model_id, bank_id) 

122 

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. 

132 

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) 

150 

151 async def delete(self, bank_id: str, model_id: str) -> bool: 

152 return await self._store.delete(model_id, bank_id) 

153 

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. 

162 

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)``. 

168 

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) 

183 

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. 

192 

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. 

199 

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))