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
@@ -7,7 +7,6 @@ import logging
7
7
  import os
8
8
  import uuid
9
9
  from datetime import UTC, datetime
10
- from typing import Any, cast
11
10
 
12
11
  from pydantic import BaseModel, ConfigDict, Field
13
12
 
@@ -20,8 +19,8 @@ from reflexio.server.services.deduplication_utils import (
20
19
  BaseDeduplicator,
21
20
  format_dedup_timestamp,
22
21
  parse_item_id,
22
+ resolve_dedup_query_embeddings,
23
23
  )
24
- from reflexio.server.services.embedding_text import embedding_input
25
24
  from reflexio.server.services.profile.profile_generation_service_utils import (
26
25
  ProfileTimeToLive,
27
26
  calculate_expiration_timestamp,
@@ -306,42 +305,9 @@ class ProfileConsolidator(BaseDeduplicator):
306
305
 
307
306
  # Generate embeddings with the request storage backend so dedup search
308
307
  # uses the same model/prefix/routing as normal profile search.
309
- embeddings: list[list[float] | None]
310
- try:
311
- get_storage_embedding = getattr(storage, "_get_embedding", None)
312
- if callable(get_storage_embedding):
313
- logger.info(
314
- "Profile dedup query embeddings: source=storage model=%s",
315
- getattr(storage, "embedding_model_name", "unknown"),
316
- )
317
- embeddings = [
318
- cast(
319
- list[float],
320
- get_storage_embedding(query_text, purpose="query"),
321
- )
322
- for query_text in query_texts
323
- ]
324
- else:
325
- storage_with_embeddings = cast(Any, storage)
326
- embedding_model_name = storage_with_embeddings.embedding_model_name
327
- embedding_dimensions = storage_with_embeddings.embedding_dimensions
328
- logger.info(
329
- "Profile dedup query embeddings: source=llm_client model=%s",
330
- embedding_model_name,
331
- )
332
- embeddings = list(
333
- self.client.get_embeddings(
334
- [
335
- embedding_input(query_text, purpose="query")
336
- for query_text in query_texts
337
- ],
338
- model=embedding_model_name,
339
- dimensions=embedding_dimensions,
340
- )
341
- )
342
- except Exception as e:
343
- logger.warning("Failed to generate embeddings for dedup search: %s", e)
344
- embeddings = [None] * len(query_texts)
308
+ embeddings = resolve_dedup_query_embeddings(
309
+ storage, self.client, query_texts, entity_label="Profile"
310
+ )
345
311
 
346
312
  # Search for each new profile.
347
313
  #
@@ -465,7 +431,14 @@ class ProfileConsolidator(BaseDeduplicator):
465
431
 
466
432
  dedup_output = response
467
433
  except Exception as e:
468
- logger.error("Failed to identify duplicates: %s", str(e))
434
+ # Graceful degradation: dedup is best-effort — on any failure
435
+ # (commonly a transient minimax timeout/overload) keep the profiles
436
+ # un-deduped rather than failing the caller. WARNING, not ERROR, so a
437
+ # flaky provider doesn't flood error alerts for a handled fallback.
438
+ logger.warning(
439
+ "Failed to identify duplicates (%s); keeping profiles un-deduped",
440
+ str(e),
441
+ )
469
442
  return _strip_deletion_markers(new_profiles), [], []
470
443
 
471
444
  if not dedup_output.duplicate_groups and not dedup_output.deletions:
@@ -14,6 +14,7 @@ from reflexio.models.config_schema import ProfileExtractorConfig
14
14
  from reflexio.server.api_endpoints.request_context import RequestContext
15
15
  from reflexio.server.llm.litellm_client import LiteLLMClient
16
16
  from reflexio.server.llm.token_accounting import RunTokenTotals, sum_trace_tokens
17
+ from reflexio.server.services.deferred_learning_plan import ExtractorBookmarkAdvance
17
18
  from reflexio.server.services.extraction.outcome import ExtractionOutcome
18
19
  from reflexio.server.services.extraction.resumable_agent import (
19
20
  run_resumable_extraction_agent,
@@ -181,25 +182,6 @@ class ProfileExtractor:
181
182
  )
182
183
  return session_data_models
183
184
 
184
- def _update_operation_state(
185
- self, request_interaction_data_models: list[RequestInteractionDataModel]
186
- ) -> None:
187
- """
188
- Update operation state after processing interactions.
189
-
190
- Args:
191
- request_interaction_data_models: The interactions that were processed
192
- """
193
- all_interactions = extract_interactions_from_request_interaction_data_models(
194
- request_interaction_data_models
195
- )
196
- mgr = self._create_state_manager()
197
- mgr.update_extractor_bookmark(
198
- extractor_name=get_extractor_name(self.config),
199
- processed_interactions=all_interactions,
200
- user_id=self.service_config.user_id,
201
- )
202
-
203
185
  def run(self) -> list[UserProfile] | ExtractionOutcome[UserProfile] | None:
204
186
  """
205
187
  Extract profiles from request interaction groups.
@@ -209,10 +191,12 @@ class ProfileExtractor:
209
191
  2. Applies time range filter for rerun flows
210
192
  3. Calls LLM to extract profiles
211
193
  4. Converts raw extraction to UserProfile objects
212
- 5. Updates operation state after processing
194
+ 5. Defers the stride-bookmark advance onto the outcome (applied in persist)
213
195
 
214
196
  Returns:
215
- Optional[list[UserProfile]]: List of extracted profiles, or None if no profiles found
197
+ None when there are no interactions to process; otherwise an
198
+ ExtractionOutcome carrying the extracted profiles, the resumable
199
+ run_id (when set), and the deferred bookmark advance.
216
200
  """
217
201
  # Collect interactions using extractor's own window_size/stride_size settings
218
202
  request_interaction_data_models = self._get_interactions()
@@ -257,22 +241,33 @@ class ProfileExtractor:
257
241
  request_id=self.service_config.request_id,
258
242
  source_interaction_ids=source_interaction_ids,
259
243
  )
