claude-smart 0.2.49 → 0.2.50

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 (177) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +10 -43
  3. package/bin/claude-smart.js +105 -0
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/pyproject.toml +1 -1
  27. package/plugin/scripts/_lib.sh +197 -1
  28. package/plugin/scripts/backend-python-runner.py +46 -0
  29. package/plugin/scripts/backend-service.sh +757 -119
  30. package/plugin/scripts/codex-hook.js +63 -225
  31. package/plugin/scripts/dashboard-open.sh +6 -4
  32. package/plugin/scripts/dashboard-service.sh +117 -136
  33. package/plugin/scripts/hook_entry.sh +3 -0
  34. package/plugin/scripts/smart-install.sh +15 -1
  35. package/plugin/src/claude_smart/cli.py +14 -0
  36. package/plugin/src/claude_smart/context_inject.py +3 -0
  37. package/plugin/src/claude_smart/env_config.py +4 -1
  38. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  39. package/plugin/src/claude_smart/events/session_end.py +2 -1
  40. package/plugin/src/claude_smart/events/stop.py +3 -0
  41. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  42. package/plugin/src/claude_smart/internal_call.py +5 -2
  43. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  44. package/plugin/src/claude_smart/publish.py +59 -7
  45. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  46. package/plugin/src/claude_smart/runtime.py +15 -6
  47. package/plugin/src/claude_smart/state.py +211 -52
  48. package/plugin/uv.lock +1 -1
  49. package/plugin/vendor/reflexio/.env.example +13 -0
  50. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  51. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  52. package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
  53. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  54. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  55. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  56. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
  57. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  58. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  59. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  60. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  61. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  62. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  63. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  64. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  65. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
  67. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  68. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  69. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  70. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  71. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
  72. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  73. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  74. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  75. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  76. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  77. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
  78. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  79. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  80. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  81. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  82. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  83. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  84. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  85. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  86. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  90. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  91. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  92. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  93. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  94. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  95. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  96. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  99. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  100. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  101. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  103. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  104. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  105. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  106. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
  109. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  110. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  111. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  112. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  113. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  114. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  115. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  116. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
  118. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  120. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  122. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  124. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  125. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  126. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  127. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  128. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  129. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  130. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  131. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  132. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  133. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
  134. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  135. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  139. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  172. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  173. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  174. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  175. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  176. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -11,6 +11,7 @@ from reflexio.server.api_endpoints.request_context import RequestContext
11
11
  from reflexio.server.llm.litellm_client import LiteLLMClient
12
12
  from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
13
13
  from reflexio.server.llm.token_accounting import RunTokenTotals, sum_trace_tokens
14
+ from reflexio.server.services.deferred_learning_plan import ExtractorBookmarkAdvance
14
15
  from reflexio.server.services.extraction.outcome import ExtractionOutcome
15
16
  from reflexio.server.services.extraction.resumable_agent import (
16
17
  run_resumable_extraction_agent,
@@ -172,25 +173,6 @@ class PlaybookExtractor:
172
173
  )
173
174
  return session_data_models
174
175
 
175
- def _update_operation_state(
176
- self, request_interaction_data_models: list[RequestInteractionDataModel]
177
- ) -> None:
178
- """
179
- Update operation state after processing interactions.
180
-
181
- Args:
182
- request_interaction_data_models: The interactions that were processed
183
- """
184
- all_interactions = extract_interactions_from_request_interaction_data_models(
185
- request_interaction_data_models
186
- )
187
- mgr = self._create_state_manager()
188
- mgr.update_extractor_bookmark(
189
- extractor_name=get_extractor_name(self.config),
190
- processed_interactions=all_interactions,
191
- user_id=self.service_config.user_id,
192
- )
193
-
194
176
  # ===============================
195
177
  # public methods
196
178
  # ===============================
@@ -202,10 +184,12 @@ class PlaybookExtractor:
202
184
  This extractor handles its own data collection:
203
185
  1. Gets interactions based on its config (window size, source filtering)
204
186
  2. Applies time range filter for rerun flows
205
- 3. Updates operation state after processing
187
+ 3. Defers the stride-bookmark advance onto the outcome (applied in persist)
206
188
 
207
189
  Returns:
