claude-smart 0.2.47 → 0.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  7. package/plugin/dashboard/package-lock.json +61 -390
  8. package/plugin/dashboard/package.json +2 -2
  9. package/plugin/pyproject.toml +1 -1
  10. package/plugin/uv.lock +1 -1
  11. package/plugin/vendor/reflexio/.env.example +7 -0
  12. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  13. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  14. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  15. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  16. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  17. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  18. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  19. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  20. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  21. package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
  22. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  23. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  24. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  25. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  26. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  27. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  28. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  29. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  30. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  31. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  32. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  33. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  34. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  35. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  36. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  37. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  38. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  39. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  40. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  41. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  42. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  43. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  44. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  45. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  46. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  47. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  48. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  49. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  50. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  51. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  52. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  53. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  54. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  55. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  56. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  57. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  58. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  59. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  60. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  61. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  62. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  63. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  64. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  65. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  66. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  68. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
  70. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  71. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
  72. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  73. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
  74. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  75. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  76. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  77. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  88. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  89. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
@@ -1,68 +1,12 @@
1
- """Profile and interaction CRUD + search mixins for SQLite storage."""
2
-
3
- import logging
4
- import sqlite3
5
- import uuid
6
- from concurrent.futures import ThreadPoolExecutor
7
- from typing import Any
8
-
9
- logger = logging.getLogger(__name__)
10
-
11
- from reflexio.models.api_schema.retriever_schema import (
12
- SearchInteractionRequest,
13
- SearchUserProfileRequest,
14
- )
15
- from reflexio.models.api_schema.service_schemas import (
16
- DeleteUserInteractionRequest,
17
- DeleteUserProfileRequest,
18
- Interaction,
19
- Status,
20
- UserProfile,
21
- )
22
- from reflexio.models.config_schema import SearchMode
23
- from reflexio.server.llm.providers.embedding_service_provider import (
24
- EmbeddingUnavailableError,
25
- )
26
-
27
- from ._base import (
28
- _TOMBSTONE_STATUS_VALUES,
29
- SQLiteStorageBase,
30
- _build_status_sql,
31
- _effective_search_mode,
32
- _epoch_now,
33
- _epoch_to_iso,
34
- _iso_now,
35
- _json_dumps,
36
- _row_to_interaction,
37
- _row_to_profile,
38
- _sanitize_fts_query,
39
- _true_rrf_merge,
40
- _vector_rank_rows,
41
- )
42
- from ._lineage import _GC_ELIGIBLE_STATUSES, _append_event_stmt
1
+ """Shared SQLite profile helpers.
43
2
 
3
+ All public profile methods live in the ``profiles`` package
4
+ (``ProfileStoreMixin``, ``InteractionStoreMixin``, ``ProfileSearchMixin``). Only
5
+ the shared module-level ``_build_tags_sql`` helper (used by both ProfileStore and
6
+ ProfileSearch) remains here.
7
+ """
44
8
 
45
- def _emit_hard_delete_profile(
46
- conn: sqlite3.Connection,
47
- *,
48
- org_id: str,
49
- entity_id: str,
50
- request_id: str,
51
- actor: str = "api",
52
- ) -> None:
53
- """Emit a single hard_delete lineage event for a profile entity."""
54
- _append_event_stmt(
55
- conn,
56
- org_id=org_id,
57
- entity_type="profile",
58
- entity_id=entity_id,
59
- op="hard_delete",
60
- prov="wasInvalidatedBy",
61
- source_ids=[],
62
- actor=actor,
63
- request_id=request_id,
64
- reason="erasure",
65
- )
9
+ from typing import Any
66
10
 
67
11
 
68
12
  def _build_tags_sql(alias: str, tags: list[str] | None) -> tuple[str, list[Any]]:
@@ -73,1221 +17,3 @@ def _build_tags_sql(alias: str, tags: list[str] | None) -> tuple[str, list[Any]]
73
17
  f"EXISTS (SELECT 1 FROM json_each({alias}.tags) WHERE value IN ({placeholders}))",
74
18
  list(tags),
75
19
  )