244
+ # Defer the stride-bookmark advance onto the outcome instead of
245
+ # self-advancing here (F1): the advance is applied downstream — inside
246
+ # the persist fence on the durable path, or in ``.run()``'s persist
247
+ # half — so it stays atomic with the profile row writes it corresponds
248
+ # to. Only produced when output was generated (bookmark-iff-rows).
249
+ bookmark_advance: ExtractorBookmarkAdvance | None = None
260
250
  if raw_profiles:
261
- # Update operation state (bookmark) only when output was produced.
262
- self._update_operation_state(request_interaction_data_models)
263
-
264
- # A resumable run must always surface its run_id so the generation
265
- # service can finalize the _agent_runs row (FINALIZED_PENDING_TOOL when
266
- # the agent created a follow-up ask and finished with empty output).
267
- # Dropping the run_id here would orphan the run in AGENT_COMPLETED and
268
- # sever the resolve -> resume chain. Mirrors PlaybookExtractor.run().
269
- if self._last_resumable_run_id:
270
- return ExtractionOutcome.completed(
271
- user_profiles,
272
- run_id=self._last_resumable_run_id,
273
- token_totals=self._last_resumable_token_totals,
251
+ bookmark_advance = ExtractorBookmarkAdvance(
252
+ extractor_name=get_extractor_name(self.config),
253
+ processed_interactions=extract_interactions_from_request_interaction_data_models(
254
+ request_interaction_data_models
255
+ ),
256
+ user_id=self.service_config.user_id,
274
257
  )
275
- return user_profiles or None
258
+
259
+ # Always return an ExtractionOutcome so the bookmark advance rides along
260
+ # even in the non-resumable case. A resumable run must also surface its
261
+ # run_id so the generation service can finalize the _agent_runs row
262
+ # (FINALIZED_PENDING_TOOL when the agent created a follow-up ask and
263
+ # finished with empty output); dropping it would orphan the run in
264
+ # AGENT_COMPLETED and sever the resolve -> resume chain.
265
+ return ExtractionOutcome.completed(
266
+ user_profiles,
267
+ run_id=self._last_resumable_run_id,
268
+ token_totals=self._last_resumable_token_totals,
269
+ bookmark_advance=bookmark_advance,
270
+ )
276
271
 
277
272
  def _convert_raw_to_user_profiles(
278
273
  self,
@@ -27,6 +27,7 @@ from reflexio.server.services.base_generation_service import (
27
27
  BaseGenerationService,
28
28
  StatusChangeOperation,
29
29
  )
30
+ from reflexio.server.services.deferred_learning_plan import ProfileWritePlan
30
31
  from reflexio.server.services.profile.components.extractor import ProfileExtractor
31
32
  from reflexio.server.services.profile.profile_generation_service_utils import (
32
33
  ProfileGenerationRequest,
@@ -123,9 +124,10 @@ class ProfileGenerationService(
123
124
  else:
124
125
  existing_profiles = self.storage.get_user_profile(request.user_id) # type: ignore[reportOptionalMemberAccess]
125
126
 
127
+ generation_request_id = request.request_id
126
128
  return ProfileGenerationServiceConfig(
127
129
  user_id=request.user_id,
128
- request_id=request.request_id,
130
+ request_id=generation_request_id,
129
131
  source=request.source,
130
132
  existing_data=existing_profiles,
131
133
  allow_manual_trigger=self.allow_manual_trigger,
@@ -147,12 +149,24 @@ class ProfileGenerationService(
147
149
  [p for result in results if result for p in result]
148
150
  )
149
151
 
150
- def _finalize_extracted_items(self, all_new_profiles: list[UserProfile]) -> None:
151
- """Deduplicate, persist, and changelog extracted profile items."""
152
+ def _resolve_write_plan(
153
+ self, results: list[list[UserProfile]]
154
+ ) -> ProfileWritePlan | None:
155
+ """Compute-half of profile finalization — NO learning DB write.
156
+
157
+ Flattens the extractor results, runs the deduplicator (the 2nd LLM call
158
+ + reads of existing rows), assigns ``source``/``status``, resolves the
159
+ missing-``request_id`` guard (dropping unreconstructable supersede ids),
160
+ and **precomputes embeddings** on the new rows. Returns a
161
+ :class:`ProfileWritePlan` for the persist half, or ``None`` when there is
162
+ nothing to write. Issues no ``add_user_profile``/``supersede_*`` — the
163
+ write is the persist half's job (compute is write-free).
164
+ """
152
165
  user_id = self.service_config.user_id # type: ignore[reportOptionalMemberAccess]
153
166
  source = self.service_config.source # type: ignore[reportOptionalMemberAccess]
154
- request_id = self.service_config.request_id # type: ignore[reportOptionalMemberAccess]
167
+ generation_request_id = self.service_config.request_id # type: ignore[reportOptionalMemberAccess]
155
168
 
169
+ all_new_profiles = [p for result in results if result for p in result]
156
170
  existing_ids_to_delete: list[str] = []
157
171
 
158
172
  # Always run deduplicator when enabled and there are new profiles
@@ -170,7 +184,9 @@ class ProfileGenerationService(
170
184
  output_pending_status=self.output_pending_status,
171
185
  )
172
186
  all_new_profiles, existing_ids_to_delete, _superseded_profiles = (
173
- consolidator.deduplicate(all_new_profiles, user_id, request_id)
187
+ consolidator.deduplicate(
188
+ all_new_profiles, user_id, generation_request_id
189
+ )
174
190
  )
175
191
  logger.info(
176
192
  "Profile updates after deduplication: %d profiles, %d existing to delete",
@@ -183,60 +199,120 @@ class ProfileGenerationService(
183
199
  profile.source = source
184
200
  profile.status = Status.PENDING if self.output_pending_status else None
185
201
 
186
- # Save new profiles
202
+ # Missing-request_id guard (moved here, in compute). An empty request_id
203
+ # makes the supersede unreconstructable (the lineage events are keyed on
204
+ # it). Fail loud and drop those ids entirely — never silently
205
+ # hard-delete. Persist then only supersedes with a non-empty request_id.
206
+ if existing_ids_to_delete and not generation_request_id:
207
+ capture_anomaly(
208
+ "lineage.dedup.missing_request_id",
209
+ level="error",
210
+ org_id=self.org_id,
211
+ user_id=user_id,
212
+ )
213
+ existing_ids_to_delete = []
214
+
215
+ if not all_new_profiles and not existing_ids_to_delete:
216
+ return None
217
+
218
+ # Precompute embeddings on the new rows (compute-side, NO DB write). The
219
+ # persist half passes skip_embedding=True so no embedding runs in the fence.
187
220
  if all_new_profiles:
221
+ self.storage.precompute_profile_embeddings(all_new_profiles) # type: ignore[reportOptionalMemberAccess]
222
+
223
+ return ProfileWritePlan(
224
+ user_id=user_id,
225
+ request_id=generation_request_id,
226
+ new_profiles=all_new_profiles,
227
+ superseded_ids=existing_ids_to_delete,
228
+ )
229
+
230
+ def _persist_write_plan(self, plan: ProfileWritePlan) -> None:
231
+ """Persist-half of profile finalization — apply the resolved write-plan.
232
+
233
+ Issues only the fence-critical row writes: inserts the new profiles
234
+ (``skip_embedding=True`` — embeddings were precomputed in compute) then
235
+ soft-supersedes the dedup'd existing ids. NO LLM / embedding / dedup.
236
+ The soft-supersede emits the lineage events the profile change log is
237
+ reconstructed from (the legacy ``profile_change_logs`` table is no longer
238
+ written — see reconstruct_profile_change_log).
239
+
240
+ On a write failure this **re-raises** (symmetric with playbook
241
+ ``_persist_write_plan``): on the durable path the raise rolls back the
242
+ fenced ``commit_scope`` so the rows AND the extractor bookmark advance
243
+ (applied by ``persist_generation`` only if persist returns) are discarded
244
+ together — never a "write failed but bookmark advanced" window. On the
245
+ synchronous ``.run()`` path ``_run_generation`` catches it, records
246
+ ``generation_failed``, and leaves the bookmark un-advanced so the next
247
+ publish retries the window.
248
+ """
249
+ user_id = plan.user_id
250
+ generation_request_id = plan.request_id
251
+
252
+ # Save new profiles (embeddings already set → skip re-embedding).
253
+ if plan.new_profiles:
188
254
  try:
189
- self.storage.add_user_profile(user_id, all_new_profiles) # type: ignore[reportOptionalMemberAccess]
255
+ self.storage.add_user_profile( # type: ignore[reportOptionalMemberAccess]
256
+ user_id, plan.new_profiles, skip_embedding=True
257
+ )
190
258
  except Exception as e:
191
259
  with sentry_tags(
192
260
  subsystem="profile_generation",
193
261
  op="save_profiles",
194
262
  org_id=self.org_id,
195
263
  user_id=user_id,
196
- request_id=request_id,
264
+ request_id=generation_request_id,
197
265
  error_type=type(e).__name__,
198
266
  ):
199
267
  logger.exception(
200
268
  "Failed to save profiles for user id: %s",
201
269
  user_id,
202
270
  )
203
- return
204
-
205
- # Always soft-supersede superseded existing profiles (never hard-delete on
206
- # the dedup path). This emits the lineage events that the profile change log
207
- # is reconstructed from (the legacy `profile_change_logs` table is no longer
208
- # written see reconstruct_profile_change_log).
209
- if existing_ids_to_delete:
210
- if not request_id:
211
- # An empty request_id makes the removal unreconstructable (the lineage
212
- # events are keyed on it). Fail loud and skip removal entirely — never
213
- # silently hard-delete.
214
- capture_anomaly(
215
- "lineage.dedup.missing_request_id",
216
- level="error",
217
- org_id=self.org_id,
271
+ # Re-raise so the bookmark advance is skipped / the fence rolls
272
+ # back (F1 symmetry with playbook persist) — never advance the
273
+ # extractor bookmark over a window whose rows failed to write.
274
+ raise
275
+
276
+ # Always soft-supersede superseded existing profiles (never hard-delete
277
+ # on the dedup path). Compute already dropped these when request_id was
278
+ # empty, so any ids here carry a valid lineage key.
279
+ if plan.superseded_ids:
280
+ try:
281
+ self.storage.supersede_profiles_by_ids( # type: ignore[reportOptionalMemberAccess]
218
282
  user_id=user_id,
283
+ profile_ids=plan.superseded_ids,
284
+ request_id=generation_request_id,
219
285
  )
220
- else:
221
- try:
222
- self.storage.supersede_profiles_by_ids( # type: ignore[reportOptionalMemberAccess]
223
- user_id=user_id,
224
- profile_ids=existing_ids_to_delete,
225
- request_id=request_id,
286
+ except Exception as e:
287
+ with sentry_tags(
288
+ subsystem="profile_generation",
289
+ op="supersede_profiles",
290
+ org_id=self.org_id,
291
+ user_id=user_id,
292
+ request_id=generation_request_id,
293
+ error_type=type(e).__name__,
294
+ ):
295
+ logger.exception(
296
+ "Failed to soft-delete superseded profiles for user %s",
297
+ user_id,
226
298
  )
227
- except Exception as e:
228
- with sentry_tags(
229
- subsystem="profile_generation",
230
- op="supersede_profiles",
231
- org_id=self.org_id,
232
- user_id=user_id,
233
- request_id=request_id,
234
- error_type=type(e).__name__,
235
- ):
236
- logger.exception(
237
- "Failed to soft-delete superseded profiles for user %s",
238
- user_id,
239
- )
299
+ # Re-raise for the same reason: a half-applied persist (new rows
300
+ # in, supersede failed) must not advance the bookmark. Playbook's
301
+ # _apply_consolidation_lineage raises here too.
302
+ raise
303
+
304
+ def _finalize_extracted_items(self, all_new_profiles: list[UserProfile]) -> None:
305
+ """Permanent V3 wrapper: compute-then-persist together (no external fence).
306
+
307
+ Kept for the synchronous resume/manual callers
308
+ (``ExtractionResumeWorker`` calls this directly). Routes them through the
309
+ same ``_resolve_write_plan`` (compute) + ``_persist_write_plan``
310
+ (persist) split the durable worker uses — with no external
311
+ ``commit_scope`` — so the result is identical to the pre-split monolith.
312
+ """
313
+ plan = self._resolve_write_plan([all_new_profiles])
314
+ if plan is not None:
315
+ self._persist_write_plan(plan)
240
316
 
241
317
  def check_and_update_profiles(self, profiles: list[UserProfile]) -> None:
242
318
  """check if the profiles are expired and update them if they are"""
@@ -436,9 +512,10 @@ class ProfileGenerationService(
436
512
  """
437
513
  # Handle rerun requests (have start_time/end_time datetime objects)
438
514
  if isinstance(request, RerunProfileGenerationRequest):
515
+ operation_request_id = f"rerun_{uuid.uuid4().hex[:8]}"
439
516
  return ProfileGenerationRequest(
440
517
  user_id=user_id,
441
- request_id=f"rerun_{uuid.uuid4().hex[:8]}",
518
+ request_id=operation_request_id,
442
519
  source=request.source,
443
520
  rerun_start_time=(
444
521
  int(request.start_time.timestamp()) if request.start_time else None
@@ -449,9 +526,10 @@ class ProfileGenerationService(
449
526
  auto_run=False,
450
527
  )
451
528
  # Handle manual requests (ManualProfileGenerationRequest)
529
+ operation_request_id = f"manual_{uuid.uuid4().hex[:8]}"
452
530
  return ProfileGenerationRequest(
453
531
  user_id=user_id,
454
- request_id=f"manual_{uuid.uuid4().hex[:8]}",
532
+ request_id=operation_request_id,
455
533
  source=request.source,
456
534
  auto_run=False,
457
535
  )