208
- list[UserPlaybook]: List of extracted user playbook entries
190
+ An empty list when there are no interactions to process; otherwise an
191
+ ExtractionOutcome carrying the extracted playbook entries, the
192
+ resumable run_id (when set), and the deferred bookmark advance.
209
193
  """
210
194
  # Collect interactions using extractor's own window_size/stride_size settings
211
195
  request_interaction_data_models = self._get_interactions()
@@ -217,17 +201,30 @@ class PlaybookExtractor:
217
201
 
218
202
  user_playbooks = self.extract_playbook_entries(request_interaction_data_models)
219
203
 
220
- # Update operation state after successful processing
204
+ # Defer the stride-bookmark advance onto the outcome instead of
205
+ # self-advancing here (F1): applied downstream — inside the persist
206
+ # fence on the durable path, or in ``.run()``'s persist half — so it
207
+ # stays atomic with the playbook row writes. Only produced when output
208
+ # was generated (bookmark-iff-rows).
209
+ bookmark_advance: ExtractorBookmarkAdvance | None = None
221
210
  if user_playbooks:
222
- self._update_operation_state(request_interaction_data_models)
223
-
224
- if self._last_resumable_run_id:
225
- return ExtractionOutcome.completed(
226
- user_playbooks,
227
- run_id=self._last_resumable_run_id,
228
- token_totals=self._last_resumable_token_totals,
211
+ bookmark_advance = ExtractorBookmarkAdvance(
212
+ extractor_name=get_extractor_name(self.config),
213
+ processed_interactions=extract_interactions_from_request_interaction_data_models(
214
+ request_interaction_data_models
215
+ ),
216
+ user_id=self.service_config.user_id,
229
217
  )
230
- return user_playbooks
218
+
219
+ # Always return an ExtractionOutcome so the bookmark advance rides along
220
+ # even in the non-resumable case; a resumable run also surfaces its
221
+ # run_id for _agent_runs finalization.
222
+ return ExtractionOutcome.completed(
223
+ user_playbooks,
224
+ run_id=self._last_resumable_run_id,
225
+ token_totals=self._last_resumable_token_totals,
226
+ bookmark_advance=bookmark_advance,
227
+ )
231
228
 
232
229
  def extract_playbook_entries(
233
230
  self, request_interaction_data_models: list[RequestInteractionDataModel]
@@ -19,6 +19,7 @@ def apply_playbook_edit(
19
19
  new_playbook: UserPlaybook,
20
20
  source: str,
21
21
  request_id: str,
22
+ skip_embedding: bool = False,
22
23
  ) -> int:
23
24
  """Insert a replacement playbook then atomically supersede the incumbent.
24
25
 
