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
@@ -0,0 +1,288 @@
1
+ """Process-local queue for learning work after async publish persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import queue
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass, field
10
+
11
+ from reflexio.server.cache.reflexio_cache import get_reflexio
12
+ from reflexio.server.env_utils import env_str
13
+ from reflexio.server.operation_limiter import operation_limit, operation_limit_value
14
+ from reflexio.server.services.generation_service import GenerationService
15
+ from reflexio.server.usage_metrics import record_usage_event
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _DEFAULT_QUEUE_WARN_SIZE = 1000
20
+
21
+
22
+ def _env_int(name: str, default: int, *, minimum: int = 1) -> int:
23
+ raw = env_str(name, str(default))
24
+ try:
25
+ return max(minimum, int(raw))
26
+ except ValueError:
27
+ logger.warning("Invalid %s=%r; using %s", name, raw, default)
28
+ return max(minimum, default)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PublishLearningJob:
33
+ """Metadata needed to run post-persist publish learning."""
34
+
35
+ org_id: str
36
+ user_id: str
37
+ request_id: str
38
+ session_id: str | None
39
+ source: str | None
40
+ agent_version: str
41
+ force_extraction: bool
42
+ skip_aggregation: bool
43
+ enqueued_at: float = field(default_factory=time.monotonic)
44
+
45
+
46
+ class PublishLearningWorker:
47
+ """Process-local worker pool for deferred publish learning."""
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ queue_warn_size: int | None = None,
53
+ worker_count: int | None = None,
54
+ ) -> None:
55
+ default_workers = operation_limit_value("publish")
56
+ self.queue_warn_size = (
57
+ queue_warn_size
58
+ if queue_warn_size is not None
59
+ else _env_int(
60
+ "REFLEXIO_PUBLISH_LEARNING_QUEUE_WARN_SIZE",
61
+ _DEFAULT_QUEUE_WARN_SIZE,
62
+ )
63
+ )
64
+ self.worker_count = (
65
+ worker_count
66
+ if worker_count is not None
67
+ else _env_int("REFLEXIO_PUBLISH_LEARNING_WORKERS", default_workers)
68
+ )
69
+ self._queue: queue.Queue[PublishLearningJob] = queue.Queue()
70
+ self._stop_event = threading.Event()
71
+ self._threads: list[threading.Thread] = []
72
+ self._started = False
73
+ self._start_lock = threading.Lock()
74
+
75
+ def start(self) -> None:
76
+ with self._start_lock:
77
+ if self._started:
78
+ return
79
+ self._stop_event.clear()
80
+ self._threads = [
81
+ threading.Thread(
82
+ target=self._worker_loop,
83
+ name=f"publish-learning-worker-{idx}",
84
+ daemon=True,
85
+ )
86
+ for idx in range(self.worker_count)
87
+ ]
88
+ for thread in self._threads:
89
+ thread.start()
90
+ self._started = True
91
+ logger.info(
92
+ "event=publish_learning_worker_started workers=%d queue_warn_size=%d",
93
+ self.worker_count,
94
+ self.queue_warn_size,
95
+ )
96
+
97
+ def enqueue(self, job: PublishLearningJob) -> bool:
98
+ self.start()
99
+ self._queue.put_nowait(job)
100
+ queue_depth = self._queue.qsize()
101
+ if queue_depth >= self.queue_warn_size:
102
+ record_usage_event(
103
+ org_id=job.org_id,
104
+ user_id=job.user_id,
105
+ request_id=job.request_id,
106
+ session_id=job.session_id,
107
+ source=job.source,
108
+ agent_version=job.agent_version,
109
+ event_name="learning_queue_pressure",
110
+ event_category="publish_learning",
111
+ outcome="queued",
112
+ metadata={
113
+ "queue_depth": queue_depth,
114
+ "queue_warn_size": self.queue_warn_size,
115
+ },
116
+ )
117
+ logger.warning(
118
+ "event=publish_learning_queue_pressure org_id=%s user_id=%s "
119
+ "request_id=%s queue_depth=%d queue_warn_size=%d",
120
+ job.org_id,
121
+ job.user_id,
122
+ job.request_id,
123
+ queue_depth,
124
+ self.queue_warn_size,
125
+ )
126
+ logger.info(
127
+ "event=publish_learning_enqueued org_id=%s user_id=%s request_id=%s "
128
+ "queue_depth=%d queue_warn_size=%d",
129
+ job.org_id,
130
+ job.user_id,
131
+ job.request_id,
132
+ queue_depth,
133
+ self.queue_warn_size,
134
+ )
135
+ return True
136
+
137
+ def stop(self, timeout: float | None = 5.0) -> None:
138
+ self._stop_event.set()
139
+ deadline = None if timeout is None else time.monotonic() + timeout
140
+ for thread in list(self._threads):
141
+ remaining = (
142
+ None if deadline is None else max(0.0, deadline - time.monotonic())
143
+ )
144
+ thread.join(timeout=remaining)
145
+ if all(not thread.is_alive() for thread in self._threads):
146
+ self._threads = []
147
+ self._started = False
148
+ logger.info("event=publish_learning_worker_stopped")
149
+
150
+ def _worker_loop(self) -> None:
151
+ while not self._stop_event.is_set() or not self._queue.empty():
152
+ try:
153
+ job = self._queue.get(timeout=0.25)
154
+ except queue.Empty:
155
+ continue
156
+ try:
157
+ self._process_job(job)
158
+ finally:
159
+ self._queue.task_done()
160
+
161
+ def _process_job(self, job: PublishLearningJob) -> None:
162
+ try:
163
+ with operation_limit(
164
+ job.org_id,
165
+ "publish",
166
+ wait_forever=False,
167
+ log_timeout=False,
168
+ ):
169
+ reflexio = get_reflexio(org_id=job.org_id)
170
+ GenerationService(
171
+ llm_client=reflexio.llm_client,
172
+ request_context=reflexio.request_context,
173
+ ).run_deferred_learning(
174
+ user_id=job.user_id,
175
+ request_id=job.request_id,
176
+ session_id=job.session_id,
177
+ source=job.source,
178
+ agent_version=job.agent_version,
179
+ force_extraction=job.force_extraction,
180
+ skip_aggregation=job.skip_aggregation,
181
+ )
182
+ except TimeoutError:
183
+ self._requeue_after_limiter_timeout(job)
184
+ return
185
+ except Exception as exc:
186
+ record_usage_event(
187
+ org_id=job.org_id,
188
+ user_id=job.user_id,
189
+ request_id=job.request_id,
190
+ session_id=job.session_id,
191
+ source=job.source,
192
+ agent_version=job.agent_version,
193
+ event_name="learning_failed",
194
+ event_category="publish_learning",
195
+ outcome="failed",
196
+ error_kind=type(exc).__name__,
197
+ )
198
+ logger.exception(
199
+ "event=publish_learning_failed org_id=%s user_id=%s request_id=%s",
200
+ job.org_id,
201
+ job.user_id,
202
+ job.request_id,
203
+ )
204
+ return
205
+
206
+ record_usage_event(
207
+ org_id=job.org_id,
208
+ user_id=job.user_id,
209
+ request_id=job.request_id,
210
+ session_id=job.session_id,
211
+ source=job.source,
212
+ agent_version=job.agent_version,
213
+ event_name="learning_succeeded",
214
+ event_category="publish_learning",
215
+ outcome="success",
216
+ )
217
+ logger.info(
218
+ "event=publish_learning_done org_id=%s user_id=%s request_id=%s "
219
+ "queue_depth=%d",
220
+ job.org_id,
221
+ job.user_id,
222
+ job.request_id,
223
+ self._queue.qsize(),
224
+ )
225
+
226
+ def _requeue_after_limiter_timeout(self, job: PublishLearningJob) -> None:
227
+ self._queue.put_nowait(job)
228
+
229
+ record_usage_event(
230
+ org_id=job.org_id,
231
+ user_id=job.user_id,
232
+ request_id=job.request_id,
233
+ session_id=job.session_id,
234
+ source=job.source,
235
+ agent_version=job.agent_version,
236
+ event_name="learning_requeued_limiter_busy",
237
+ event_category="publish_learning",
238
+ outcome="requeued",
239
+ metadata={
240
+ "age_seconds": self._job_age_seconds(job),
241
+ "queue_depth": self._queue.qsize(),
242
+ },
243
+ )
244
+ logger.info(
245
+ "event=publish_learning_requeued_limiter_busy org_id=%s user_id=%s "
246
+ "request_id=%s queue_depth=%d age_seconds=%.3f",
247
+ job.org_id,
248
+ job.user_id,
249
+ job.request_id,
250
+ self._queue.qsize(),
251
+ self._job_age_seconds(job),
252
+ )
253
+
254
+ @staticmethod
255
+ def _job_age_seconds(job: PublishLearningJob) -> float:
256
+ return time.monotonic() - job.enqueued_at
257
+
258
+
259
+ _worker: PublishLearningWorker | None = None
260
+ _worker_lock = threading.Lock()
261
+
262
+
263
+ def get_publish_learning_worker() -> PublishLearningWorker:
264
+ global _worker # noqa: PLW0603
265
+ if _worker is None:
266
+ with _worker_lock:
267
+ if _worker is None:
268
+ _worker = PublishLearningWorker()
269
+ return _worker
270
+
271
+
272
+ def enqueue_publish_learning(job: PublishLearningJob) -> bool:
273
+ return get_publish_learning_worker().enqueue(job)
274
+
275
+
276
+ def stop_publish_learning_worker(timeout: float | None = 5.0) -> None:
277
+ global _worker # noqa: PLW0603
278
+ worker = _worker
279
+ if worker is not None:
280
+ worker.stop(timeout=timeout)
281
+
282
+
283
+ def reset_publish_learning_worker_for_tests() -> None:
284
+ global _worker # noqa: PLW0603
285
+ worker = _worker
286
+ if worker is not None:
287
+ worker.stop(timeout=0.1)
288
+ _worker = None
@@ -0,0 +1,211 @@
1
+ """Recency adjustment helpers for unified search ranking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, replace
9
+ from datetime import UTC, datetime
10
+
11
+ from reflexio.server.env_utils import env_str, env_truthy
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ SECONDS_PER_DAY = 24 * 60 * 60
16
+ PLAYBOOK_HALF_LIFE_SECONDS = 90 * SECONDS_PER_DAY
17
+ _PROFILE_TTL_HALF_LIFE_FRACTION = 0.5
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ScoredItem[T]:
22
+ item: T
23
+ score: float | None
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RecencyConfig:
28
+ enabled: bool = True
29
+ max_penalty_frac: float = 0.15
30
+ max_penalty_logit: float = 0.2
31
+ pool_size: int = 20
32
+
33
+ @classmethod
34
+ def from_env(cls, *, env: Mapping[str, str] | None = None) -> RecencyConfig:
35
+ return cls(
36
+ enabled=env_truthy(
37
+ env_str("REFLEXIO_SEARCH_RECENCY_ENABLED", "true", env=env)
38
+ ),
39
+ max_penalty_frac=_float_env(
40
+ "REFLEXIO_SEARCH_RECENCY_MAX_PENALTY_FRAC",
41
+ cls.max_penalty_frac,
42
+ env=env,
43
+ minimum=0.0,
44
+ maximum=1.0,
45
+ ),
46
+ max_penalty_logit=_float_env(
47
+ "REFLEXIO_SEARCH_RECENCY_MAX_PENALTY_LOGIT",
48
+ cls.max_penalty_logit,
49
+ env=env,
50
+ minimum=0.0,
51
+ ),
52
+ pool_size=_int_env(
53
+ "REFLEXIO_SEARCH_RECENCY_POOL_SIZE",
54
+ cls.pool_size,
55
+ env=env,
56
+ minimum=1,
57
+ ),
58
+ )
59
+
60
+ def with_overrides(self, values: Mapping[str, object] | None) -> RecencyConfig:
61
+ if not values:
62
+ return self
63
+ updates: dict[str, object] = {}
64
+ if "recency_enabled" in values:
65
+ updates["enabled"] = _bool_value(values["recency_enabled"], self.enabled)
66
+ if "recency_max_penalty_frac" in values:
67
+ updates["max_penalty_frac"] = _float_value(
68
+ values["recency_max_penalty_frac"],
69
+ self.max_penalty_frac,
70
+ minimum=0.0,
71
+ maximum=1.0,
72
+ )
73
+ if "recency_max_penalty_logit" in values:
74
+ updates["max_penalty_logit"] = _float_value(
75
+ values["recency_max_penalty_logit"],
76
+ self.max_penalty_logit,
77
+ minimum=0.0,
78
+ )
79
+ if "recency_pool_size" in values:
80
+ updates["pool_size"] = _int_value(
81
+ values["recency_pool_size"], self.pool_size, minimum=1
82
+ )
83
+ return replace(self, **updates)
84
+
85
+
86
+ def decay_for_item(
87
+ item: object,
88
+ *,
89
+ entity_type: str,
90
+ now: int | None = None,
91
+ ) -> float:
92
+ """Return a freshness decay in [0, 1], where 1 means no penalty."""
93
+ now = now if now is not None else int(datetime.now(UTC).timestamp())
94
+ if entity_type == "profiles":
95
+ return _profile_decay(item, now=now)
96
+ return _timestamp_decay(
97
+ getattr(item, "created_at", None),
98
+ half_life_seconds=PLAYBOOK_HALF_LIFE_SECONDS,
99
+ now=now,
100
+ )
101
+
102
+
103
+ def decay(age_seconds: float, half_life_seconds: float) -> float:
104
+ if half_life_seconds <= 0:
105
+ return 1.0
106
+ age_seconds = max(0.0, age_seconds)
107
+ return math.exp(-age_seconds / (half_life_seconds / math.log(2)))
108
+
109
+
110
+ def multiplicative_factor(decay_value: float, max_penalty_frac: float) -> float:
111
+ return 1.0 - _clamp(max_penalty_frac, 0.0, 1.0) * (1.0 - _decay(decay_value))
112
+
113
+
114
+ def additive_penalty(decay_value: float, max_penalty_logit: float) -> float:
115
+ return max(0.0, max_penalty_logit) * (1.0 - _decay(decay_value))
116
+
117
+
118
+ def _profile_decay(item: object, *, now: int) -> float:
119
+ modified_at = _int_or_none(getattr(item, "last_modified_timestamp", None))
120
+ expires_at = _int_or_none(getattr(item, "expiration_timestamp", None))
121
+ if modified_at is None or expires_at is None or expires_at <= modified_at:
122
+ return 1.0
123
+ half_life = (expires_at - modified_at) * _PROFILE_TTL_HALF_LIFE_FRACTION
124
+ return decay(now - modified_at, half_life)
125
+
126
+
127
+ def _timestamp_decay(value: object, *, half_life_seconds: int, now: int) -> float:
128
+ timestamp = _int_or_none(value)
129
+ if timestamp is None or timestamp <= 0:
130
+ return 1.0
131
+ return decay(now - timestamp, half_life_seconds)
132
+
133
+
134
+ def _float_env(
135
+ name: str,
136
+ default: float,
137
+ *,
138
+ env: Mapping[str, str] | None,
139
+ minimum: float,
140
+ maximum: float | None = None,
141
+ ) -> float:
142
+ return _float_value(
143
+ env_str(name, str(default), env=env),
144
+ default,
145
+ minimum=minimum,
146
+ maximum=maximum,
147
+ )
148
+
149
+
150
+ def _int_env(
151
+ name: str,
152
+ default: int,
153
+ *,
154
+ env: Mapping[str, str] | None,
155
+ minimum: int,
156
+ ) -> int:
157
+ return _int_value(env_str(name, str(default), env=env), default, minimum=minimum)
158
+
159
+
160
+ def _bool_value(value: object, default: bool) -> bool:
161
+ if isinstance(value, bool):
162
+ return value
163
+ if isinstance(value, str) and value.strip():
164
+ return env_truthy(value)
165
+ return default
166
+
167
+
168
+ def _float_value(
169
+ value: object,
170
+ default: float,
171
+ *,
172
+ minimum: float,
173
+ maximum: float | None = None,
174
+ ) -> float:
175
+ try:
176
+ if not isinstance(value, str | int | float):
177
+ raise TypeError
178
+ parsed = float(value)
179
+ except (TypeError, ValueError):
180
+ logger.warning("Invalid recency float override %r; using %.3f", value, default)
181
+ return default
182
+ parsed = max(minimum, parsed)
183
+ if maximum is not None:
184
+ parsed = min(maximum, parsed)
185
+ return parsed
186
+
187
+
188
+ def _int_value(value: object, default: int, *, minimum: int) -> int:
189
+ try:
190
+ if not isinstance(value, str | int):
191
+ raise TypeError
192
+ parsed = int(value)
193
+ except (TypeError, ValueError):
194
+ logger.warning("Invalid recency integer override %r; using %d", value, default)
195
+ return default
196
+ return max(minimum, parsed)
197
+
198
+
199
+ def _int_or_none(value: object) -> int | None:
200
+ try:
201
+ return int(value) # type: ignore[arg-type]
202
+ except (TypeError, ValueError):
203
+ return None
204
+
205
+
206
+ def _decay(value: float) -> float:
207
+ return _clamp(value, 0.0, 1.0)
208
+
209
+
210
+ def _clamp(value: float, low: float, high: float) -> float:
211
+ return min(high, max(low, value))
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import logging
12
12
  from collections.abc import Callable, Sequence
13
+ from dataclasses import dataclass
13
14
  from typing import Any
14
15
 
15
16
  from reflexio.server.llm.rerank import score_pairs
@@ -21,12 +22,20 @@ from reflexio.server.tracing import profile_step
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
 
24
- def _floor_and_sort[T](items: list[T], scores: list[float], floor: float) -> list[T]:
25
- """Return items scoring >= ``floor``, sorted by score descending."""
25
+ @dataclass(frozen=True)
26
+ class RelevanceFloorResult:
27
+ items: list[Any]
28
+ scores: list[float] | None
29
+
30
+
31
+ def _floor_and_sort[T](
32
+ items: list[T], scores: list[float], floor: float
33
+ ) -> list[tuple[T, float]]:
34
+ """Return ``(item, score)`` pairs scoring >= ``floor``, sorted descending."""
26
35
  ranked = sorted(
27
36
  zip(items, scores, strict=True), key=lambda pair: pair[1], reverse=True
28
37
  )
29
- return [item for item, score in ranked if score >= floor]
38
+ return [(item, score) for item, score in ranked if score >= floor]
30
39
 
31
40
 
32
41
  def apply_relevance_floor[T](
@@ -71,7 +80,7 @@ def apply_relevance_floor[T](
71
80
  return items[:top_k]
72
81
  span.set_data("available", True)
73
82
 
74
- survivors = _floor_and_sort(items, scores, floor)
83
+ survivors = [item for item, _score in _floor_and_sort(items, scores, floor)]
75
84
  dropped = len(items) - len(survivors)
76
85
  span.set_data("kept", len(survivors))
77
86
  span.set_data("dropped", dropped)
@@ -92,7 +101,7 @@ def apply_relevance_floors(
92
101
  top_k: int,
93
102
  *,
94
103
  content_of: Callable[[Any], str] = lambda item: item.content,
95
- ) -> list[list[Any]]:
104
+ ) -> list[RelevanceFloorResult]:
96
105
  """Floor every arm with a single cross-encoder batch.
97
106
 
98
107
  CPU cross-encoder inference does not parallelize across threads, so
@@ -108,12 +117,13 @@ def apply_relevance_floors(
108
117
  content_of: Extracts the text to score for an item.
109
118
 
110
119
  Returns:
111
- One survivor list per arm, in input order each sorted by score
112
- descending and capped at ``top_k``. On reranker unavailability,
113
- every arm returns ``items[:top_k]`` unchanged (logged).
120
+ One result per arm, in input order. Available reranker results are
121
+ sorted by score descending and left uncapped with the paired raw logits.
122
+ On reranker unavailability, every arm returns the original full item
123
+ pool with a ``None`` score sentinel (logged).
114
124
  """
115
125
  if not any(items for _, items, _ in arms):
116
- return [[] for _ in arms]
126
+ return [RelevanceFloorResult([], []) for _ in arms]
117
127
  arm_names = [name for name, _, _ in arms]
118
128
  contents: list[str] = []
119
129
  for _, items, _ in arms:
@@ -123,20 +133,21 @@ def apply_relevance_floors(
123
133
  arm="all",
124
134
  arms=arm_names,
125
135
  items=len(contents),
136
+ top_k=top_k,
126
137
  ) as span:
127
138
  try:
128
139
  scores = score_pairs(query, contents)
129
140
  except CrossEncoderUnavailableError:
130
141
  span.set_data("available", False)
131
142
  logger.warning(
132
- "event=relevance_floor_unavailable arms=%s items=%d (returning unfiltered top_k)",
143
+ "event=relevance_floor_unavailable arms=%s items=%d (returning unfiltered pool)",
133
144
  arm_names,
134
145
  len(contents),
135
146
  )
136
- return [items[:top_k] for _, items, _ in arms]
147
+ return [RelevanceFloorResult(list(items), None) for _, items, _ in arms]
137
148
  span.set_data("available", True)
138
149
 
139
- results: list[list[Any]] = []
150
+ results: list[RelevanceFloorResult] = []
140
151
  offset = 0
141
152
  for name, items, floor in arms:
142
153
  arm_scores = scores[offset : offset + len(items)]
@@ -153,5 +164,10 @@ def apply_relevance_floors(
153
164
  dropped,
154
165
  floor,
155
166
  )
156
- results.append(survivors[:top_k])
167
+ results.append(
168
+ RelevanceFloorResult(
169
+ [item for item, _score in survivors],
170
+ [score for _item, score in survivors],
171
+ )
172
+ )
157
173
  return results
@@ -14,6 +14,10 @@ class StorageError(Exception):
14
14
  return f"StorageError: {self.message}"
15
15
 
16
16
 
17
+ class SubjectWriteBarrierError(StorageError):
18
+ """Raised when a write targets a subject with an active erasure barrier."""
19
+
20
+
17
21
  def require_non_empty_session_id(value: Any) -> str:
18
22
  """Return a stripped, non-empty request ``session_id`` or raise ``StorageError``.
19
23