76
-
77
-
78
- def _escape_like_pattern(value: str) -> str:
79
- return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
80
-
81
-
82
- class ProfileMixin:
83
- """Mixin providing profile and interaction CRUD + search."""
84
-
85
- # Type hints for instance attributes/methods provided by SQLiteStorageBase via MRO
86
- _lock: Any
87
- conn: sqlite3.Connection
88
- org_id: str
89
- _execute: Any
90
- _fetchone: Any
91
- _fetchall: Any
92
- _get_embedding: Any
93
- _should_expand_documents: Any
94
- _expand_document: Any
95
- _fts_upsert: Any
96
- _fts_delete: Any
97
- _fts_upsert_profile: Any
98
- _fts_delete_profile: Any
99
- _vec_upsert: Any
100
- _vec_delete: Any
101
- _delete_profile_search_rows: Any
102
- _delete_in_chunks: Any
103
- _has_sqlite_vec: bool
104
- llm_client: Any
105
- embedding_model_name: str
106
- embedding_dimensions: int
107
- _subject_ref_for_user_id: Any
108
- _assert_subject_writable_locked: Any
109
-
110
- # ------------------------------------------------------------------
111
- # CRUD — Profiles
112
- # ------------------------------------------------------------------
113
-
114
- def _subject_ref_from_profile_row(self, row: sqlite3.Row) -> str:
115
- subject_ref = row["governance_subject_ref"]
116
- return (
117
- str(subject_ref)
118
- if subject_ref
119
- else self._subject_ref_for_user_id(row["user_id"])
120
- )
121
-
122
- def _assert_profile_writable_locked(
123
- self,
124
- profile_id: str,
125
- *,
126
- user_id: str | None = None,
127
- ) -> sqlite3.Row | None:
128
- if user_id is None:
129
- row = self.conn.execute(
130
- "SELECT user_id, governance_subject_ref FROM profiles WHERE profile_id = ?",
131
- (profile_id,),
132
- ).fetchone()
133
- else:
134
- row = self.conn.execute(
135
- "SELECT user_id, governance_subject_ref FROM profiles WHERE profile_id = ? AND user_id = ?",
136
- (profile_id, user_id),
137
- ).fetchone()
138
- if row is None:
139
- return None
140
- self._assert_subject_writable_locked(self._subject_ref_from_profile_row(row))
141
- return row
142
-
143
- @SQLiteStorageBase.handle_exceptions
144
- def get_all_profiles(
145
- self,
146
- limit: int = 100,
147
- status_filter: list[Status | None] | None = None,
148
- user_id: str | None = None,
149
- profile_id: str | None = None,
150
- query: str | None = None,
151
- source: str | None = None,
152
- profile_time_to_live: str | None = None,
153
- start_time: int | None = None,
154
- end_time: int | None = None,
155
- ) -> list[UserProfile]:
156
- if status_filter is None:
157
- status_filter = [None]
158
- frag, params = _build_status_sql(status_filter)
159
- conditions = [frag]
160
- if user_id:
161
- conditions.append("user_id = ?")
162
- params.append(user_id)
163
- if profile_id:
164
- conditions.append("LOWER(profile_id) = LOWER(?)")
165
- params.append(profile_id)
166
- if query:
167
- like = f"%{_escape_like_pattern(query.lower())}%"
168
- conditions.append(
169
- "(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(profile_id) LIKE ? ESCAPE '\\' OR LOWER(user_id) LIKE ? ESCAPE '\\')"
170
- )
171
- params.extend([like, like, like])
172
- if source is not None:
173
- conditions.append("source = ?")
174
- params.append(source)
175
- if profile_time_to_live:
176
- conditions.append("profile_time_to_live = ?")
177
- params.append(profile_time_to_live)
178
- if start_time is not None:
179
- conditions.append("last_modified_timestamp >= ?")
180
- params.append(start_time)
181
- if end_time is not None:
182
- conditions.append("last_modified_timestamp <= ?")
183
- params.append(end_time)
184
- sql = (
185
- f"SELECT * FROM profiles WHERE {' AND '.join(conditions)} "
186
- "ORDER BY last_modified_timestamp DESC LIMIT ?"
187
- )
188
- params.append(limit)
189
- return [_row_to_profile(r) for r in self._fetchall(sql, params)]
190
-
191
- @SQLiteStorageBase.handle_exceptions
192
- def get_user_profile(
193
- self,
194
- user_id: str,
195
- status_filter: list[Status | None] | None = None,
196
- tags: list[str] | None = None,
197
- profile_id: str | None = None,
198
- query: str | None = None,
199
- source: str | None = None,
200
- profile_time_to_live: str | None = None,
201
- start_time: int | None = None,
202
- end_time: int | None = None,
203
- ) -> list[UserProfile]:
204
- if status_filter is None:
205
- status_filter = [None]
206
- current_ts = _epoch_now()
207
- frag, params = _build_status_sql(status_filter)
208
- conditions = ["user_id = ?", "expiration_timestamp >= ?", frag]
209
- all_params: list[Any] = [user_id, current_ts, *params]
210
- if profile_id:
211
- conditions.append("LOWER(profile_id) = LOWER(?)")
212
- all_params.append(profile_id)
213
- if query:
214
- like = f"%{_escape_like_pattern(query.lower())}%"
215
- conditions.append(
216
- "(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(profile_id) LIKE ? ESCAPE '\\' OR LOWER(user_id) LIKE ? ESCAPE '\\')"
217
- )
218
- all_params.extend([like, like, like])
219
- if source is not None:
220
- conditions.append("source = ?")
221
- all_params.append(source)
222
- if profile_time_to_live:
223
- conditions.append("profile_time_to_live = ?")
224
- all_params.append(profile_time_to_live)
225
- if start_time is not None:
226
- conditions.append("last_modified_timestamp >= ?")
227
- all_params.append(start_time)
228
- if end_time is not None:
229
- conditions.append("last_modified_timestamp <= ?")
230
- all_params.append(end_time)
231
- tag_frag, tag_params = _build_tags_sql("profiles", tags)
232
- if tag_frag:
233
- conditions.append(tag_frag)
234
- all_params.extend(tag_params)
235
- sql = f"SELECT * FROM profiles WHERE {' AND '.join(conditions)}"
236
- return [_row_to_profile(r) for r in self._fetchall(sql, all_params)]
237
-
238
- @SQLiteStorageBase.handle_exceptions
239
- def add_user_profile(self, user_id: str, user_profiles: list[UserProfile]) -> None: # noqa: ARG002
240
- for profile in user_profiles:
241
- subject_ref = self._subject_ref_for_user_id(profile.user_id)
242
- with self._lock:
243
- self._assert_subject_writable_locked(subject_ref)
244
- embedding_text = "\n".join([profile.content, str(profile.custom_features)])
245
- if self._should_expand_documents():
246
- with ThreadPoolExecutor(max_workers=2) as executor:
247
- emb_future = executor.submit(self._get_embedding, embedding_text)
248
- exp_future = executor.submit(self._expand_document, profile.content)
249
- profile.embedding = emb_future.result(timeout=15)
250
- profile.expanded_terms = exp_future.result(timeout=15)
251
- else:
252
- profile.embedding = self._get_embedding(embedding_text)
253
- embedding = profile.embedding
254
- with self._lock:
255
- try:
256
- self.conn.execute("BEGIN IMMEDIATE")
257
- self._assert_subject_writable_locked(subject_ref)
258
- self.conn.execute(
259
- """INSERT OR REPLACE INTO profiles
260
- (profile_id, user_id, content, last_modified_timestamp,
261
- generated_from_request_id, profile_time_to_live,
262
- expiration_timestamp, custom_features, embedding, source,
263
- status, extractor_names, expanded_terms,
264
- source_span, notes, reader_angle, tags, source_interaction_ids, created_at,
265
- merged_into, superseded_by, governance_subject_ref)
266
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
267
- (
268
- profile.profile_id,
269
- profile.user_id,
270
- profile.content,
271
- profile.last_modified_timestamp,
272
- profile.generated_from_request_id,
273
- profile.profile_time_to_live.value,
274
- profile.expiration_timestamp,
275
- _json_dumps(profile.custom_features),
276
- _json_dumps(profile.embedding),
277
- profile.source,
278
- profile.status.value if profile.status else None,
279
- _json_dumps(profile.extractor_names),
280
- profile.expanded_terms,
281
- profile.source_span,
282
- profile.notes,
283
- profile.reader_angle,
284
- _json_dumps(profile.tags),
285
- _json_dumps(profile.source_interaction_ids),
286
- _iso_now(),
287
- profile.merged_into,
288
- profile.superseded_by,
289
- subject_ref,
290
- ),
291
- )
292
- self.conn.commit()
293
- except Exception:
294
- self.conn.rollback()
295
- raise
296
- fts_parts = [profile.content or ""]
297
- if profile.custom_features:
298
- fts_parts.extend(str(v) for v in profile.custom_features.values() if v)
299
- if profile.expanded_terms:
300
- fts_parts.append(profile.expanded_terms)
301
- self._fts_upsert_profile(profile.profile_id, " ".join(fts_parts))
302
- # Sync vec table — look up implicit rowid via primary key
303
- row = self._fetchone(
304
- "SELECT rowid FROM profiles WHERE profile_id = ?",
305
- (profile.profile_id,),
306
- )
307
- if row and embedding:
308
- self._vec_upsert("profiles_vec", row["rowid"], embedding)
309
-
310
- @SQLiteStorageBase.handle_exceptions
311
- def update_user_profile_by_id(
312
- self, user_id: str, profile_id: str, new_profile: UserProfile
313
- ) -> None:
314
- """Replace a profile's content in-place and emit a revise lineage event.
315
-
316
- Each call generates a fresh request_id so every edit is a distinct audit
317
- event (not collapsed by the idempotency key). The UPDATE, lineage event,
318
- FTS sync, and vec sync are all executed inside a single lock acquisition;
319
- self._lock is an RLock so the inner _fts_upsert_profile/_vec_upsert calls
320
- that re-acquire it are safe.
321
- """
322
- current_ts = _epoch_now()
323
- with self._lock:
324
- row = self.conn.execute(
325
- "SELECT user_id, governance_subject_ref FROM profiles WHERE user_id = ? AND profile_id = ? AND expiration_timestamp >= ?",
326
- (user_id, profile_id, current_ts),
327
- ).fetchone()
328
- if not row:
329
- logger.warning("User profile not found for user id: %s", user_id)
330
- return
331
- self._assert_subject_writable_locked(
332
- self._subject_ref_from_profile_row(row)
333
- )
334
- embedding = self._get_embedding(
335
- "\n".join([new_profile.content, str(new_profile.custom_features)])
336
- )
337
- new_profile.embedding = embedding
338
- with self._lock:
339
- if (
340
- self._assert_profile_writable_locked(profile_id, user_id=user_id)
341
- is None
342
- ):
343
- logger.warning("User profile not found for user id: %s", user_id)
344
- return
345
- cur = self.conn.execute(
346
- """UPDATE profiles SET content=?, last_modified_timestamp=?,
347
- generated_from_request_id=?, profile_time_to_live=?,
348
- expiration_timestamp=?, custom_features=?, embedding=?,
349
- source=?, status=?, extractor_names=?, expanded_terms=?,
350
- source_span=?, notes=?, reader_angle=?, tags=?, source_interaction_ids=?
351
- WHERE profile_id=?""",
352
- (
353
- new_profile.content,
354
- new_profile.last_modified_timestamp,
355
- new_profile.generated_from_request_id,
356
- new_profile.profile_time_to_live.value,
357
- new_profile.expiration_timestamp,
358
- _json_dumps(new_profile.custom_features),
359
- _json_dumps(new_profile.embedding),
360
- new_profile.source,
361
- new_profile.status.value if new_profile.status else None,
362
- _json_dumps(new_profile.extractor_names),
363
- new_profile.expanded_terms,
364
- new_profile.source_span,
365
- new_profile.notes,
366
- new_profile.reader_angle,
367
- _json_dumps(new_profile.tags),
368
- _json_dumps(new_profile.source_interaction_ids),
369
- profile_id,
370
- ),
371
- )
372
- if cur.rowcount > 0:
373
- _append_event_stmt(
374
- self.conn,
375
- org_id=self.org_id,
376
- entity_type="profile",
377
- entity_id=str(profile_id),
378
- op="revise",
379
- prov="wasRevisionOf",
380
- source_ids=[],
381
- actor="api",
382
- request_id=uuid.uuid4().hex,
383
- reason="in-place update",
384
- )
385
- self.conn.commit()
386
- fts_parts = [new_profile.content or ""]
387
- if new_profile.custom_features:
388
- fts_parts.extend(
389
- str(v) for v in new_profile.custom_features.values() if v
390
- )
391
- if new_profile.expanded_terms:
392
- fts_parts.append(new_profile.expanded_terms)
393
- self._fts_upsert_profile(profile_id, " ".join(fts_parts))
394
- rowid_row = self._fetchone(
395
- "SELECT rowid FROM profiles WHERE profile_id = ?", (profile_id,)
396
- )
397
- if rowid_row and embedding:
398
- self._vec_upsert("profiles_vec", rowid_row["rowid"], embedding)
399
-
400
- @SQLiteStorageBase.handle_exceptions
401
- def update_user_profile_tags(
402
- self, user_id: str, profile_id: str, tags: list[str]
403
- ) -> None:
404
- with self._lock:
405
- if (
406
- self._assert_profile_writable_locked(profile_id, user_id=user_id)
407
- is None
408
- ):
409
- return
410
- self.conn.execute(
411
- "UPDATE profiles SET tags=? WHERE user_id=? AND profile_id=?",
412
- (_json_dumps(tags), user_id, profile_id),
413
- )
414
- self.conn.commit()
415
-
416
- @SQLiteStorageBase.handle_exceptions
417
- def delete_user_profile(self, request: DeleteUserProfileRequest) -> None:
418
- # Atomic: fts + vec + row + lineage in ONE lock/commit to prevent rowid reuse
419
- # race. profiles uses implicit (reusable) rowid keyed by TEXT PK — a cleanup
420
- # running after commit could race with a concurrent INSERT reusing the freed
421
- # rowid and delete the NEW profile's vec row. (#196)
422
- with self._lock:
423
- rowid_row = self.conn.execute(
424
- "SELECT rowid FROM profiles WHERE user_id = ? AND profile_id = ?",
425
- (request.user_id, request.profile_id),
426
- ).fetchone()
427
- if rowid_row is None:
428
- return
429
- self.conn.execute(
430
- "DELETE FROM profiles_fts WHERE profile_id = ?",
431
- (request.profile_id,),
432
- )
433
- if self._has_sqlite_vec and rowid_row:
434
- self.conn.execute(
435
- "DELETE FROM profiles_vec WHERE rowid = ?",
436
- (rowid_row["rowid"],),
437
- )
438
- cur = self.conn.execute(
439
- "DELETE FROM profiles WHERE user_id = ? AND profile_id = ?",
440
- (request.user_id, request.profile_id),
441
- )
442
- if cur.rowcount > 0:
443
- _emit_hard_delete_profile(
444
- self.conn,
445
- org_id=self.org_id,
446
- entity_id=str(request.profile_id),
447
- request_id=uuid.uuid4().hex,
448
- )
449
- self.conn.commit()
450
-
451
- @SQLiteStorageBase.handle_exceptions
452
- def delete_all_profiles_for_user(self, user_id: str) -> None:
453
- # Atomic: fts + vec + row + lineage in ONE lock/commit — rowid reuse race
454
- # prevention (see delete_user_profile comment, #196).
455
- batch_request_id = uuid.uuid4().hex
456
- with self._lock:
457
- rows = self.conn.execute(
458
- "SELECT rowid, profile_id FROM profiles WHERE user_id = ?", (user_id,)
459
- ).fetchall()
460
- if not rows:
461
- return
462
- pids = [r["profile_id"] for r in rows]
463
- rowids = [r["rowid"] for r in rows]
464
- self._delete_in_chunks("profiles_fts", "profile_id", pids)
465
- if self._has_sqlite_vec and rowids:
466
- self._delete_in_chunks("profiles_vec", "rowid", rowids)
467
- self.conn.execute("DELETE FROM profiles WHERE user_id = ?", (user_id,))
468
- for pid in pids:
469
- _emit_hard_delete_profile(
470
- self.conn,
471
- org_id=self.org_id,
472
- entity_id=str(pid),
473
- request_id=batch_request_id,
474
- )
475
- self.conn.commit()
476
-
477
- @SQLiteStorageBase.handle_exceptions
478
- def delete_all_profiles(self) -> None:
479
- # Also wipe profiles_vec (full-wipe variant of the rowid-race fix, #196).
480
- batch_request_id = uuid.uuid4().hex
481
- with self._lock:
482
- pids = [
483
- r["profile_id"]
484
- for r in self.conn.execute("SELECT profile_id FROM profiles").fetchall()
485
- ]
486
- for pid in pids:
487
- _emit_hard_delete_profile(
488
- self.conn,
489
- org_id=self.org_id,
490
- entity_id=str(pid),
491
- request_id=batch_request_id,
492
- )
493
- self.conn.execute("DELETE FROM profiles_fts")
494
- if self._has_sqlite_vec:
495
- self.conn.execute("DELETE FROM profiles_vec")
496
- self.conn.execute("DELETE FROM profiles")
497
- self.conn.commit()
498
-
499
- @SQLiteStorageBase.handle_exceptions
500
- def count_all_profiles(self) -> int:
501
- row = self._fetchone("SELECT COUNT(*) as cnt FROM profiles")
502
- return row["cnt"] if row else 0
503
-
504
- @SQLiteStorageBase.handle_exceptions
505
- def update_all_profiles_status(
506
- self,
507
- old_status: Status | None,
508
- new_status: Status | None,
509
- user_ids: list[str] | None = None,
510
- ) -> int:
511
- new_val = new_status.value if new_status else None
512
- now_ts = _epoch_now()
513
- old_val_str = old_status.value if old_status else "None"
514
- new_val_str = new_status.value if new_status else "None"
515
- reason = f"{old_val_str}->{new_val_str}"
516
-
517
- if old_status is None or (
518
- hasattr(old_status, "value") and old_status.value is None
519
- ):
520
- where = "status IS NULL"
521
- select_params: list[Any] = []
522
- else:
523
- where = "status = ?"
524
- select_params = [old_status.value]
525
-
526
- extra_params: list[Any] = []
527
- if user_ids is not None:
528
- placeholders = ",".join("?" for _ in user_ids)
529
- where += f" AND user_id IN ({placeholders})"
530
- extra_params.extend(user_ids)
531
-
532
- # Set retired_at = now when transitioning to a GC-eligible status; clear to NULL otherwise.
533
- retired_at_val = now_ts if new_val in _GC_ELIGIBLE_STATUSES else None
534
-
535
- batch_request_id = uuid.uuid4().hex
536
- with self._lock:
537
- affected = list(
538
- self.conn.execute(
539
- f"SELECT profile_id, user_id, governance_subject_ref FROM profiles WHERE {where}",
540
- select_params + extra_params,
541
- ).fetchall()
542
- )
543
- for row in affected:
544
- self._assert_subject_writable_locked(
545
- self._subject_ref_from_profile_row(row)
546
- )
547
- cur = self.conn.execute(
548
- f"UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? WHERE {where}",
549
- [new_val, now_ts, retired_at_val] + select_params + extra_params,
550
- )
551
- from_val = old_status.value if old_status else None
552
- to_val = new_status.value if new_status else None
553
- for row in affected:
554
- pid = row["profile_id"]
555
- _append_event_stmt(
556
- self.conn,
557
- org_id=self.org_id,
558
- entity_type="profile",
559
- entity_id=str(pid),
560
- op="status_change",
561
- prov="wasInvalidatedBy",
562
- source_ids=[],
563
- actor="api",
564
- request_id=batch_request_id,
565
- reason=reason,
566
- from_status=from_val,
567
- to_status=to_val,
568
- status_namespace="lifecycle_status",
569
- )
570
- self.conn.commit()
571
- return cur.rowcount
572
-
573
- @SQLiteStorageBase.handle_exceptions
574
- def get_profiles_by_ids(
575
- self,
576
- user_id: str,
577
- profile_ids: list[str],
578
- status_filter: list[Status | None] | None = None,
579
- ) -> list[UserProfile]:
580
- if not profile_ids:
581
- return []
582
- if status_filter is None:
583
- status_filter = [None]
584
- current_ts = _epoch_now()
585
- frag, sparams = _build_status_sql(status_filter)
586
- ph = ",".join("?" for _ in profile_ids)
587
- sql = (
588
- f"SELECT * FROM profiles "
589
- f"WHERE user_id = ? AND profile_id IN ({ph}) "
590
- f"AND expiration_timestamp >= ? AND {frag}"
591
- )
592
- params: list[Any] = [user_id, *profile_ids, current_ts, *sparams]
593
- return [_row_to_profile(r) for r in self._fetchall(sql, params)]
594
-
595
- @SQLiteStorageBase.handle_exceptions
596
- def get_profile_by_id(
597
- self, profile_id: str, *, include_tombstones: bool = False
598
- ) -> UserProfile | None:
599
- """Fetch a single profile by primary key.
600
-
601
- Args:
602
- profile_id: The profile's primary key.
603
- include_tombstones: When False (default), MERGED/SUPERSEDED profiles
604
- return None. Set to True for lineage resolution (resolve_current).
605
-
606
- Returns:
607
- The UserProfile if found and not filtered, otherwise None.
608
- """
609
- sql = "SELECT * FROM profiles WHERE profile_id = ?"
610
- if not include_tombstones:
611
- sql += " AND (status IS NULL OR status NOT IN (?, ?))"
612
- row = self._fetchone(sql, (profile_id, *_TOMBSTONE_STATUS_VALUES))
613
- else:
614
- row = self._fetchone(sql, (profile_id,))
615
- return _row_to_profile(row) if row else None
616
-
617
- @SQLiteStorageBase.handle_exceptions
618
- def get_distinct_generated_from_request_ids(self) -> list[str]:
619
- """Return DISTINCT non-empty generated_from_request_id values, including tombstones.
620
-
621
- Returns:
622
- list[str]: Distinct non-empty ``generated_from_request_id`` values.
623
- """
624
- rows = self._fetchall(
625
- "SELECT DISTINCT generated_from_request_id FROM profiles"
626
- " WHERE generated_from_request_id IS NOT NULL"
627
- " AND generated_from_request_id != ''",
628
- (),
629
- )
630
- return [row[0] for row in rows]
631
-
632
- @SQLiteStorageBase.handle_exceptions
633
- def get_profiles_by_generated_from_request_id(
634
- self,
635
- request_id: str,
636
- ) -> list[UserProfile]:
637
- """Return all profiles for a generated_from_request_id, including tombstones.
638
-
639
- Args:
640
- request_id (str): The generated_from_request_id to filter on.
641
-
642
- Returns:
643
- list[UserProfile]: All matching profiles (any status).
644
- """
645
- rows = self._fetchall(
646
- "SELECT * FROM profiles WHERE generated_from_request_id = ?",
647
- (request_id,),
648
- )
649
- return [_row_to_profile(r) for r in rows]
650
-
651
- def get_all_generated_profiles(self) -> list[UserProfile]:
652
- """All profiles (any status) with a non-empty generated_from_request_id."""
653
- rows = self._fetchall(
654
- "SELECT * FROM profiles "
655
- "WHERE generated_from_request_id IS NOT NULL "
656
- "AND generated_from_request_id <> ''",
657
- )
658
- return [_row_to_profile(r) for r in rows]
659
-
660
- @SQLiteStorageBase.handle_exceptions
661
- def archive_profile_by_id(self, user_id: str, profile_id: str) -> bool:
662
- with self._lock:
663
- if (
664
- self._assert_profile_writable_locked(profile_id, user_id=user_id)
665
- is None
666
- ):
667
- return False
668
- now_ts = _epoch_now()
669
- cur = self.conn.execute(
670
- "UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? "
671
- "WHERE profile_id = ? AND user_id = ? AND status IS NULL",
672
- (Status.ARCHIVED.value, now_ts, now_ts, profile_id, user_id),
673
- )
674
- if cur.rowcount > 0:
675
- _append_event_stmt(
676
- self.conn,
677
- org_id=self.org_id,
678
- entity_type="profile",
679
- entity_id=str(profile_id),
680
- op="status_change",
681
- prov="wasInvalidatedBy",
682
- source_ids=[],
683
- actor="api",
684
- request_id=uuid.uuid4().hex,
685
- reason="None->archived",
686
- from_status=None,
687
- to_status="archived",
688
- status_namespace="lifecycle_status",
689
- )
690
- self.conn.commit()
691
- return cur.rowcount > 0
692
-
693
- @SQLiteStorageBase.handle_exceptions
694
- def supersede_profiles_by_ids(
695
- self,
696
- user_id: str,
697
- profile_ids: list[str],
698
- request_id: str,
699
- ) -> list[str]:
700
- """Soft-delete profiles by setting status to SUPERSEDED, emitting set-based lineage.
701
-
702
- For each matching id (user_id scoped, currently CURRENT), updates status to
703
- SUPERSEDED and emits one ``status_change`` event under the shared ``request_id``.
704
- Atomic: one ``conn.commit()`` at the end, guarded on rowcount per id.
705
- FTS/vec rows are NOT removed — reads exclude tombstones by status filter.
706
-
707
- Args:
708
- user_id (str): Owning user id.
709
- profile_ids (list[str]): Profile ids to supersede.
710
- request_id (str): Shared request id for all emitted lineage events.
711
-
712
- Returns:
713
- list[str]: The profile ids actually superseded by this call, in input order
714
- (already-superseded or absent ids are omitted).
715
- """
716
- if not profile_ids:
717
- return []
718
- if not request_id:
719
- raise ValueError("request_id must be non-empty for supersede")
720
- now_ts = _epoch_now()
721
- # Eligibility: CURRENT (NULL) or PENDING — the two live statuses dedup can target.
722
- eligible = (None, Status.PENDING.value)
723
- committed_ids: list[str] = []
724
- with self._lock:
725
- for pid in profile_ids:
726
- # Read current status for from_status derivation (user_id scoped)
727
- row = self.conn.execute(
728
- "SELECT status, user_id, governance_subject_ref FROM profiles WHERE profile_id = ? AND user_id = ?",
729
- (pid, user_id),
730
- ).fetchone()
731
- if row is None:
732
- continue
733
- self._assert_subject_writable_locked(
734
- self._subject_ref_from_profile_row(row)
735
- )
736
- old_status_val = (
737
- row[0] if isinstance(row, (tuple, list)) else row["status"]
738
- )
739
- if old_status_val not in eligible:
740
- continue
741
- cur = self.conn.execute(
742
- "UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? "
743
- "WHERE profile_id = ? AND user_id = ? "
744
- "AND (status IS NULL OR status = ?)",
745
- (
746
- Status.SUPERSEDED.value,
747
- now_ts,
748
- now_ts,
749
- pid,
750
- user_id,
751
- Status.PENDING.value,
752
- ),
753
- )
754
- if cur.rowcount > 0:
755
- _append_event_stmt(
756
- self.conn,
757
- org_id=self.org_id,
758
- entity_type="profile",
759
- entity_id=str(pid),
760
- op="status_change",
761
- prov="wasInvalidatedBy",
762
- source_ids=[],
763
- actor="dedup",
764
- request_id=request_id,
765
- reason=f"{old_status_val}->superseded",
766
- from_status=old_status_val,
767
- to_status=Status.SUPERSEDED.value,
768
- status_namespace="lifecycle_status",
769
- )
770
- committed_ids.append(pid)
771
- self.conn.commit()
772
- return committed_ids
773
-
774
- @SQLiteStorageBase.handle_exceptions
775
- def delete_all_profiles_by_status(self, status: Status) -> int:
776
- # Atomic: fts + vec + row + lineage in ONE lock/commit — rowid reuse race
777
- # prevention (see delete_user_profile comment, #196).
778
- batch_request_id = uuid.uuid4().hex
779
- with self._lock:
780
- rows = self.conn.execute(
781
- "SELECT rowid, profile_id FROM profiles WHERE status = ?",
782
- (status.value,),
783
- ).fetchall()
784
- if not rows:
785
- return 0
786
- pids = [r["profile_id"] for r in rows]
787
- rowids = [r["rowid"] for r in rows]
788
- self._delete_in_chunks("profiles_fts", "profile_id", pids)
789
- if self._has_sqlite_vec and rowids:
790
- self._delete_in_chunks("profiles_vec", "rowid", rowids)
791
- ph = ",".join("?" for _ in pids)
792
- cur = self.conn.execute(
793
- f"DELETE FROM profiles WHERE profile_id IN ({ph})",
794
- pids, # noqa: S608
795
- )
796
- for pid in pids:
797
- _emit_hard_delete_profile(
798
- self.conn,
799
- org_id=self.org_id,
800
- entity_id=str(pid),
801
- request_id=batch_request_id,
802
- )
803
- self.conn.commit()
804
- return cur.rowcount
805
-
806
- @SQLiteStorageBase.handle_exceptions
807
- def get_user_ids_with_status(self, status: Status | None) -> list[str]:
808
- if status is None or (hasattr(status, "value") and status.value is None):
809
- rows = self._fetchall(
810
- "SELECT DISTINCT user_id FROM profiles WHERE status IS NULL"
811
- )
812
- else:
813
- rows = self._fetchall(
814
- "SELECT DISTINCT user_id FROM profiles WHERE status = ?",
815
- (status.value,),
816
- )
817
- return [r["user_id"] for r in rows]
818
-
819
- @SQLiteStorageBase.handle_exceptions
820
- def delete_profiles_by_ids(
821
- self, profile_ids: list[str], *, emit_hard_delete: bool = True
822
- ) -> int:
823
- if not profile_ids:
824
- return 0
825
- # Atomic: fts + vec + row + lineage in ONE lock/commit — rowid reuse race
826
- # prevention (see delete_user_profile comment, #196).
827
- ph = ",".join("?" for _ in profile_ids)
828
- batch_request_id = uuid.uuid4().hex
829
- with self._lock:
830
- pre_rows = self.conn.execute(
831
- f"SELECT rowid, profile_id FROM profiles WHERE profile_id IN ({ph})",
832
- profile_ids,
833
- ).fetchall()
834
- if not pre_rows:
835
- return 0
836
- existing = [r["profile_id"] for r in pre_rows]
837
- rowids = [r["rowid"] for r in pre_rows]
838
- self._delete_in_chunks("profiles_fts", "profile_id", existing)
839
- if self._has_sqlite_vec and rowids:
840
- self._delete_in_chunks("profiles_vec", "rowid", rowids)
841
- cur = self.conn.execute(
842
- f"DELETE FROM profiles WHERE profile_id IN ({ph})",
843
- profile_ids, # noqa: S608
844
- )
845
- if emit_hard_delete:
846
- for pid in existing:
847
- _emit_hard_delete_profile(
848
- self.conn,
849
- org_id=self.org_id,
850
- entity_id=str(pid),
851
- request_id=batch_request_id,
852
- actor="system",
853
- )
854
- self.conn.commit()
855
- return cur.rowcount
856
-
857
- # ------------------------------------------------------------------
858
- # CRUD — Interactions
859
- # ------------------------------------------------------------------
860
-
861
- @SQLiteStorageBase.handle_exceptions
862
- def get_all_interactions(self, limit: int = 100) -> list[Interaction]:
863
- rows = self._fetchall(
864
- "SELECT * FROM interactions ORDER BY created_at DESC LIMIT ?", (limit,)
865
- )
866
- return [_row_to_interaction(r) for r in rows]
867
-
868
- @SQLiteStorageBase.handle_exceptions
869
- def get_user_interaction(self, user_id: str) -> list[Interaction]:
870
- rows = self._fetchall(
871
- "SELECT * FROM interactions WHERE user_id = ?", (user_id,)
872
- )
873
- return [_row_to_interaction(r) for r in rows]
874
-
875
- @SQLiteStorageBase.handle_exceptions
876
- def add_user_interaction(self, user_id: str, interaction: Interaction) -> None: # noqa: ARG002
877
- embedding = self._get_embedding(
878
- f"{interaction.content}\n{interaction.user_action_description}"
879
- )
880
- interaction.embedding = embedding
881
- self._insert_interaction(interaction)
882
-
883
- def _insert_interaction(self, interaction: Interaction) -> int:
884
- created_at_iso = _epoch_to_iso(interaction.created_at)
885
- subject_ref = self._subject_ref_for_user_id(interaction.user_id)
886
- with self._lock:
887
- try:
888
- self.conn.execute("BEGIN IMMEDIATE")
889
- self._assert_subject_writable_locked(subject_ref)
890
- if interaction.interaction_id:
891
- self.conn.execute(
892
- """INSERT OR REPLACE INTO interactions
893
- (interaction_id, user_id, content, request_id, created_at,
894
- role, user_action, user_action_description,
895
- interacted_image_url, image_encoding, shadow_content,
896
- expert_content, tools_used, citations, embedding, governance_subject_ref)
897
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
898
- (
899
- interaction.interaction_id,
900
- interaction.user_id,
901
- interaction.content,
902
- interaction.request_id,
903
- created_at_iso,
904
- interaction.role,
905
- interaction.user_action.value,
906
- interaction.user_action_description,
907
- interaction.interacted_image_url,
908
- interaction.image_encoding,
909
- interaction.shadow_content,
910
- interaction.expert_content,
911
- _json_dumps(
912
- [t.model_dump() for t in interaction.tools_used]
913
- ),
914
- _json_dumps(
915
- [c.model_dump() for c in interaction.citations]
916
- ),
917
- _json_dumps(interaction.embedding),
918
- subject_ref,
919
- ),
920
- )
921
- iid = interaction.interaction_id
922
- else:
923
- cur = self.conn.execute(
924
- """INSERT INTO interactions
925
- (user_id, content, request_id, created_at,
926
- role, user_action, user_action_description,
927
- interacted_image_url, image_encoding, shadow_content,
928
- expert_content, tools_used, citations, embedding, governance_subject_ref)
929
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
930
- (
931
- interaction.user_id,
932
- interaction.content,
933
- interaction.request_id,
934
- created_at_iso,
935
- interaction.role,
936
- interaction.user_action.value,
937
- interaction.user_action_description,
938
- interaction.interacted_image_url,
939
- interaction.image_encoding,
940
- interaction.shadow_content,
941
- interaction.expert_content,
942
- _json_dumps(
943
- [t.model_dump() for t in interaction.tools_used]
944
- ),
945
- _json_dumps(
946
- [c.model_dump() for c in interaction.citations]
947
- ),
948
- _json_dumps(interaction.embedding),
949
- subject_ref,
950
- ),
951
- )
952
- iid = cur.lastrowid or 0
953
- interaction.interaction_id = iid
954
- self.conn.commit()
955
- except Exception:
956
- self.conn.rollback()
957
- raise
958
- # Update FTS and vec
959
- self._fts_upsert(
960
- "interactions_fts",
961
- iid,
962
- content=interaction.content,
963
- user_action_description=interaction.user_action_description,
964
- )
965
- if interaction.embedding:
966
- self._vec_upsert("interactions_vec", iid, interaction.embedding)
967
- return iid
968
-
969
- @SQLiteStorageBase.handle_exceptions
970
- def add_user_interactions_bulk(
971
- self,
972
- user_id: str, # noqa: ARG002
973
- interactions: list[Interaction],
974
- ) -> None:
975
- if not interactions:
976
- return
977
- texts = [
978
- "\n".join([i.content or "", i.user_action_description or ""])
979
- for i in interactions
980
- ]
981
- try:
982
- embeddings = self.llm_client.get_embeddings(
983
- texts, self.embedding_model_name, self.embedding_dimensions
984
- )
985
- except EmbeddingUnavailableError as exc:
986
- logger.warning(
987
- "Embedding unavailable for interaction bulk insert; "
988
- "continuing without vectors: %s",
989
- exc,
990
- )
991
- embeddings = [[] for _ in texts]
992
- for interaction, embedding in zip(interactions, embeddings, strict=False):
993
- interaction.embedding = embedding
994
- self._insert_interaction(interaction)
995
-
996
- @SQLiteStorageBase.handle_exceptions
997
- def delete_user_interaction(self, request: DeleteUserInteractionRequest) -> None:
998
- with self._lock:
999
- row = self.conn.execute(
1000
- "SELECT interaction_id FROM interactions WHERE user_id = ? AND interaction_id = ?",
1001
- (request.user_id, request.interaction_id),
1002
- ).fetchone()
1003
- if row is None:
1004
- return
1005
- self.conn.execute(
1006
- "DELETE FROM interactions_fts WHERE rowid = ?",
1007
- (request.interaction_id,),
1008
- )
1009
- if self._has_sqlite_vec:
1010
- self.conn.execute(
1011
- "DELETE FROM interactions_vec WHERE rowid = ?",
1012
- (request.interaction_id,),
1013
- )
1014
- self.conn.execute(
1015
- "DELETE FROM interactions WHERE user_id = ? AND interaction_id = ?",
1016
- (request.user_id, request.interaction_id),
1017
- )
1018
- self.conn.commit()
1019
-
1020
- @SQLiteStorageBase.handle_exceptions
1021
- def delete_all_interactions_for_user(self, user_id: str) -> None:
1022
- with self._lock:
1023
- rows = self.conn.execute(
1024
- "SELECT interaction_id FROM interactions WHERE user_id = ?", (user_id,)
1025
- ).fetchall()
1026
- if not rows:
1027
- return
1028
- ids = [r["interaction_id"] for r in rows]
1029
- self._delete_in_chunks("interactions_fts", "rowid", ids)
1030
- if self._has_sqlite_vec:
1031
- self._delete_in_chunks("interactions_vec", "rowid", ids)
1032
- self.conn.execute("DELETE FROM interactions WHERE user_id = ?", (user_id,))
1033
- self.conn.commit()
1034
-
1035
- @SQLiteStorageBase.handle_exceptions
1036
- def delete_all_interactions(self) -> None:
1037
- with self._lock:
1038
- self.conn.execute("DELETE FROM interactions_fts")
1039
- if self._has_sqlite_vec:
1040
- self.conn.execute("DELETE FROM interactions_vec")
1041
- self.conn.execute("DELETE FROM interactions")
1042
- self.conn.commit()
1043
-
1044
- @SQLiteStorageBase.handle_exceptions
1045
- def count_all_interactions(self) -> int:
1046
- row = self._fetchone("SELECT COUNT(*) as cnt FROM interactions")
1047
- return row["cnt"] if row else 0
1048
-
1049
- @SQLiteStorageBase.handle_exceptions
1050
- def delete_oldest_interactions(self, count: int) -> int:
1051
- if count <= 0:
1052
- return 0
1053
- with self._lock:
1054
- rows = self.conn.execute(
1055
- "SELECT interaction_id FROM interactions ORDER BY created_at ASC LIMIT ?",
1056
- (count,),
1057
- ).fetchall()
1058
- if not rows:
1059
- return 0
1060
- ids = [r["interaction_id"] for r in rows]
1061
- self._delete_in_chunks("interactions_fts", "rowid", ids)
1062
- if self._has_sqlite_vec:
1063
- self._delete_in_chunks("interactions_vec", "rowid", ids)
1064
- self._delete_in_chunks("interactions", "interaction_id", ids)
1065
- self.conn.commit()
1066
- return len(ids)
1067
-
1068
- # ------------------------------------------------------------------
1069
- # Search — Interactions & Profiles
1070
- # ------------------------------------------------------------------
1071
-
1072
- @SQLiteStorageBase.handle_exceptions
1073
- def search_interaction(
1074
- self,
1075
- search_interaction_request: SearchInteractionRequest,
1076
- query_embedding: list[float] | None = None,
1077
- ) -> list[Interaction]:
1078
- req = search_interaction_request
1079
- has_query = bool(req.query)
1080
- match_count = req.most_recent_k or 10
1081
- mode = _effective_search_mode(req.search_mode, query_embedding, req.query)
1082
-
1083
- conditions: list[str] = ["i.user_id = ?"]
1084
- params: list[str | int | float] = [req.user_id]
1085
-
1086
- if req.request_id:
1087
- conditions.append("i.request_id = ?")
1088
- params.append(req.request_id)
1089
- if req.start_time:
1090
- conditions.append("i.created_at >= ?")
1091
- params.append(req.start_time.timestamp())
1092
- if req.end_time:
1093
- conditions.append("i.created_at <= ?")
1094
- params.append(req.end_time.timestamp())
1095
-
1096
- where_clause = " AND ".join(conditions)
1097
- overfetch = match_count * 5 if mode != SearchMode.FTS else match_count
1098
-
1099
- # Vector-only: rank by embedding similarity
1100
- if (
1101
- mode in (SearchMode.VECTOR, SearchMode.HYBRID)
1102
- and query_embedding
1103
- and not has_query
1104
- ):
1105
- vector_limit = match_count * 10
1106
- sql = f"""SELECT i.* FROM interactions i
1107
- WHERE {where_clause}
1108
- ORDER BY i.created_at DESC
1109
- LIMIT ?"""
1110
- rows = self._fetchall(sql, (*params, vector_limit))
1111
- rows = _vector_rank_rows(rows, query_embedding, match_count)
1112
- elif has_query:
1113
- # FTS search (with optional HYBRID re-ranking)
1114
- fts_query = _sanitize_fts_query(req.query) # type: ignore[arg-type]
1115
- fts_conditions = ["interactions_fts MATCH ?", *conditions]
1116
- fts_where = " AND ".join(fts_conditions)
1117
- fts_params: list[str | int | float] = [fts_query, *params, overfetch]
1118
- sql = f"""SELECT i.* FROM interactions i
1119
- JOIN interactions_fts f ON i.interaction_id = f.rowid
1120
- WHERE {fts_where}
1121
- ORDER BY bm25(interactions_fts, 1.0, 2.0)
1122
- LIMIT ?"""
1123
- fts_rows = self._fetchall(sql, tuple(fts_params))
1124
-
1125
- if mode == SearchMode.HYBRID and query_embedding:
1126
- vec_limit = match_count * 10
1127
- vec_sql = f"""SELECT i.* FROM interactions i
1128
- WHERE {where_clause}
1129
- ORDER BY i.created_at DESC
1130
- LIMIT ?"""
1131
- vec_candidates = self._fetchall(vec_sql, (*params, vec_limit))
1132
- vec_rows = _vector_rank_rows(vec_candidates, query_embedding, overfetch)
1133
- rows = _true_rrf_merge(
1134
- fts_rows,
1135
- vec_rows,
1136
- "interaction_id",
1137
- match_count,
1138
- )
1139
- else:
1140
- rows = fts_rows[:match_count]
1141
- else:
1142
- if req.most_recent_k:
1143
- # No query — just fetch most recent interactions by time
1144
- sql = f"""SELECT i.* FROM interactions i
1145
- WHERE {where_clause}
1146
- ORDER BY i.created_at DESC
1147
- LIMIT ?"""
1148
- rows = self._fetchall(sql, (*params, req.most_recent_k))
1149
- return [_row_to_interaction(r) for r in reversed(rows)]
1150
- return []
1151
-
1152
- interactions = [_row_to_interaction(r) for r in rows]
1153
- if req.most_recent_k:
1154
- sorted_ints = sorted(interactions, key=lambda x: x.created_at, reverse=True)
1155
- return list(reversed(sorted_ints[: req.most_recent_k]))
1156
- return interactions
1157
-
1158
- @SQLiteStorageBase.handle_exceptions
1159
- def search_user_profile( # noqa: C901
1160
- self,
1161
- search_user_profile_request: SearchUserProfileRequest,
1162
- status_filter: list[Status | None] | None = None,
1163
- query_embedding: list[float] | None = None,
1164
- ) -> list[UserProfile]:
1165
- if status_filter is None:
1166
- status_filter = [None]
1167
-
1168
- req = search_user_profile_request
1169
- match_count = req.top_k or 10
1170
- current_ts = _epoch_now()
1171
- has_query = bool(req.query)
1172
- mode = _effective_search_mode(req.search_mode, query_embedding, req.query)
1173
- has_embedding = query_embedding is not None
1174
- logger.info(
1175
- "Profile search: requested_mode=%s, effective_mode=%s, has_query=%s, has_embedding=%s, user_id=%s",
1176
- req.search_mode,
1177
- mode,
1178
- has_query,
1179
- has_embedding,
1180
- req.user_id,
1181
- )
1182
-
1183
- conditions: list[str] = ["p.expiration_timestamp >= ?"]
1184
- params: list[object] = [current_ts]
1185
-
1186
- if req.user_id:
1187
- conditions.append("p.user_id = ?")
1188
- params.append(req.user_id)
1189
- if req.start_time:
1190
- conditions.append("p.last_modified_timestamp >= ?")
1191
- params.append(int(req.start_time.timestamp()))
1192
- if req.end_time:
1193
- conditions.append("p.last_modified_timestamp <= ?")
1194
- params.append(int(req.end_time.timestamp()))
1195
- if req.source:
1196
- conditions.append("LOWER(p.source) = LOWER(?)")
1197
- params.append(req.source)
1198
- if status_filter is not None:
1199
- frag, sparams = _build_status_sql(status_filter)
1200
- conditions.append(frag)
1201
- params.extend(sparams)
1202
- tag_frag, tag_params = _build_tags_sql("p", req.tags)
1203
- if tag_frag:
1204
- conditions.append(tag_frag)
1205
- params.extend(tag_params)
1206
-
1207
- where_clause = " AND ".join(conditions)
1208
- overfetch = match_count * 5 if mode != SearchMode.FTS else match_count
1209
-
1210
- # Pure vector search: fetch all candidates, rank by cosine similarity
1211
- if mode == SearchMode.VECTOR and query_embedding:
1212
- if req.generated_from_request_id:
1213
- conditions.append("p.generated_from_request_id = ?")
1214
- params.append(req.generated_from_request_id)
1215
- where_clause = " AND ".join(conditions)
1216
- sql = f"""SELECT p.* FROM profiles p
1217
- WHERE {where_clause}
1218
- ORDER BY p.last_modified_timestamp DESC"""
1219
- rows = self._fetchall(sql, tuple(params))
1220
- logger.info(
1221
- "VECTOR search: %d candidates fetched, ranking by embedding", len(rows)
1222
- )
1223
- rows = _vector_rank_rows(rows, query_embedding, match_count)
1224
- elif has_query:
1225
- fts_query = _sanitize_fts_query(req.query) # type: ignore[arg-type]
1226
- sql = f"""SELECT p.* FROM profiles p
1227
- JOIN profiles_fts f ON p.profile_id = f.profile_id
1228
- WHERE profiles_fts MATCH ?
1229
- AND {where_clause}
1230
- ORDER BY bm25(profiles_fts, 0.0, 1.0)
1231
- LIMIT ?"""
1232
- params_list: list[object] = [fts_query, *params, overfetch]
1233
- fts_rows = self._fetchall(sql, tuple(params_list))
1234
- logger.info("FTS search: %d results from BM25", len(fts_rows))
1235
-
1236
- if mode == SearchMode.HYBRID and query_embedding:
1237
- logger.info("HYBRID merging FTS + vector results via RRF")
1238
- vec_limit = match_count * 10
1239
- vec_sql = f"""SELECT p.* FROM profiles p
1240
- WHERE {where_clause}
1241
- ORDER BY p.last_modified_timestamp DESC
1242
- LIMIT ?"""
1243
- vec_candidates = self._fetchall(vec_sql, (*params, vec_limit))
1244
- vec_rows = _vector_rank_rows(vec_candidates, query_embedding, overfetch)
1245
- rows = _true_rrf_merge(
1246
- fts_rows,
1247
- vec_rows,
1248
- "profile_id",
1249
- match_count,
1250
- )
1251
- else:
1252
- rows = fts_rows
1253
- elif query_embedding:
1254
- # HYBRID without query text: rank by embedding only
1255
- if req.generated_from_request_id:
1256
- conditions.append("p.generated_from_request_id = ?")
1257
- params.append(req.generated_from_request_id)
1258
- where_clause = " AND ".join(conditions)
1259
- sql = f"""SELECT p.* FROM profiles p
1260
- WHERE {where_clause}
1261
- ORDER BY p.last_modified_timestamp DESC"""
1262
- rows = self._fetchall(sql, tuple(params))
1263
- logger.info(
1264
- "HYBRID (no query text) search: %d candidates, ranking by embedding",
1265
- len(rows),
1266
- )
1267
- rows = _vector_rank_rows(rows, query_embedding, match_count)
1268
- else:
1269
- if req.generated_from_request_id:
1270
- conditions.append("p.generated_from_request_id = ?")
1271
- params.append(req.generated_from_request_id)
1272
- where_clause = " AND ".join(conditions)
1273
- sql = f"""SELECT p.* FROM profiles p
1274
- WHERE {where_clause}
1275
- ORDER BY p.last_modified_timestamp DESC
1276
- LIMIT ?"""
1277
- params_list = [*params, overfetch]
1278
- rows = self._fetchall(sql, tuple(params_list))
1279
-
1280
- profiles = [_row_to_profile(r) for r in rows]
1281
- logger.info("Profile search: %d profiles before post-filtering", len(profiles))
1282
-
1283
- # Apply filters that can't easily go into SQL
1284
- filtered: list[UserProfile] = []
1285
- for profile in profiles:
1286
- if req.custom_feature and (
1287
- req.custom_feature.lower() not in str(profile.custom_features).lower()
1288
- ):
1289
- continue
1290
- filtered.append(profile)
1291
- if len(filtered) >= match_count:
1292
- break
1293
- return filtered