Coverage for astrocyte/pipeline/directive_compile.py: 21%

90 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""M17 follow-up — directive-style preference compile. 

2 

3Distils raw preference-type facts into a small set of imperative 

4directives stored as :class:`MentalModel` rows with ``kind="directive"``. 

5Hindsight-parity: their ``mental_models.subtype='directive'`` rows are 

6user-curated hard rules that the answerer treats as authoritative; 

7auto-extracted preferences (subtype='preference') are advisory and 

8rarely surfaced verbatim in the system prompt. 

9 

10Why a separate "directive" tier rather than re-enabling the M14.6 

11preference surface that M14.7 reverted: 

12 

13- M14.6 surfaced every consolidated preference (typically 8-12 rows 

14 per document) on EVERY question. That bloated the system prompt and 

15 caused cross-category regression on LME (single-session-user dropped 

16 5/5 → 1/5 as the answerer was overwhelmed by personalization context 

17 on non-preference questions). 

18- Hindsight's fix is to keep the consolidation pass (subtype='preference', 

19 advisory) AND add a tight "directive" tier capped at 3-5 rows of the 

20 most-confident, most-stable preferences. Only the directive tier 

21 surfaces verbatim. Result: the answerer gets sparse, high-confidence 

22 hard rules, and the verbose preference tier stays in the store for 

23 on-demand recall. 

24 

25Algorithm (one LLM call per document): 

26 

271. Pull all ``fact_type='preference'`` PageIndexFacts for the document. 

282. If fewer than 2 preference facts, skip (no consolidation signal — 

29 single-mention preferences are too noisy to promote to directives). 

303. Send to an LLM with a prompt asking for **up to 5** imperative 

31 directives, each with title + content phrased as a short rule the 

32 answerer can follow verbatim. The prompt explicitly rejects vague 

33 or single-mention preferences and asks the model to abstain when no 

34 stable signal exists. 

354. Save each as a :class:`MentalModel` with ``kind='directive'``, 

36 ``scope=f'document:{document_id}'``. Idempotent — skip when the 

37 document already has directive models. 

38 

39The bench's :meth:`AstrocyteClient.get_user_profile` surfaces both 

40``kind='general'`` and ``kind='directive'`` models in the 

41``## User Profile`` block. ``kind='preference'`` rows remain in the 

42store but are NOT surfaced in the profile (M14.7 revert preserved). 

43 

44Cost: 1 LLM call per document (gpt-4o-mini), ~$0.01. Marginal. 

45 

46See: 

47- docs/_design/m17-pageindex-ingestion.md (Conversation-Engine bench) 

48- ``astrocyte.pipeline.preference_compile`` — sibling that produces the 

49 larger ``kind='preference'`` pool this directive pass distils from 

50""" 

51 

52from __future__ import annotations 

53 

54import json 

55import logging 

56import re 

57from datetime import datetime, timezone 

58from typing import TYPE_CHECKING 

59 

60from astrocyte.pipeline._json_tolerant import tolerant_json_loads_or_raise 

61from astrocyte.types import MentalModel, Message 

62 

63if TYPE_CHECKING: 

64 from astrocyte.provider import LLMProvider, MentalModelStore 

65 from astrocyte.types import PageIndexFact 

66 

67_logger = logging.getLogger("astrocyte.pipeline.directive_compile") 

68 

69 

70_COMPILE_PROMPT = """\ 

71You are extracting a SMALL SET of HARD-RULE DIRECTIVES from a user's \ 

72stated preferences in a conversation transcript. The directives will be \ 

73injected verbatim into an answering assistant's system prompt — treat \ 

74them as authoritative rules the assistant must honour when answering \ 

75follow-up questions. 

76 

77Output a JSON object with one key, ``directives``, containing an array \ 

78of AT MOST 5 directive objects. Each directive has: 

79- "title": 2-5 word noun phrase naming the directive (e.g. \ 

80"Breakfast preference", "Dance studio") 

81- "content": ONE imperative sentence the assistant can follow. \ 

82Use the form "Prefer X over Y because Z." or "Avoid X; the user dislikes \ 

83it." or "Recommend X for context Z." Always include the specific entity \ 

84the user named. Keep under 25 words. 

85- "source_fact_ids": array of strings — the contributing raw fact_ids. 

86 

87STRICT RULES: 

88- Emit AT MOST 5 directives. Quality over quantity. Sparse is better \ 