@@ -43,6 +44,12 @@ def apply_playbook_edit(
43
44
  non-empty; use the reflection run id (``ReflectionServiceRequest.request_id``)
44
45
  or another operation-scoped id. Raises ``ValueError`` immediately
45
46
  (before any storage write) when empty, preventing orphaned successor rows.
47
+ skip_embedding: Forwarded to ``save_user_playbooks``. Defaults to
48
+ ``False`` (recompute the embedding at write time — what every online
49
+ / offline-tuner caller relies on). The durable reflection persist
50
+ path passes ``True`` because the successor's embedding was already
51
+ precomputed off the writer transaction, so no embedding runs inside
52
+ the fence.
46
53
 
47
54
  Returns:
48
55
  The ``user_playbook_id`` of the newly inserted playbook, or ``-1`` if
@@ -56,7 +63,7 @@ def apply_playbook_edit(
56
63
  "apply_playbook_edit: request_id must be non-empty (operation-run correlation id)"
57
64
  )
58
65
  new_playbook.source = source
59
- storage.save_user_playbooks([new_playbook])
66
+ storage.save_user_playbooks([new_playbook], skip_embedding=skip_embedding)
60
67
  new_id: int = new_playbook.user_playbook_id
61
68
 
62
69
  ctx = LineageContext(op_kind="revise", actor=source, request_id=request_id)
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
8
8
  if TYPE_CHECKING:
9
9
  from reflexio.server.api_endpoints.request_context import RequestContext
10
10
  from reflexio.server.llm.litellm_client import LiteLLMClient
11
+ from reflexio.server.services.deferred_learning_plan import GenerationComputePlan
11
12
  from reflexio.server.services.storage.storage_base import BaseStorage
12
13
 
13
14
  from reflexio.models.api_schema.internal_schema import RequestInteractionDataModel
@@ -22,22 +23,19 @@ from reflexio.models.api_schema.service_schemas import (
22
23
  UserPlaybook,
23
24
  )
24
25
  from reflexio.models.config_schema import PlaybookConfig
25
- from reflexio.server.extensions import get_service
26
- from reflexio.server.operation_limiter import run_with_operation_limit
27
26
  from reflexio.server.services.base_generation_service import (
28
27
  BaseGenerationService,
29
28
  StatusChangeOperation,
30
29
  )
31
- from reflexio.server.services.playbook.aggregation_prompt_processing import (
32
- AGGREGATION_PROMPT_PROCESSOR,
30
+ from reflexio.server.services.deferred_learning_plan import PlaybookWritePlan
31
+ from reflexio.server.services.playbook.aggregation_trigger import (
32
+ maybe_trigger_user_playbook_aggregation,
33
33
  )
34
- from reflexio.server.services.playbook.components.aggregator import PlaybookAggregator
35
34
  from reflexio.server.services.playbook.components.extractor import PlaybookExtractor
36
35
  from reflexio.server.services.playbook.playbook_service_constants import (
37
36
  PlaybookServiceConstants,
38
37
  )
39
38
  from reflexio.server.services.playbook.playbook_service_utils import (
40
- PlaybookAggregatorRequest,
41
39
  PlaybookGenerationRequest,
42
40
  format_expert_comparison_pairs,
43
41
  has_expert_content,
@@ -154,8 +152,9 @@ class PlaybookGenerationService(
154
152
  Returns:
155
153
  PlaybookGenerationServiceConfig object
156
154
  """
155
+ generation_request_id = request.request_id
157
156
  return PlaybookGenerationServiceConfig(
158
- request_id=request.request_id,
157
+ request_id=generation_request_id,
159
158
  agent_version=request.agent_version,
160
159
  user_id=request.user_id,
161
160
  source=request.source,
@@ -290,12 +289,30 @@ class PlaybookGenerationService(
290
289
  all_playbooks.extend(result)
291
290
  self._finalize_extracted_items(all_playbooks)
292
291
 
293
- def _finalize_extracted_items(self, all_playbooks: list[UserPlaybook]) -> None:
294
- """Deduplicate, persist, and aggregate extracted user playbook items."""
292
+ def _resolve_write_plan(
293
+ self, results: list[list[UserPlaybook]]
294
+ ) -> PlaybookWritePlan | None:
295
+ """Compute-half of playbook finalization — NO learning DB write.
296
+
297
+ Flattens + ``dedupe_and_drop_empty``, runs the deduplicator (its 2nd
298
+ LLM call + reads of existing rows), assigns ``source``/``status``, and
299
+ **precomputes embeddings** on the survivors. Returns a
300
+ :class:`PlaybookWritePlan` for the persist half, or ``None`` when there
301
+ is nothing to write (matching the pre-split ``if all_playbooks:`` gate,
302
+ which suppressed save/lineage/schedulers alike). Issues no
303
+ ``save_user_playbooks``/``merge_records``/``supersede_*`` — the write is
304
+ the persist half's job (compute is write-free).
305
+ """
295
306
  from reflexio.server.services.playbook.playbook_service_utils import (
296
307
  dedupe_and_drop_empty,
297
308
  )
298
309
 
310
+ generation_request_id = self.service_config.request_id # type: ignore[reportOptionalMemberAccess]
311
+
312
+ all_playbooks: list[UserPlaybook] = []
313
+ for result in results:
314
+ if isinstance(result, list):
315
+ all_playbooks.extend(result)
299
316
  all_playbooks = dedupe_and_drop_empty(all_playbooks)
300
317
 
301
318
  # Deduplicate against existing entries in DB when deduplicator is enabled
@@ -324,7 +341,7 @@ class PlaybookGenerationService(
324
341
  merge_groups,
325
342
  ) = consolidator.deduplicate(
326
343
  [all_playbooks],
327
- self.service_config.request_id, # type: ignore[reportOptionalMemberAccess]
344
+ generation_request_id,
328
345
  self.service_config.agent_version, # type: ignore[reportOptionalMemberAccess]
329
346
  user_id=self.service_config.user_id, # type: ignore[reportOptionalMemberAccess]
330
347
  )
@@ -342,41 +359,115 @@ class PlaybookGenerationService(
342
359
 
343
360
  logger.info("All user playbook entries: %s", all_playbooks)
344
361
 
362
+ if not all_playbooks:
363
+ return None
364
+
345
365
  logger.info(
346
366
  "Successfully completed %d %s playbook generation for request id: %s",
347
367
  len(all_playbooks),
348
368
  self._get_service_name(),
349
- self.service_config.request_id, # type: ignore[reportOptionalMemberAccess]
369
+ generation_request_id,
350
370
  )
351
371
 
352
- # Save results
353
- if all_playbooks:
354
- try:
355
- self.storage.save_user_playbooks(all_playbooks) # type: ignore[reportOptionalMemberAccess]
356
- self._enqueue_user_playbook_optimization(all_playbooks)
357
- self._apply_consolidation_lineage(
358
- all_playbooks, merge_groups, existing_ids_to_delete
359
- )
360
- except Exception as e:
361
- logger.error(
362
- "Failed to save %s results for request id: %s due to %s, exception type: %s",
363
- self._get_service_name(),
364
- self.service_config.request_id, # type: ignore[reportOptionalMemberAccess]
365
- str(e),
366
- type(e).__name__,
367
- )
368
- raise
372
+ # Precompute embeddings on the survivors (compute-side, NO DB write). The
373
+ # persist half passes skip_embedding=True so no embedding runs in the fence.
374
+ self.storage.precompute_user_playbook_embeddings(all_playbooks) # type: ignore[reportOptionalMemberAccess]
375
+
376
+ return PlaybookWritePlan(
377
+ request_id=generation_request_id,
378
+ output_pending_status=self.output_pending_status,
379
+ skip_aggregation=self.skip_aggregation,
380
+ new_playbooks=all_playbooks,
381
+ superseded_ids=existing_ids_to_delete,
382
+ merge_groups=merge_groups,
383
+ )
384
+
385
+ def _persist_write_plan(self, plan: PlaybookWritePlan) -> None:
386
+ """Persist-half of playbook finalization — apply the resolved write-plan.
387
+
388
+ Issues only the fence-critical row writes: saves the new playbooks
389
+ (``skip_embedding=True`` — embeddings were precomputed in compute; the
390
+ save also assigns survivor ids) then materializes the consolidation
391
+ lineage, which MUST see those survivor ids and so runs AFTER the save.
392
+ NO LLM / embedding / dedup. The off-thread optimization/aggregation
393
+ schedulers are NOT here — they fire post-commit in
394
+ ``emit_generation_side_effects`` (durable / ``.run()``) or right after
395
+ persist in ``_finalize_extracted_items`` (resume/manual).
396
+ """
397
+ if not plan.new_playbooks:
398
+ return
399
+ try:
400
+ self.storage.save_user_playbooks( # type: ignore[reportOptionalMemberAccess]
401
+ plan.new_playbooks, skip_embedding=True
402
+ )
403
+ self._apply_consolidation_lineage(
404
+ plan.new_playbooks,
405
+ plan.merge_groups,
406
+ plan.superseded_ids,
407
+ request_id=plan.request_id,
408
+ )
409
+ except Exception as e:
410
+ logger.error(
411
+ "Failed to save %s results for request id: %s due to %s, exception type: %s",
412
+ self._get_service_name(),
413
+ plan.request_id,
414
+ str(e),
415
+ type(e).__name__,
416
+ )
417
+ raise
418
+
419
+ def _dispatch_playbook_schedulers(self, plan: PlaybookWritePlan) -> None:
420
+ """Fire the off-thread optimization + aggregation schedulers post-persist.
369
421
 
370
- # Trigger playbook aggregation
371
- if not self.output_pending_status and not self.skip_aggregation:
372
- logger.info("Trigger playbook aggregation")
373
- self._trigger_playbook_aggregation()
422
+ Phantom-billing gate: on the durable / ``.run()`` path this is invoked
423
+ from ``emit_generation_side_effects`` (post-commit), so a fence-lost
424
+ (superseded) job never enqueues optimization or triggers aggregation. On
425
+ the synchronous resume/manual path the permanent
426
+ ``_finalize_extracted_items`` wrapper invokes it right after persist,
427
+ keeping that path identical to the pre-split monolith. The two callers
428
+ are mutually exclusive, so the schedulers fire exactly once per run.
429
+ """
430
+ self._enqueue_user_playbook_optimization(plan.new_playbooks)
431
+ if not plan.output_pending_status and not plan.skip_aggregation:
432
+ logger.info("Trigger playbook aggregation")
433
+ self._trigger_playbook_aggregation()
434
+
435
+ def emit_generation_side_effects(self, plan: GenerationComputePlan) -> None:
436
+ """Post-commit side-effects — base telemetry/billing + playbook schedulers.
437
+
438
+ Extends the base emit (``generation_succeeded`` + ② Learning billing)
439
+ with the off-thread optimization/aggregation schedulers, which move here
440
+ so they fire only for a fence-winning durable job (never for a
441
+ superseded one) — the phantom-billing gate.
442
+ """
443
+ super().emit_generation_side_effects(plan)
444
+ write_plan = plan.write_plan
445
+ if write_plan is not None:
446
+ self._dispatch_playbook_schedulers(write_plan)
447
+
448
+ def _finalize_extracted_items(self, all_playbooks: list[UserPlaybook]) -> None:
449
+ """Permanent V3 wrapper: compute→persist→schedulers together (no fence).
450
+
451
+ Kept for the synchronous resume/manual callers
452
+ (``ExtractionResumeWorker`` calls this directly). Routes them through the
453
+ same ``_resolve_write_plan`` (compute) + ``_persist_write_plan``
454
+ (persist) split the durable worker uses — with no external
455
+ ``commit_scope`` — then dispatches the same off-thread schedulers, so the
456
+ result is identical to the pre-split monolith.
457
+ """
458
+ plan = self._resolve_write_plan([all_playbooks])
459
+ if plan is None:
460
+ return
461
+ self._persist_write_plan(plan)
462
+ self._dispatch_playbook_schedulers(plan)
374
463
 
375
464
  def _apply_consolidation_lineage(
376
465
  self,
377
466
  saved_playbooks: list[UserPlaybook],
378
467
  merge_groups: list[tuple[int, list[int]]],
379
468
  existing_ids_to_delete: list[int],
469
+ *,
470
+ request_id: str,
380
471
  ) -> None:
381
472
  """Materialize consolidation merges as lineage tombstones.
382
473
 
@@ -393,9 +484,14 @@ class PlaybookGenerationService(
393
484
  saved_playbooks: The just-persisted entries (survivor ids assigned).
394
485
  merge_groups: ``(survivor_index, source_existing_ids)`` per merge.
395
486
  existing_ids_to_delete: ALL archived ids (merge sources + leftovers).
487
+ request_id: Generation request id — the lineage key recorded on the
488
+ merge/supersede events. Passed explicitly (from the write-plan)
489
+ rather than read off ``self.service_config`` so persist stays
490
+ decoupled from the mutable service config on the fenced path.
396
491
  """
397
492
  from reflexio.models.api_schema.domain.entities import LineageContext
398
493
 
494
+ generation_request_id = request_id
399
495
  merged_source_ids: set[int] = set()
400
496
  for survivor_idx, source_ids in merge_groups:
401
497
  survivor_id = saved_playbooks[survivor_idx].user_playbook_id
@@ -409,7 +505,7 @@ class PlaybookGenerationService(
409
505
  actor="consolidator",
410
506
  source_ids=[str(s) for s in source_ids],
411
507
  reason="dedup-merge",
412
- request_id=self.service_config.request_id, # type: ignore[reportOptionalMemberAccess]
508
+ request_id=generation_request_id,
413
509
  ),
414
510
  )
415
511
 
@@ -423,7 +519,7 @@ class PlaybookGenerationService(
423
519
  try:
424
520
  superseded_count = self.storage.supersede_user_playbooks_by_ids( # type: ignore[reportOptionalMemberAccess]
425
521
  leftover_ids,
426
- self.service_config.request_id, # type: ignore[reportOptionalMemberAccess]
522
+ generation_request_id,
427
523
  )
428
524
  logger.info(
429
525
  "Superseded %d split-source existing entries", superseded_count
@@ -528,42 +624,12 @@ class PlaybookGenerationService(
528
624
  Trigger playbook aggregation for playbook types that have aggregator config.
529
625
  This is called after raw user playbook entries are saved to check if aggregation should run.
530
626
  """
531
- playbook_config = self._configured_playbook_config()
532
- if not playbook_config or not playbook_config.aggregation_config:
533
- return
534
-
535
- logger.info("Triggering aggregation")
536
-
537
- # Create aggregator request. Aggregation is singleton — it operates on the
538
- # user's whole playbook set, so no name selector is threaded.
539
- aggregator_request = PlaybookAggregatorRequest(
540
- agent_version=self.service_config.agent_version, # type: ignore[reportOptionalMemberAccess]
541
- )
542
-
543
- # Initialize and run aggregator (synchronous)
544
- aggregation_prompt_processor = get_service(AGGREGATION_PROMPT_PROCESSOR)
545
- aggregator_kwargs = {}
546
- if aggregation_prompt_processor is not None:
547
- aggregator_kwargs["aggregation_prompt_processor"] = (
548
- aggregation_prompt_processor
549
- )
550
- aggregator = PlaybookAggregator(
551
- llm_client=self.client,
627
+ maybe_trigger_user_playbook_aggregation(
552
628
  request_context=self.request_context,
629
+ llm_client=self.client,
553
630
  agent_version=self.service_config.agent_version, # type: ignore[reportOptionalMemberAccess]
554
- **aggregator_kwargs,
631
+ reason="playbook_generation",
555
632
  )
556
- try:
557
- run_with_operation_limit(
558
- org_id=self.request_context.org_id,
559
- operation="aggregation",
560
- fn=lambda: aggregator.run(aggregator_request),
561
- )
562
- except TimeoutError:
563
- logger.info(
564
- "Skipping inline aggregation for agent_version=%s: aggregation limiter is saturated",
565
- self.service_config.agent_version, # type: ignore[reportOptionalMemberAccess]
566
- )
567
633
 
568
634
  # ===============================
569
635
  # Rerun hook implementations (override base class methods)
@@ -646,8 +712,9 @@ class PlaybookGenerationService(
646
712
  """
647
713
  # Handle rerun requests (have start_time/end_time datetime objects)
648
714
  if isinstance(request, RerunPlaybookGenerationRequest):
715
+ operation_request_id = f"rerun_playbook_{uuid.uuid4().hex[:8]}"
649
716
  return PlaybookGenerationRequest(
650
- request_id=f"rerun_playbook_{uuid.uuid4().hex[:8]}",
717
+ request_id=operation_request_id,
651
718
  agent_version=request.agent_version,
652
719
  user_id=user_id,
653
720
  source=request.source,
@@ -660,8 +727,9 @@ class PlaybookGenerationService(
660
727
  auto_run=False,
661
728
  )
662
729
  # Handle manual requests (ManualPlaybookGenerationRequest)
730
+ operation_request_id = f"manual_{uuid.uuid4().hex[:8]}"
663
731
  return PlaybookGenerationRequest(
664
- request_id=f"manual_{uuid.uuid4().hex[:8]}",
732
+ request_id=operation_request_id,
665
733
  agent_version=request.agent_version,
666
734
  user_id=user_id,
667
735
  source=request.source,
@@ -20,6 +20,9 @@ from reflexio.models.api_schema.domain.entities import LineageContext
20
20
  from reflexio.models.config_schema import PlaybookOptimizerConfig
21
21
  from reflexio.server.api_endpoints.request_context import RequestContext
22
22
  from reflexio.server.llm.litellm_client import LiteLLMClient
23
+ from reflexio.server.services.playbook.aggregation_trigger import (
24
+ maybe_trigger_user_playbook_aggregation,
25
+ )
23
26
  from reflexio.server.tracing import sentry_tags
24
27
 
25
28
  from .assistant_webhook import AssistantCallable, LocalScriptAssistant, WebhookAssistant
@@ -488,13 +491,29 @@ class PlaybookOptimizer:
488
491
  # the rule wording, so writing ``best_content`` is sufficient — there
489
492
  # is no derived polarity label or separate polarity field to keep in
490
493
  # sync.
491
- return _supersede_user_playbook(
494
+ successor_id = _supersede_user_playbook(
492
495
  self.storage,
493
496
  current_user,
494
497
  best_content,
495
498
  "playbook_optimizer",
496
499
  request_id=run_request_id,
497
500
  )
501
+ if successor_id is not None:
502
+ try:
503
+ successor = self.storage.get_user_playbook_by_id(successor_id)
504
+ if successor is not None and successor.agent_version:
505
+ maybe_trigger_user_playbook_aggregation(
506
+ request_context=self.request_context,
507
+ llm_client=self.llm_client,
508
+ agent_version=successor.agent_version,
509
+ reason="playbook_optimizer",
510
+ )
511
+ except Exception: # noqa: BLE001
512
+ logger.exception(
513
+ "playbook_optimizer aggregation trigger failed after successor commit",
514
+ extra={"successor_id": successor_id},
515
+ )
516
+ return successor_id
498
517
 
499
518
 
500
519
  def _agent_like_playbook(playbook: UserPlaybook) -> AgentPlaybook:
@@ -5,6 +5,9 @@ import logging
5
5
  import threading
6
6
  import time
7
7
  from collections.abc import Callable
8
+ from functools import partial
9
+
10
+ from reflexio.server.callback_executor import submit_callback
8
11
 
9
12
  from .optimizer import PlaybookOptimizationRunStatus, PlaybookOptimizationTarget
10
13
 
@@ -110,12 +113,16 @@ class PlaybookOptimizationScheduler:
110
113
  continue
111
114
  _, callback, abort_threshold, cooldown_seconds = current
112
115
  del self._scheduled[key]
113
- threading.Thread(
114
- target=self._run_callback,
115
- args=(key, callback, abort_threshold, cooldown_seconds),
116
- daemon=True,
117
- name=f"playbook-opt-{key[1]}-{key[2]}",
118
- ).start()
116
+ submit_callback(
117
+ f"playbook-opt-{key[1]}-{key[2]}",
118
+ partial(
119
+ self._run_callback,
120
+ key,
121
+ callback,
122
+ abort_threshold,
123
+ cooldown_seconds,
124
+ ),
125
+ )
119
126
  except Exception:
120
127
  logger.exception("Playbook optimization scheduler loop failed")
121
128
  time.sleep(1)
@@ -1,16 +1,19 @@
1
1
  """Independent query reformulation module for pre-retrieval optimization.
2
2
 
3
- Reformulates search queries into clean, normalized natural language:
4
- resolves conversation context, expands abbreviations, fixes grammar.
3
+ Reformulates search queries into clean, normalized natural language AND
4
+ extracts the query's temporal signals (relative time window,
5
+ recency_dominant, wants_current) in the same structured LLM call — so
6
+ retrieval can be time-sensitive with no additional pre-search latency.
5
7
  No FTS expansion -- that is handled by document-side expansion at storage time.
6
8
 
7
9
  Provides two interfaces:
8
- - rewrite(): pure query transformation (no search)
10
+ - rewrite(): pure query transformation + temporal signals (no search)
9
11
  - search(): rewrite + execute search via callable + merge results
10
12
  """
11
13
 
12
14
  import logging
13
15
  from collections.abc import Callable
16
+ from datetime import UTC, datetime
14
17
  from typing import Generic, TypeVar
15
18
 
16
19
  from pydantic import BaseModel
@@ -84,13 +87,15 @@ class QueryReformulator:
84
87
  query: str,
85
88
  conversation_history: list[ConversationTurn] | None = None,
86
89
  ) -> ReformulationResult:
87
- """Reformulate a search query into clean, normalized natural language.
90
+ """Reformulate a query and extract its temporal signals.
88
91
 
89
- Always runs reformulation: resolves conversation context (pronouns,
92
+ One structured LLM call resolves conversation context (pronouns,
90
93
  ellipsis, implicit references), expands abbreviations, fixes grammar,
91
- and normalizes terminology.
94
+ normalizes terminology — and reads the question's time semantics
95
+ (relative window, recency_dominant, wants_current).
92
96
 
93
- Falls back to the original query on any LLM failure.
97
+ Falls back to the original query with no temporal signals on any
98
+ LLM/parse failure.
94
99
 
95
100
  Args:
96
101
  query (str): The original user search query
@@ -98,11 +103,11 @@ class QueryReformulator:
98
103
  context-aware reformulation.
99
104
 
100
105
  Returns:
101
- ReformulationResult: The reformulated standalone query
106
+ ReformulationResult: Reformulated standalone query + temporal
107
+ signals.
102
108
  """
103
109
  try:
104
- reformulated = self._reformulate(query, conversation_history)
105
- return ReformulationResult(standalone_query=reformulated)
110
+ return self._reformulate(query, conversation_history)
106
111
  except Exception as e:
107
112
  logger.warning("Query reformulation failed, using original: %s", e)
108
113
  return ReformulationResult(standalone_query=query)
@@ -150,18 +155,21 @@ class QueryReformulator:
150
155
  self,
151
156
  query: str,
152
157
  conversation_history: list[ConversationTurn] | None = None,
153
- ) -> str:
154
- """Use LLM to reformulate the query into clean, standalone natural language.
158
+ ) -> ReformulationResult:
159
+ """One structured LLM call: rewritten query + temporal signals.
155
160
 
156
161
  Args:
157
162
  query (str): The original search query
158
163
  conversation_history (list, optional): Prior conversation turns
159
164
 
160
165
  Returns:
161
- str: Reformulated query text
166
+ ReformulationResult: Validated result. A suspect rewritten-query
167
+ field degrades the whole result to the original query with no
168
+ signals (a model that mangles the rewrite is not trusted for
169
+ the signals either).
162
170
 
163
171
  Raises:
164
- Exception: If LLM call or extraction fails
172
+ Exception: If the LLM call fails
165
173
  """
166
174
  conversation_context = self._format_conversation_context(conversation_history)
167
175
  conversation_context_block = (
@@ -171,29 +179,36 @@ class QueryReformulator:
171
179
  )
172
180
  prompt = self.prompt_manager.render_prompt(
173
181
  "query_reformulation",
174
- {"query": query, "conversation_context_block": conversation_context_block},
182
+ {
183
+ "query": query,
184
+ "conversation_context_block": conversation_context_block,
185
+ "today_iso": datetime.now(UTC).date().isoformat(),
186
+ },
175
187
  )
176
188
  logger.debug("Query reformulation prompt: %s", prompt)
177
189
  model_kwargs = {}
178
190
  if self.model_name:
179
191
  model_kwargs["model"] = self.model_name
180
- result = self.llm_client.generate_response(
181
- prompt,
192
+ result = self.llm_client.generate_chat_response(
193
+ messages=[{"role": "user", "content": prompt}],
194
+ response_format=ReformulationResult,
182
195
  timeout=self.LLM_TIMEOUT,
183
196
  max_retries=self.LLM_MAX_RETRIES,
184
197
  **model_kwargs,
185
198
  )
186
199
  log_model_response(logger, "Query reformulation model response", result)
187
200
 
188
- if isinstance(result, str):
189
- extracted = self._extract_reformulated_query(result)
190
- if extracted:
191
- return extracted
192
- logger.warning("LLM returned invalid reformulation: %s", result)
193
- return query
201
+ if not isinstance(result, ReformulationResult):
202
+ logger.warning("LLM returned non-structured reformulation: %r", result)
203
+ return ReformulationResult(standalone_query=query)
194
204
 
195
- logger.warning("LLM returned empty response for query reformulation")
196
- return query
205
+ extracted = self._extract_reformulated_query(result.standalone_query)
206
+ if not extracted:
207
+ logger.warning(
208
+ "LLM returned invalid reformulation: %s", result.standalone_query
209
+ )
210
+ return ReformulationResult(standalone_query=query)
211
+ return result.model_copy(update={"standalone_query": extracted})
197
212
 
198
213
  @classmethod
199
214
  def _extract_reformulated_query(cls, output: str) -> str | None: