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