89than verbose: surface only the most CONFIDENT, STABLE preferences. 

90- {min_facts_rule} 

91- Phrase content imperatively (do/don't, prefer/avoid). The assistant \ 

92will read it as a rule. 

93- DO NOT include fleeting / weak statements ("kind of liked it"). \ 

94Only stable preferences with explicit positive/negative sentiment. 

95- {empty_input_rule} 

96 

97Input preferences (raw extractions): 

98{prefs_block} 

99 

100OUTPUT MUST BE VALID JSON. No prose around it. 

101""" 

102 

103_MIN_FACTS_RULE_MULTI = ( 

104 "A directive must be backed by AT LEAST 2 distinct raw preference " 

105 "facts in the input. Drop single-mention preferences entirely." 

106) 

107_MIN_FACTS_RULE_SINGLE = ( 

108 "This document has only ONE session — single-mention preferences " 

109 "are admissible because no repeat signal is possible. Emit a " 

110 "directive for any clearly-stated preference even if it appears " 

111 "only once, as long as it carries explicit positive/negative " 

112 'sentiment (e.g. "no screens after 9:30pm", "avoid Y", ' 

113 '"prefer X"). Skip only vague / fleeting statements.' 

114) 

115_EMPTY_RULE_MULTI = ( 

116 "If the input has fewer than 2 distinct stable preferences, " 

117 'return ``{{"directives": []}}``. Returning zero is correct ' 

118 "when the signal is absent." 

119) 

120_EMPTY_RULE_SINGLE = ( 

121 "If the input has no clearly-stated preferences, return " 

122 '``{{"directives": []}}``. Returning zero is correct when the ' 

123 "signal is absent." 

124) 

125 

126 

127_MAX_DIRECTIVES = 5 

128_MIN_FACTS_TO_COMPILE = 2 

129 

130 

131def _slugify(text: str) -> str: 

132 s = text.lower().strip() 

133 s = re.sub(r"[^a-z0-9\s-]", "", s) 

134 s = re.sub(r"\s+", "-", s) 

135 return s[:60] or "dir" 

136 

137 

138def _format_pref_for_prompt(fact: PageIndexFact) -> str: 

139 parts = [f"id={fact.id}"] 

140 if fact.entities: 

141 parts.append(f"entities={','.join(fact.entities[:6])}") 

142 return f"[{', '.join(parts)}] {fact.text}" 

143 

144 

145async def compile_directives_for_document( 

146 *, 

147 mental_model_store: MentalModelStore, 

148 bank_id: str, 

149 document_id: str, 

150 facts: list[PageIndexFact], 

151 provider: LLMProvider, 

152 model: str | None = None, 

153 max_directives: int = _MAX_DIRECTIVES, 

154 n_sessions: int | None = None, 

155) -> list[str]: 

156 """Consolidate preference facts → at most ``max_directives`` directive 

157 mental models. Idempotent: skips when directive models already exist 

158 for the document. 

159 

160 ``n_sessions`` is the document's session count (caller-supplied so 

161 we don't need a store round-trip). When ``n_sessions == 1`` we lower 

162 the minimum-facts threshold to 1: in a single-session document, the 

163 ≥2-distinct-facts heuristic silently drops every single-mention 

164 preference (e.g. "no screens after 9:30pm" said once), which is the 

165 only signal we have for that document. The cross-document corpus 

166 that motivated the ≥2 threshold doesn't apply when there IS only 

167 one session. 

168 

169 Returns the list of ``model_id`` values for rows persisted (or 

170 already-present on repeat call). 

171 """ 

172 pref_facts = [f for f in facts if f.fact_type == "preference"] 

173 # Fix 2 (conv-run-4): single-session docs lower the threshold to 1 

174 # so single-mention preferences make it to a directive instead of 

175 # being dropped silently. The ≥2 threshold is a noise-reduction 

176 # heuristic for multi-session corpora — irrelevant when only one 

177 # session's worth of preferences exists for this document. 

178 is_single_session = n_sessions is not None and n_sessions <= 1 

179 min_threshold = 1 if is_single_session else _MIN_FACTS_TO_COMPILE 

180 if len(pref_facts) < min_threshold: 

181 _logger.debug( 

182 "directive_compile: doc=%s has %d preference facts (<%d, single_session=%s), skip", 

183 document_id, 

184 len(pref_facts), 

185 min_threshold, 

186 is_single_session, 

187 ) 

188 return [] 

189 

190 # Idempotency — skip if directives already exist for this document scope. 

191 try: 

192 existing = await mental_model_store.list( 

193 bank_id, 

194 scope=f"document:{document_id}", 

195 kind="directive", 

196 ) 

197 except TypeError: 

198 all_for_scope = await mental_model_store.list( 

199 bank_id, 

200 scope=f"document:{document_id}", 

201 ) 

202 existing = [m for m in all_for_scope if getattr(m, "kind", None) == "directive"] 

203 if existing: 

204 return [m.model_id for m in existing] 

205 

206 prefs_block = "\n".join(_format_pref_for_prompt(f) for f in pref_facts) 

207 msg = _COMPILE_PROMPT.format( 

208 prefs_block=prefs_block, 

209 min_facts_rule=(_MIN_FACTS_RULE_SINGLE if is_single_session else _MIN_FACTS_RULE_MULTI), 

210 empty_input_rule=(_EMPTY_RULE_SINGLE if is_single_session else _EMPTY_RULE_MULTI), 

211 ) 

212 

213 try: 

214 completion = await provider.complete( 

215 [Message(role="user", content=msg)], 

216 model=model, 

217 max_tokens=1200, 

218 temperature=0.0, 

219 response_format={"type": "json_object"}, 

220 ) 

221 except Exception as exc: # noqa: BLE001 

222 _logger.warning( 

223 "directive_compile.llm: call failed doc=%s (%s)", 

224 document_id, 

225 exc, 

226 ) 

227 return [] 

228 

229 try: 

230 data = tolerant_json_loads_or_raise(completion.text) 

231 except (json.JSONDecodeError, AttributeError) as exc: 

232 _logger.warning( 

233 "directive_compile.parse: bad JSON doc=%s (%s) text=%r", 

234 document_id, 

235 exc, 

236 getattr(completion, "text", "")[:200], 

237 ) 

238 return [] 

239 

240 items = data.get("directives") or [] 

241 if not isinstance(items, list): 

242 return [] 

243 

244 now = datetime.now(tz=timezone.utc) 

245 scope = f"document:{document_id}" 

246 saved: list[str] = [] 

247 seen_ids: set[str] = set() 

248 

249 # M40 — index source facts so MM construction can attach per-source 

250 # evidence timestamps in conversation time (mirrors preference_compile). 

251 _fact_by_id = {getattr(f, "fact_id", None): f for f in pref_facts} 

252 

253 def _ts_for_fact_id(fid: str) -> datetime: 

254 f = _fact_by_id.get(fid) 

255 if f is None: 

256 return now 

257 return getattr(f, "mentioned_at", None) or getattr(f, "occurred_start", None) or now 

258 

259 for raw in items[:max_directives]: 

260 if not isinstance(raw, dict): 

261 continue 

262 title = str(raw.get("title", "")).strip() 

263 content = str(raw.get("content", "")).strip() 

264 if not title or not content: 

265 continue 

266 source_fact_ids = raw.get("source_fact_ids") or [] 

267 if not isinstance(source_fact_ids, list): 

268 source_fact_ids = [] 

269 source_fact_ids = [str(s) for s in source_fact_ids if isinstance(s, (str, int))] 

270 slug = _slugify(title) 

271 model_id = f"dir:{document_id[:8]}:{slug}" 

272 if model_id in seen_ids: 

273 continue 

274 seen_ids.add(model_id) 

275 

276 source_timestamps = [_ts_for_fact_id(sid) for sid in source_fact_ids] 

277 

278 mm = MentalModel( 

279 model_id=model_id, 

280 bank_id=bank_id, 

281 title=title, 

282 content=content, 

283 scope=scope, 

284 source_ids=source_fact_ids, 

285 revision=1, 

286 refreshed_at=now, 

287 kind="directive", 

288 source_timestamps=source_timestamps, 

289 ) 

290 try: 

291 await mental_model_store.upsert(mm, bank_id) 

292 saved.append(model_id) 

293 except Exception as exc: # noqa: BLE001 

294 _logger.warning( 

295 "directive_compile.save: doc=%s id=%s failed (%s)", 

296 document_id, 

297 model_id, 

298 exc, 

299 ) 

300 

301 _logger.info( 

302 "directive_compile: doc=%s produced %d directives from %d preference facts", 

303 document_id, 

304 len(saved), 

305 len(pref_facts), 

306 ) 

307 return saved