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
@@ -2,46 +2,33 @@
2
2
  Base class for generation services
3
3
  """
4
4
 
5
- import contextvars
6
5
  import logging
7
- import os
8
6
  import re
9
7
  import time
10
8
  import uuid
11
9
  from abc import ABC, abstractmethod
12
- from concurrent.futures import ThreadPoolExecutor
13
- from concurrent.futures import TimeoutError as FuturesTimeoutError
14
10
  from dataclasses import dataclass
15
- from datetime import UTC, datetime, timedelta
16
- from enum import StrEnum
17
11
  from typing import Any, Generic, TypeVar
18
12
 
19
13
  from reflexio.models.api_schema.internal_schema import RequestInteractionDataModel
20
- from reflexio.models.api_schema.service_schemas import Status
21
14
  from reflexio.server.api_endpoints.request_context import RequestContext
22
15
  from reflexio.server.llm.litellm_client import LiteLLMClient
23
16
  from reflexio.server.llm.token_accounting import RunTokenTotals
24
- from reflexio.server.services.extraction.outcome import ExtractionOutcome
17
+ from reflexio.server.services.base_generation import (
18
+ BatchProgressMixin,
19
+ ConfigFilterMixin,
20
+ ExtractionRunLifecycleMixin,
21
+ ShouldRunPrecheckMixin,
22
+ StatusChangeMixin,
23
+ UsageBillingMixin,
24
+ )
25
+ from reflexio.server.services.base_generation import (
26
+ StatusChangeOperation as StatusChangeOperation, # re-export for back-compat
27
+ )
25
28
  from reflexio.server.services.extractor_config_utils import (
26
- filter_extractor_configs,
27
29
  get_extractor_name,
28
30
  )
29
- from reflexio.server.services.extractor_interaction_utils import (
30
- get_effective_source_filter,
31
- get_extractor_window_params,
32
- should_extractor_run_by_stride,
33
- )
34
31
  from reflexio.server.services.operation_state_utils import OperationStateManager
35
- from reflexio.server.services.service_utils import log_llm_messages, log_model_response
36
- from reflexio.server.services.storage.storage_base import AgentRunStatus
37
- from reflexio.server.usage_metrics import record_usage_event
38
-
39
-
40
- class StatusChangeOperation(StrEnum):
41
- """Operation type for upgrade/downgrade responses."""
42
-
43
- UPGRADE = "upgrade"
44
- DOWNGRADE = "downgrade"
45
32
 
46
33
 
47
34
  class ExtractorExecutionError(RuntimeError):
@@ -163,6 +150,12 @@ class PreparedGenerationRun(Generic[TExtractorConfig]): # noqa: UP046
163
150
 
164
151
  # Unified base class for all generation services (evaluation, playbook, profile)
165
152
  class BaseGenerationService(
153
+ BatchProgressMixin[TRequest],
154
+ UsageBillingMixin[TExtractorConfig, TGenerationServiceConfig],
155
+ ExtractionRunLifecycleMixin[TExtractorConfig, TGenerationServiceConfig],
156
+ ShouldRunPrecheckMixin[TExtractorConfig, TGenerationServiceConfig],
157
+ ConfigFilterMixin[TExtractorConfig, TGenerationServiceConfig],
158
+ StatusChangeMixin[TRequest],
166
159
  ABC,
167
160
  Generic[TExtractorConfig, TExtractor, TGenerationServiceConfig, TRequest], # noqa: UP046
168
161
  ):
@@ -220,187 +213,6 @@ class BaseGenerationService(
220
213
  # re-querying storage. None when the gate did not run (bypass paths).
221
214
  self._last_precheck_sessions: list[Any] | None = None
222
215
 
223
- def _usage_pipeline(self) -> str | None:
224
- service_name = self._get_service_name()
225
- if "profile" in service_name:
226
- return "profile"
227
- if "playbook" in service_name:
228
- return "playbook"
229
- if "evaluation" in service_name:
230
- return "evaluation"
231
- return None
232
-
233
- def _usage_context(self) -> dict[str, Any]:
234
- service_config = self.service_config
235
- return {
236
- "org_id": self.org_id,
237
- "user_id": getattr(service_config, "user_id", None),
238
- "request_id": getattr(service_config, "request_id", None),
239
- "source": getattr(service_config, "source", None),
240
- "agent_version": getattr(service_config, "agent_version", None),
241
- "pipeline": self._usage_pipeline(),
242
- }
243
-
244
- def _record_generation_event(
245
- self,
246
- *,
247
- event_name: str,
248
- outcome: str,
249
- count_value: int = 1,
250
- duration_ms: int | None = None,
251
- error_kind: str | None = None,
252
- metadata: dict[str, Any] | None = None,
253
- ) -> None:
254
- record_usage_event(
255
- **self._usage_context(),
256
- event_name=event_name,
257
- event_category="generation",
258
- outcome=outcome,
259
- count_value=count_value,
260
- duration_ms=duration_ms,
261
- error_kind=error_kind,
262
- metadata=metadata,
263
- )
264
-
265
- def _extraction_input_text(self, prepared: "PreparedGenerationRun[Any]") -> str:
266
- """Return the interaction content fed to the extractor as a single string.
267
-
268
- Replays the same storage query the extractor uses (same user_id, source,
269
- window params) so ``count_input_tokens`` sees the same text the LLM saw.
270
- Returns an empty string when storage is unavailable or no interactions exist.
271
-
272
- Args:
273
- prepared: The prepared generation run, used to access extractor_config
274
- for window/source parameters.
275
-
276
- Returns:
277
- str: Formatted interaction history string, or "" if nothing is found.
278
- """
279
- from reflexio.server.services.service_utils import (
280
- format_sessions_to_history_string,
281
- )
282
-
283
- if self.storage is None or self.service_config is None:
284
- return ""
285
- # Reuse the window the should-run gate already fetched
286
- # (_collect_scoped_interactions_for_precheck stashed it). Same query params,
287
- # so the token count is byte-identical — and we avoid a second storage read.
288
- if self._last_precheck_sessions is not None:
289
- return format_sessions_to_history_string(self._last_precheck_sessions)
290
- # Fallback storage read: used ONLY when the gate did not pre-fetch
291
- # (bypass paths: auto_run=False, force_extraction, skip_should_run_check,
292
- # mock mode). Re-fetches the extractor's input window so billing token
293
- # counting sees exactly the text the LLM saw.
294
- try:
295
- root_config = self.request_context.configurator.get_config()
296
- global_window_size = (
297
- getattr(root_config, "window_size", None) if root_config else None
298
- )
299
- global_stride_size = (
300
- getattr(root_config, "stride_size", None) if root_config else None
301
- )
302
- window_size, _ = get_extractor_window_params(
303
- prepared.extractor_config, global_window_size, global_stride_size
304
- )
305
- should_skip, effective_source = get_effective_source_filter(
306
- prepared.extractor_config, getattr(self.service_config, "source", None)
307
- )
308
- if should_skip:
309
- return ""
310
- session_data_models, _ = self.storage.get_last_k_interactions_grouped(
311
- user_id=getattr(self.service_config, "user_id", None),
312
- k=window_size,
313
- sources=effective_source,
314
- start_time=getattr(self.service_config, "rerun_start_time", None),
315
- end_time=getattr(self.service_config, "rerun_end_time", None),
316
- **self._get_precheck_interaction_query_kwargs(),
317
- )
318
- return format_sessions_to_history_string(session_data_models)
319
- except Exception:
320
- logger.warning(
321
- "_extraction_input_text failed; billing_input_tokens will be 0",
322
- exc_info=True,
323
- )
324
- return ""
325
-
326
- def _record_billing_learning_events(
327
- self, *, prepared: "PreparedGenerationRun[Any]", generated_count: int
328
- ) -> None:
329
- """Emit the ② Learning billing events. Called ONLY when extraction fired.
330
-
331
- Emits ``learnings_generated`` (value facet) and ``extraction_tokens``
332
- (cost facet) via the OSS emission helpers. ``platform_storage`` is left
333
- ``None`` here and resolved enterprise-side at rollup (Phase 1).
334
-
335
- Gated by ``EMITS_LEARNING_BILLING`` — only profile/playbook generation
336
- services opt in. Evaluation, reflection, consolidation and aggregation
337
- are bundled and must NOT separately meter the ② Learning line.
338
-
339
- Args:
340
- prepared: The prepared generation run (used for input-text computation).
341
- generated_count: Number of learnings produced by this extraction run.
342
- """
343
- if not self.EMITS_LEARNING_BILLING:
344
- return
345
-
346
- try:
347
- from reflexio.server.billing_meter import (
348
- record_extraction_tokens,
349
- record_learnings_generated,
350
- )
351
- from reflexio.server.billing_signals import (
352
- count_input_tokens,
353
- platform_llm_from_config,
354
- )
355
-
356
- config = self.request_context.configurator.get_config()
357
- platform_llm = platform_llm_from_config(config)
358
- ctx = self._usage_context()
359
-
360
- # session_id is intentionally not passed: the generation path has no
361
- # session_id source. _usage_context() never includes it, and neither
362
- # the Profile/Playbook service configs nor their requests carry a
363
- # session_id (unlike the Application-line path in server/api.py, which
364
- # reads request_id/session_id off the search request payload). Learning
365
- # events therefore meter without session attribution by design.
366
-
367
- # ② Learning — value: learnings generated (helper no-ops on count <= 0).
368
- record_learnings_generated(
369
- org_id=ctx["org_id"],
370
- count=generated_count,
371
- platform_llm=platform_llm,
372
- platform_storage=None,
373
- pipeline=ctx.get("pipeline"),
374
- request_id=ctx.get("request_id"),
375
- )
376
-
377
- # ② Learning — cost: input-anchored extraction tokens + real provider tokens.
378
- totals = self._last_token_totals or RunTokenTotals()
379
- billing_input_tokens = count_input_tokens(
380
- self._extraction_input_text(prepared)
381
- )
382
- record_extraction_tokens(
383
- org_id=ctx["org_id"],
384
- billing_input_tokens=billing_input_tokens,
385
- prompt_tokens=totals.prompt_tokens,
386
- completion_tokens=totals.completion_tokens,
387
- platform_llm=platform_llm,
388
- platform_storage=None,
389
- pipeline=ctx.get("pipeline"),
390
- request_id=ctx.get("request_id"),
391
- )
392
- except Exception:
393
- logger.warning(
394
- "_record_billing_learning_events failed; billing learning events not emitted",
395
- exc_info=True,
396
- )
397
-
398
- @staticmethod
399
- def _count_generated_results(result: Any) -> int:
400
- if isinstance(result, list):
401
- return len(result)
402
- return 1 if result else 0
403
-
404
216
  @abstractmethod
405
217
  def _load_extractor_config(self) -> TExtractorConfig | None:
406
218
  """
@@ -506,107 +318,6 @@ class BaseGenerationService(
506
318
  Optional[str]: Scope ID (e.g., user_id) or None for org-level scope
507
319
  """
508
320
 
509
- def _filter_extractor_config_by_service_config(
510
- self,
511
- extractor_config: TExtractorConfig,
512
- service_config: TGenerationServiceConfig,
513
- ) -> TExtractorConfig | None:
514
- """
515
- Filter the extractor config based on request_sources_enabled and manual_trigger.
516
- """
517
- filtered = filter_extractor_configs(
518
- extractor_configs=[extractor_config],
519
- source=getattr(service_config, "source", None),
520
- allow_manual_trigger=getattr(service_config, "allow_manual_trigger", False),
521
- )
522
- return filtered[0] if filtered else None
523
-
524
- def _get_extractor_state_service_name(self) -> str | None:
525
- """
526
- Get the service name used for extractor state (stride_size bookmark) lookups.
527
-
528
- Override in subclasses that support stride_size-based pre-filtering to return
529
- the OperationStateManager service name (e.g., "profile_extractor", "playbook_extractor").
530
- Returns None by default, meaning stride_size pre-filtering is skipped.
531
-
532
- Returns:
533
- Optional[str]: Service name for OperationStateManager, or None to skip stride_size pre-filtering
534
- """
535
- return None
536
-
537
- def _filter_config_by_stride(
538
- self, extractor_config: TExtractorConfig
539
- ) -> TExtractorConfig | None:
540
- """
541
- Filter extractor config by stride_size check before the should_run LLM call.
542
-
543
- Skips filtering when:
544
- - _get_extractor_state_service_name() returns None (service doesn't support stride_size)
545
- - auto_run is False (rerun/manual flows skip stride_size)
546
-
547
- Args:
548
- extractor_config: Extractor config after source/manual_trigger filtering
549
-
550
- Returns:
551
- Extractor config when it passes the stride_size check, otherwise None.
552
- """
553
- state_service_name = self._get_extractor_state_service_name()
554
- if state_service_name is None:
555
- return extractor_config
556
-
557
- if not getattr(self.service_config, "auto_run", True):
558
- return extractor_config
559
-
560
- if getattr(self.service_config, "force_extraction", False):
561
- return extractor_config
562
-
563
- root_config = self.request_context.configurator.get_config()
564
- global_window_size = (
565
- getattr(root_config, "window_size", None) if root_config else None
566
- )
567
- global_stride_size = (
568
- getattr(root_config, "stride_size", None) if root_config else None
569
- )
570
-
571
- state_manager = OperationStateManager(
572
- self.storage, # type: ignore[reportArgumentType]
573
- self.org_id,
574
- state_service_name, # type: ignore[reportArgumentType]
575
- )
576
-
577
- name = get_extractor_name(extractor_config)
578
- _, stride_size = get_extractor_window_params(
579
- extractor_config, global_window_size, global_stride_size
580
- )
581
-
582
- # Resolve effective source filter for this extractor
583
- should_skip, effective_source = get_effective_source_filter(
584
- extractor_config, getattr(self.service_config, "source", None)
585
- )
586
- if should_skip:
587
- return None
588
-
589
- (
590
- _,
591
- new_interactions,
592
- ) = state_manager.get_extractor_state_with_new_interactions(
593
- extractor_name=name,
594
- user_id=getattr(self.service_config, "user_id", None),
595
- sources=effective_source,
596
- )
597
- new_count = sum(len(ri.interactions) for ri in new_interactions)
598
-
599
- if should_extractor_run_by_stride(new_count, stride_size):
600
- return extractor_config
601
-
602
- logger.info(
603
- "Stride pre-filter: skipping extractor '%s' (new=%d, stride_size=%s)",
604
- name,
605
- new_count,
606
- stride_size,
607
- )
608
- return None
609
-
610
321
  # ===============================
611
322
  # In-progress state management via OperationStateManager
612
323
  # ===============================
@@ -949,880 +660,3 @@ class BaseGenerationService(
949
660
  extractor_name=extractor_name,
950
661
  identifier=identifier,
951
662
  )
952
-
953
- def _execute_extractor(
954
- self,
955
- extractor_config: TExtractorConfig,
956
- identifier: str,
957
- ) -> Any | None:
958
- """
959
- Run the configured extractor with timeout and error handling.
960
-
961
- The extractor runs in a thread pool with a timeout guard so providers that
962
- ignore their own timeout cannot block generation forever.
963
-
964
- Args:
965
- extractor_config: Filtered extractor config to execute
966
- identifier: Logging context identifier (user_id or request_id)
967
-
968
- Returns:
969
- Extractor result, or None if the extractor succeeded with no output.
970
-
971
- Raises:
972
- ExtractorExecutionError: If the extractor fails with an exception or timeout.
973
- """
974
- if (
975
- self.service_config is None
976
- ): # pragma: no cover — set by _prepare_generation_run
977
- raise RuntimeError("service_config must be set before executing extractor")
978
-
979
- self._last_extractor_run_stats = {"total": 1, "failed": 0, "timed_out": 0}
980
- extractor = self._create_extractor(extractor_config, self.service_config)
981
- executor: ThreadPoolExecutor | None = None
982
- try:
983
- executor = ThreadPoolExecutor(max_workers=1)
984
- # Copy context so correlation ID propagates to worker thread
985
- ctx = contextvars.copy_context()
986
- future = executor.submit(ctx.run, extractor.run) # type: ignore[reportAttributeAccessIssue]
987
- result = future.result(timeout=EXTRACTOR_TIMEOUT_SECONDS)
988
- if isinstance(result, ExtractionOutcome):
989
- if result.run_id:
990
- self._last_extraction_run_ids.append(result.run_id)
991
- self._last_token_totals = result.token_totals
992
- if result.status == "completed" and result.items:
993
- return result.items
994
- logger.info(
995
- "No results generated for %s identifier: %s",
996
- self._get_service_name(),
997
- identifier,
998
- )
999
- return None
1000
- if result:
1001
- return result
1002
- logger.info(
1003
- "No results generated for %s identifier: %s",
1004
- self._get_service_name(),
1005
- identifier,
1006
- )
1007
- return None
1008
- except FuturesTimeoutError as exc:
1009
- self._last_extractor_run_stats = {"total": 1, "failed": 1, "timed_out": 1}
1010
- error_msg = (
1011
- f"Extractor timed out after {EXTRACTOR_TIMEOUT_SECONDS} seconds "
1012
- f"for {self._get_service_name()} identifier={identifier}"
1013
- )
1014
- logger.error(error_msg)
1015
- self._fail_active_extraction_runs(
1016
- extractor_kind=get_extractor_name(extractor_config),
1017
- last_error=error_msg,
1018
- )
1019
- raise ExtractorExecutionError(error_msg) from exc
1020
- except Exception as exc:
1021
- self._last_extractor_run_stats = {"total": 1, "failed": 1, "timed_out": 0}
1022
- error_msg = (
1023
- f"Extractor failed for {self._get_service_name()} "
1024
- f"identifier={identifier}: {exc} (type={type(exc).__name__})"
1025
- )
1026
- logger.error(error_msg)
1027
- raise ExtractorExecutionError(error_msg) from exc
1028
- finally:
1029
- if executor is not None:
1030
- executor.shutdown(wait=False, cancel_futures=True)
1031
-
1032
- def _fail_active_extraction_runs(
1033
- self,
1034
- *,
1035
- extractor_kind: str,
1036
- last_error: str,
1037
- ) -> None:
1038
- """Mark active agent-run rows failed when the service timeout fires."""
1039
- if self.storage is None or self.service_config is None:
1040
- return
1041
- request_id = getattr(self.service_config, "request_id", None)
1042
- if not request_id:
1043
- return
1044
- user_id = getattr(self.service_config, "user_id", None)
1045
- try:
1046
- failed_count = self.storage.fail_running_agent_runs_for_request(
1047
- org_id=self.request_context.org_id,
1048
- extractor_kind=extractor_kind,
1049
- user_id=user_id,
1050
- request_id=request_id,
1051
- last_error=last_error,
1052
- )
1053
- except NotImplementedError:
1054
- return
1055
- except Exception as exc: # noqa: BLE001 - keep timeout error primary
1056
- logger.warning(
1057
- "Failed to mark timed-out %s agent runs failed: %s",
1058
- extractor_kind,
1059
- exc,
1060
- )
1061
- return
1062
- if failed_count:
1063
- logger.warning(
1064
- "Marked %d timed-out %s agent run(s) failed for request_id=%s",
1065
- failed_count,
1066
- extractor_kind,
1067
- request_id,
1068
- )
1069
-
1070
- def _finalize_extraction_runs(self) -> None:
1071
- if self.storage is None:
1072
- return
1073
- for run_id in self._last_extraction_run_ids:
1074
- run = self.storage.get_agent_run(run_id)
1075
- if run is None:
1076
- continue
1077
- status = (
1078
- AgentRunStatus.FINALIZED_PENDING_TOOL
1079
- if run.pending_tool_call_ids
1080
- else AgentRunStatus.FINALIZED
1081
- )
1082
- self.storage.update_agent_run_status(
1083
- run_id,
1084
- status,
1085
- pending_tool_call_ids=run.pending_tool_call_ids,
1086
- )
1087
-
1088
- def _mark_extraction_runs_finalization_failed(self, exc: Exception) -> None:
1089
- if self.storage is None:
1090
- return
1091
- root_config = self.request_context.configurator.get_config()
1092
- pending_config = getattr(root_config, "pending_tool_call_config", None)
1093
- for run_id in self._last_extraction_run_ids:
1094
- run = self.storage.get_agent_run(run_id)
1095
- if run is None or run.committed_output is None:
1096
- continue
1097
- next_attempt_count = run.finalization_attempts + 1
1098
- max_attempts = (
1099
- pending_config.max_finalization_attempts
1100
- if pending_config is not None
1101
- else 3
1102
- )
1103
- status = (
1104
- AgentRunStatus.FAILED
1105
- if next_attempt_count >= max_attempts
1106
- else AgentRunStatus.FINALIZATION_FAILED
1107
- )
1108
- delay_seconds = min(300, max(1, 2 ** max(0, next_attempt_count - 1)))
1109
- self.storage.update_agent_run_status(
1110
- run_id,
1111
- status,
1112
- next_resume_at=datetime.now(UTC) + timedelta(seconds=delay_seconds),
1113
- last_error=str(exc),
1114
- increment_finalization_attempts=True,
1115
- )
1116
-
1117
- def _should_run_before_extraction(self, extractor_config: TExtractorConfig) -> bool:
1118
- """
1119
- Pre-extraction check called before extractor execution.
1120
-
1121
- Template method that:
1122
- 1. Skips for non-auto runs and mock mode
1123
- 2. Returns True immediately when service_config.force_extraction=True
1124
- (bypasses cheap pre-filter and LLM should_run vote)
1125
- 3. Collects scoped interactions via _collect_scoped_interactions_for_precheck
1126
- 4. Delegates prompt building to _build_should_run_prompt (subclass hook)
1127
- 5. Makes a single LLM call to determine if extraction should proceed
1128
-
1129
- Override _build_should_run_prompt in subclasses to provide service-specific
1130
- criteria and prompt construction. Default returns True (always run) when
1131
- no prompt hook is provided.
1132
-
1133
- Args:
1134
- extractor_config: Enabled extractor config that will be run
1135
-
1136
- Returns:
1137
- bool: True if extraction should proceed, False to skip
1138
- """
1139
- # Skip for non-auto runs (rerun/manual flows always run)
1140
- if not getattr(self.service_config, "auto_run", True):
1141
- return True
1142
-
1143
- # Skip for mock mode
1144
- if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
1145
- return True
1146
-
1147
- # `force_extraction=True` is the caller's explicit "no gates" signal —
1148
- # corrections, manual /learn, anything time-sensitive. Bypass the
1149
- # cheap pre-filter (slash-only / too-short rejects) and the LLM
1150
- # should_run vote so the extractor always runs on this batch.
1151
- if getattr(self.service_config, "force_extraction", False):
1152
- return True
1153
-
1154
- # Skip if org config disables the pre-extraction check
1155
- root_config = self.request_context.configurator.get_config()
1156
- if root_config and root_config.skip_should_run_check:
1157
- logger.info(
1158
- "skip_should_run_check is enabled for %s, bypassing pre-extraction check",
1159
- self._get_service_name(),
1160
- )
1161
- return True
1162
-
1163
- # Collect scoped interactions
1164
- session_data_models, scoped_config = (
1165
- self._collect_scoped_interactions_for_precheck(extractor_config)
1166
- )
1167
- if not session_data_models:
1168
- logger.info(
1169
- "No interactions found for consolidated should_generate check for %s",
1170
- self._get_service_name(),
1171
- )
1172
- return False
1173
-
1174
- # Cheap pre-filter: reject batches that are structurally unable
1175
- # to yield signal (slash-commands only, too-short user turns,
1176
- # extractor-prompt echoes) without burning a 5–7s LLM call. See
1177
- # _cheap_should_run_reject for the rule set.
1178
- reject_reason = _cheap_should_run_reject(session_data_models)
1179
- if reject_reason is not None:
1180
- logger.info(
1181
- "Cheap pre-filter rejected %s should_run: reason=%s identifier=%s",
1182
- self._get_service_name(),
1183
- reject_reason,
1184
- getattr(self.service_config, "user_id", None) or "unknown",
1185
- )
1186
- return False
1187
-
1188
- # Build prompt via subclass hook
1189
- prompt = self._build_should_run_prompt(scoped_config, session_data_models)
1190
- if not prompt:
1191
- return True # No prompt means no check needed, proceed
1192
-
1193
- # Resolve model and make LLM call
1194
- should_run_model = self._resolve_should_run_model()
1195
- identifier = getattr(self.service_config, "user_id", None) or "unknown"
1196
- try:
1197
- should_start = time.perf_counter()
1198
- logger.info(
1199
- "event=consolidated_should_run_start service=%s identifier=%s model=%s extractor=%s",
1200
- self._get_service_name(),
1201
- identifier,
1202
- should_run_model,
1203
- get_extractor_name(extractor_config),
1204
- )
1205
- log_llm_messages(
1206
- logger,
1207
- "Should extract check",
1208
- [{"role": "user", "content": prompt}],
1209
- )
1210
-
1211
- content = self.client.generate_chat_response(
1212
- messages=[{"role": "user", "content": prompt}],
1213
- model=should_run_model,
1214
- )
1215
- log_model_response(
1216
- logger,
1217
- f"Consolidated {self._get_service_name()} should_run response",
1218
- content,
1219
- )
1220
- decision = bool(content and "true" in content.lower()) # type: ignore[reportAttributeAccessIssue]
1221
- logger.info(
1222
- "event=consolidated_should_run_end service=%s identifier=%s elapsed_seconds=%.3f decision=%s",
1223
- self._get_service_name(),
1224
- identifier,
1225
- time.perf_counter() - should_start,
1226
- decision,
1227
- )
1228
- return decision
1229
- except Exception as exc:
1230
- logger.error(
1231
- "Consolidated should_generate check failed for %s: %s, defaulting to run",
1232
- self._get_service_name(),
1233
- str(exc),
1234
- )
1235
- return True
1236
-
1237
- def _build_should_run_prompt(
1238
- self,
1239
- scoped_config: TExtractorConfig, # noqa: ARG002
1240
- session_data_models: list[RequestInteractionDataModel], # noqa: ARG002
1241
- ) -> str | None:
1242
- """
1243
- Build the prompt for the consolidated should_run LLM check.
1244
-
1245
- Override in subclasses to provide service-specific criteria building
1246
- and prompt rendering. Return None if no check is needed (always proceed).
1247
-
1248
- Args:
1249
- scoped_config: Extractor config that had scoped interactions
1250
- session_data_models: Deduplicated request interaction data models
1251
-
1252
- Returns:
1253
- Optional[str]: The rendered prompt string, or None to skip the check
1254
- """
1255
- return None
1256
-
1257
- def _collect_scoped_interactions_for_precheck(
1258
- self, extractor_config: TExtractorConfig
1259
- ) -> tuple[list[RequestInteractionDataModel], TExtractorConfig]:
1260
- """
1261
- Collect interactions for consolidated pre-check using extractor-scoped filters.
1262
-
1263
- Mirrors each extractor's source/window scope so the consolidated gate
1264
- does not skip valid extraction because of an unrelated fixed interaction slice.
1265
-
1266
- Args:
1267
- extractor_config: Enabled extractor config after request-level filtering
1268
-
1269
- Returns:
1270
- tuple: (session data models, extractor config)
1271
- """
1272
- root_config = self.request_context.configurator.get_config()
1273
- global_window_size = (
1274
- getattr(root_config, "window_size", None) if root_config else None
1275
- )
1276
- global_stride_size = (
1277
- getattr(root_config, "stride_size", None) if root_config else None
1278
- )
1279
-
1280
- extra_kwargs = self._get_precheck_interaction_query_kwargs()
1281
-
1282
- should_skip, effective_source = get_effective_source_filter(
1283
- extractor_config, getattr(self.service_config, "source", None)
1284
- )
1285
- if should_skip:
1286
- # Stash for the billing path to reuse (same window the gate saw).
1287
- self._last_precheck_sessions = []
1288
- return [], extractor_config
1289
-
1290
- window_size, _ = get_extractor_window_params(
1291
- extractor_config, global_window_size, global_stride_size
1292
- )
1293
- session_data_models, _ = self.storage.get_last_k_interactions_grouped( # type: ignore[reportOptionalMemberAccess]
1294
- user_id=getattr(self.service_config, "user_id", None),
1295
- k=window_size,
1296
- sources=effective_source,
1297
- start_time=getattr(self.service_config, "rerun_start_time", None),
1298
- end_time=getattr(self.service_config, "rerun_end_time", None),
1299
- **extra_kwargs,
1300
- )
1301
-
1302
- # Stash for the billing path (_extraction_input_text) to reuse instead of
1303
- # re-querying storage for billing_input_tokens.
1304
- self._last_precheck_sessions = session_data_models
1305
- return session_data_models, extractor_config
1306
-
1307
- def _get_precheck_interaction_query_kwargs(self) -> dict:
1308
- """
1309
- Return extra keyword arguments for get_last_k_interactions_grouped in precheck.
1310
-
1311
- Override in subclasses that need additional query parameters
1312
- (e.g., agent_version for playbook services).
1313
-
1314
- Returns:
1315
- dict: Extra kwargs to pass to get_last_k_interactions_grouped
1316
- """
1317
- return {}
1318
-
1319
- def _resolve_should_run_model(self) -> str:
1320
- """
1321
- Resolve the model name for should_run/should_generate LLM checks.
1322
-
1323
- Uses LLM config override if available, falls back to site var setting.
1324
-
1325
- Returns:
1326
- str: Model name for the should_run check
1327
- """
1328
- from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
1329
- from reflexio.server.site_var.site_var_manager import SiteVarManager
1330
-
1331
- root_config = self.request_context.configurator.get_config()
1332
- llm_config = root_config.llm_config if root_config else None
1333
- api_key_config = root_config.api_key_config if root_config else None
1334
-
1335
- model_setting = SiteVarManager().get_site_var("llm_model_setting")
1336
- site_var = model_setting if isinstance(model_setting, dict) else {}
1337
-
1338
- return resolve_model_name(
1339
- ModelRole.SHOULD_RUN,
1340
- site_var_value=site_var.get("should_run_model_name"),
1341
- config_override=llm_config.should_run_model_name if llm_config else None,
1342
- api_key_config=api_key_config,
1343
- )
1344
-
1345
- # ===============================
1346
- # Batch with progress (shared by rerun + manual)
1347
- # ===============================
1348
-
1349
- def _run_batch_with_progress(
1350
- self,
1351
- user_ids: list[str],
1352
- request: TRequest,
1353
- request_params: dict,
1354
- state_manager: OperationStateManager,
1355
- ) -> tuple[int, int]:
1356
- """Run a batch of users with progress tracking.
1357
-
1358
- Shared logic for both run_rerun() and run_manual_regular().
1359
- Initializes progress, processes each user, and finalizes.
1360
- Checks for cancellation before each user.
1361
-
1362
- Args:
1363
- user_ids: List of user IDs to process
1364
- request: The original request object
1365
- request_params: Parameters dict for progress state
1366
- state_manager: OperationStateManager instance
1367
-
1368
- Returns:
1369
- Tuple of (users_processed, total_generated)
1370
- """
1371
- total_users = len(user_ids)
1372
- self._is_batch_mode = True
1373
-
1374
- # Initialize progress
1375
- state_manager.initialize_progress(
1376
- total_users=total_users,
1377
- request_params=request_params,
1378
- )
1379
-
1380
- try:
1381
- # Process each user
1382
- users_processed = 0
1383
- processed_user_ids: list[str] = []
1384
- for user_id in user_ids:
1385
- # Check for cancellation before starting next user
1386
- if state_manager.is_cancellation_requested():
1387
- logger.info(
1388
- "Cancellation requested for %s, stopping after %d/%d users",
1389
- self._get_base_service_name(),
1390
- users_processed,
1391
- total_users,
1392
- )
1393
- state_manager.mark_cancelled()
1394
- return users_processed, self._get_generated_count(
1395
- request, processed_user_ids=processed_user_ids
1396
- )
1397
-
1398
- state_manager.set_current_item(user_id)
1399
-
1400
- try:
1401
- run_request = self._create_run_request_for_item(user_id, request)
1402
- self.run(run_request)
1403
- users_processed += 1
1404
- processed_user_ids.append(user_id)
1405
-
1406
- state_manager.update_progress(
1407
- item_id=user_id,
1408
- count=0, # Extractors collect their own data
1409
- success=True,
1410
- total_users=total_users,
1411
- )
1412
-
1413
- except Exception as e:
1414
- logger.error(
1415
- "Failed to process user %s for %s: %s",
1416
- user_id,
1417
- self._get_base_service_name(),
1418
- str(e),
1419
- )
1420
- state_manager.update_progress(
1421
- item_id=user_id,
1422
- count=0,
1423
- success=False,
1424
- total_users=total_users,
1425
- error=str(e),
1426
- )
1427
- continue
1428
-
1429
- # Get generated count and finalize
1430
- total_generated = self._get_generated_count(
1431
- request, processed_user_ids=processed_user_ids
1432
- )
1433
- state_manager.finalize_progress(users_processed, total_generated)
1434
-
1435
- return users_processed, total_generated
1436
- finally:
1437
- self._is_batch_mode = False
1438
-
1439
- # ===============================
1440
- # Rerun methods (optional - override to enable rerun functionality)
1441
- # ===============================
1442
-
1443
- def _get_rerun_user_ids(self, request: TRequest) -> list[str]:
1444
- """Get user IDs to process during rerun.
1445
-
1446
- Override this method to enable rerun functionality for the service.
1447
- Returns a list of user IDs that have interactions matching the request filters.
1448
- Each extractor collects its own data using its configured window_size.
1449
-
1450
- Args:
1451
- request: The rerun request object
1452
-
1453
- Returns:
1454
- List of user IDs to process
1455
- """
1456
- raise NotImplementedError("Rerun not supported by this service")
1457
-
1458
- def _build_rerun_request_params(self, request: TRequest) -> dict:
1459
- """Build request params dict for operation state tracking.
1460
-
1461
- Override this method to enable rerun functionality for the service.
1462
-
1463
- Args:
1464
- request: The rerun request object
1465
-
1466
- Returns:
1467
- Dictionary of request parameters for state tracking
1468
- """
1469
- raise NotImplementedError("Rerun not supported by this service")
1470
-
1471
- def _create_run_request_for_item(self, user_id: str, request: TRequest) -> TRequest:
1472
- """Create the request object to pass to self.run() for a single user.
1473
-
1474
- Override this method to enable rerun functionality for the service.
1475
- Each extractor collects its own data using its configured window_size.
1476
-
1477
- Args:
1478
- user_id: The user ID to process
1479
- request: The original rerun request object
1480
-
1481
- Returns:
1482
- A request object suitable for self.run()
1483
- """
1484
- raise NotImplementedError("Rerun not supported by this service")
1485
-
1486
- def _create_rerun_response(self, success: bool, msg: str, count: int) -> Any:
1487
- """Create the rerun response object.
1488
-
1489
- Override this method to enable rerun functionality for the service.
1490
-
1491
- Args:
1492
- success: Whether the operation succeeded
1493
- msg: Status message
1494
- count: Number of items generated
1495
-
1496
- Returns:
1497
- A response object (e.g., RerunProfileGenerationResponse)
1498
- """
1499
- raise NotImplementedError("Rerun not supported by this service")
1500
-
1501
- def _get_generated_count(
1502
- self,
1503
- request: TRequest,
1504
- processed_user_ids: list[str] | None = None,
1505
- ) -> int:
1506
- """Get the count of generated items (profiles or playbooks) after rerun.
1507
-
1508
- Override this method to enable rerun functionality for the service.
1509
-
1510
- Args:
1511
- request: The rerun request object (for filtering)
1512
- processed_user_ids: List of user IDs that were successfully processed
1513
- in the batch. Provided by _run_batch_with_progress so overrides
1514
- don't need to handle user_id=None from batch requests.
1515
-
1516
- Returns:
1517
- Number of items generated during rerun
1518
- """
1519
- raise NotImplementedError("Rerun not supported by this service")
1520
-
1521
- def _pre_process_rerun(self, request: TRequest) -> None: # noqa: B027
1522
- """Hook called before processing rerun items.
1523
-
1524
- Override in subclasses to perform cleanup or preparation before rerun.
1525
- Default implementation does nothing.
1526
-
1527
- Args:
1528
- request: The rerun request object
1529
- """
1530
-
1531
- def run_rerun(self, request: TRequest) -> Any:
1532
- """Run the rerun workflow for the service.
1533
-
1534
- This template method orchestrates the rerun process:
1535
- 1. Check for existing in-progress operations
1536
- 2. Get user IDs to process
1537
- 3. Pre-process hook
1538
- 4. Run batch with progress tracking
1539
- 5. Return response
1540
-
1541
- Child classes must implement the hook methods to enable rerun functionality:
1542
- - _get_rerun_user_ids()
1543
- - _build_rerun_request_params()
1544
- - _create_run_request_for_item()
1545
- - _create_rerun_response()
1546
-
1547
- Args:
1548
- request: The rerun request object
1549
-
1550
- Returns:
1551
- A response object with success status, message, and count
1552
- """
1553
- state_manager = self._create_state_manager()
1554
-
1555
- try:
1556
- # 1. Check for existing in-progress operation
1557
- error = state_manager.check_in_progress()
1558
- if error:
1559
- return self._create_rerun_response(False, error, 0)
1560
-
1561
- # 2. Get user IDs to process
1562
- user_ids = self._get_rerun_user_ids(request)
1563
- if not user_ids:
1564
- return self._create_rerun_response(
1565
- False, "No interactions found matching the specified filters", 0
1566
- )
1567
-
1568
- # 3. Pre-process hook (e.g., delete existing pending items)
1569
- self._pre_process_rerun(request)
1570
-
1571
- # 4. Run batch with progress tracking
1572
- users_processed, total_generated = self._run_batch_with_progress(
1573
- user_ids=user_ids,
1574
- request=request,
1575
- request_params=self._build_rerun_request_params(request),
1576
- state_manager=state_manager,
1577
- )
1578
-
1579
- msg = f"Completed for {users_processed} user(s)"
1580
- return self._create_rerun_response(True, msg, total_generated)
1581
-
1582
- except Exception as e:
1583
- state_manager.mark_progress_failed(str(e))
1584
- return self._create_rerun_response(
1585
- False,
1586
- f"Failed to run {self._get_base_service_name()}: {str(e)}",
1587
- 0,
1588
- )
1589
-
1590
- # ===============================
1591
- # Upgrade/Downgrade methods (optional - override to enable)
1592
- # ===============================
1593
-
1594
- def _has_items_with_status(self, status: Status | None, request: TRequest) -> bool:
1595
- """Check if items exist with given status and filters from request.
1596
-
1597
- Override this method to enable upgrade/downgrade functionality for the service.
1598
-
1599
- Args:
1600
- status: The status to check for (None for CURRENT)
1601
- request: The upgrade/downgrade request object with filters
1602
-
1603
- Returns:
1604
- bool: True if any matching items exist
1605
- """
1606
- raise NotImplementedError("Upgrade/downgrade not supported by this service")
1607
-
1608
- def _delete_items_by_status(self, status: Status, request: TRequest) -> int:
1609
- """Delete items with given status matching request filters.
1610
-
1611
- Override this method to enable upgrade/downgrade functionality for the service.
1612
-
1613
- Args:
1614
- status: The status of items to delete
1615
- request: The upgrade/downgrade request object with filters
1616
-
1617
- Returns:
1618
- int: Number of items deleted
1619
- """
1620
- raise NotImplementedError("Upgrade/downgrade not supported by this service")
1621
-
1622
- def _update_items_status(
1623
- self,
1624
- old_status: Status | None,
1625
- new_status: Status | None,
1626
- request: TRequest,
1627
- user_ids: list[str] | None = None,
1628
- ) -> int:
1629
- """Update items from old_status to new_status with request filters.
1630
-
1631
- Override this method to enable upgrade/downgrade functionality for the service.
1632
-
1633
- Args:
1634
- old_status: The current status to match (None for CURRENT)
1635
- new_status: The new status to set (None for CURRENT)
1636
- request: The upgrade/downgrade request object with filters
1637
- user_ids: Optional pre-computed list of user IDs to filter by
1638
-
1639
- Returns:
1640
- int: Number of items updated
1641
- """
1642
- raise NotImplementedError("Upgrade/downgrade not supported by this service")
1643
-
1644
- def _get_affected_user_ids_for_upgrade(self, request: TRequest) -> list[str] | None: # noqa: ARG002
1645
- """Get user IDs to filter by for upgrade operations.
1646
-
1647
- Override this method to support the only_affected_users flag.
1648
- By default returns None (no filtering).
1649
-
1650
- Args:
1651
- request: The upgrade request object
1652
-
1653
- Returns:
1654
- Optional[list[str]]: List of user IDs to filter by, or None for no filtering
1655
- """
1656
- return None
1657
-
1658
- def _get_affected_user_ids_for_downgrade(
1659
- self,
1660
- request: TRequest, # noqa: ARG002
1661
- ) -> list[str] | None:
1662
- """Get user IDs to filter by for downgrade operations.
1663
-
1664
- Override this method to support the only_affected_users flag.
1665
- By default returns None (no filtering).
1666
-
1667
- Args:
1668
- request: The downgrade request object
1669
-
1670
- Returns:
1671
- Optional[list[str]]: List of user IDs to filter by, or None for no filtering
1672
- """
1673
- return None
1674
-
1675
- def _create_status_change_response(
1676
- self,
1677
- operation: StatusChangeOperation,
1678
- success: bool,
1679
- counts: dict,
1680
- msg: str,
1681
- ) -> Any:
1682
- """Create upgrade or downgrade response object based on operation type.
1683
-
1684
- Override this method to enable upgrade/downgrade functionality for the service.
1685
-
1686
- Args:
1687
- operation: The operation type (UPGRADE or DOWNGRADE)
1688
- success: Whether the operation succeeded
1689
- counts: Dictionary of counts (upgrade: deleted/archived/promoted, downgrade: demoted/restored)
1690
- msg: Status message
1691
-
1692
- Returns:
1693
- A response object (e.g., UpgradeProfilesResponse, DowngradeUserPlaybooksResponse)
1694
- """
1695
- raise NotImplementedError("Upgrade/downgrade not supported by this service")
1696
-
1697
- def run_upgrade(self, request: TRequest) -> Any:
1698
- """Run the upgrade workflow for the service.
1699
-
1700
- This template method orchestrates the upgrade process:
1701
- 1. Validate that pending items exist
1702
- 2. Delete old archived items
1703
- 3. Archive current items (None → ARCHIVED)
1704
- 4. Promote pending items (PENDING → None/CURRENT)
1705
-
1706
- Child classes must implement the hook methods to enable upgrade functionality:
1707
- - _has_items_with_status()
1708
- - _delete_items_by_status()
1709
- - _update_items_status()
1710
- - _create_status_change_response()
1711
-
1712
- Args:
1713
- request: The upgrade request object with optional filters
1714
-
1715
- Returns:
1716
- A response object with success status, counts, and message
1717
- """
1718
- try:
1719
- # 1. Validate pending items exist
1720
- if not self._has_items_with_status(Status.PENDING, request):
1721
- return self._create_status_change_response(
1722
- StatusChangeOperation.UPGRADE,
1723
- False,
1724
- {"deleted": 0, "archived": 0, "promoted": 0},
1725
- "No pending items found to upgrade",
1726
- )
1727
-
1728
- # Get affected user IDs once (child class determines the logic)
1729
- affected_user_ids = self._get_affected_user_ids_for_upgrade(request)
1730
-
1731
- # 2. Delete old archived items (skip if archive_current=False)
1732
- deleted = 0
1733
- archived = 0
1734
- if getattr(request, "archive_current", True):
1735
- deleted = self._delete_items_by_status(Status.ARCHIVED, request)
1736
-
1737
- # 3. Archive current items (None → ARCHIVED)
1738
- archived = self._update_items_status(
1739
- None, Status.ARCHIVED, request, user_ids=affected_user_ids
1740
- )
1741
-
1742
- # 4. Promote pending items (PENDING → None)
1743
- promoted = self._update_items_status(
1744
- Status.PENDING, None, request, user_ids=affected_user_ids
1745
- )
1746
-
1747
- msg = f"Upgraded: {promoted} promoted, {archived} archived, {deleted} old archived deleted"
1748
- return self._create_status_change_response(
1749
- StatusChangeOperation.UPGRADE,
1750
- True,
1751
- {"deleted": deleted, "archived": archived, "promoted": promoted},
1752
- msg,
1753
- )
1754
-
1755
- except Exception as e:
1756
- return self._create_status_change_response(
1757
- StatusChangeOperation.UPGRADE,
1758
- False,
1759
- {"deleted": 0, "archived": 0, "promoted": 0},
1760
- f"Failed to upgrade: {str(e)}",
1761
- )
1762
-
1763
- def run_downgrade(self, request: TRequest) -> Any:
1764
- """Run the downgrade workflow for the service.
1765
-
1766
- This template method orchestrates the downgrade process:
1767
- 1. Validate that archived items exist
1768
- 2. Demote current items (None → ARCHIVE_IN_PROGRESS)
1769
- 3. Restore archived items (ARCHIVED → None/CURRENT)
1770
- 4. Complete archiving (ARCHIVE_IN_PROGRESS → ARCHIVED)
1771
-
1772
- Child classes must implement the hook methods to enable downgrade functionality:
1773
- - _has_items_with_status()
1774
- - _update_items_status()
1775
- - _create_status_change_response()
1776
-
1777
- Args:
1778
- request: The downgrade request object with optional filters
1779
-
1780
- Returns:
1781
- A response object with success status, counts, and message
1782
- """
1783
- try:
1784
- # 1. Validate archived items exist
1785
- if not self._has_items_with_status(Status.ARCHIVED, request):
1786
- return self._create_status_change_response(
1787
- StatusChangeOperation.DOWNGRADE,
1788
- False,
1789
- {"demoted": 0, "restored": 0},
1790
- "No archived items found to restore",
1791
- )
1792
-
1793
- # Get affected user IDs once (child class determines the logic)
1794
- affected_user_ids = self._get_affected_user_ids_for_downgrade(request)
1795
-
1796
- # 2. Demote current (None → ARCHIVE_IN_PROGRESS)
1797
- demoted = self._update_items_status(
1798
- None, Status.ARCHIVE_IN_PROGRESS, request, user_ids=affected_user_ids
1799
- )
1800
-
1801
- # 3. Restore archived (ARCHIVED → None)
1802
- restored = self._update_items_status(
1803
- Status.ARCHIVED, None, request, user_ids=affected_user_ids
1804
- )
1805
-
1806
- # 4. Complete archiving (ARCHIVE_IN_PROGRESS → ARCHIVED)
1807
- self._update_items_status(
1808
- Status.ARCHIVE_IN_PROGRESS,
1809
- Status.ARCHIVED,
1810
- request,
1811
- user_ids=affected_user_ids,
1812
- )
1813
-
1814
- msg = f"Downgraded: {demoted} archived, {restored} restored"
1815
- return self._create_status_change_response(
1816
- StatusChangeOperation.DOWNGRADE,
1817
- True,
1818
- {"demoted": demoted, "restored": restored},
1819
- msg,
1820
- )
1821
-
1822
- except Exception as e:
1823
- return self._create_status_change_response(
1824
- StatusChangeOperation.DOWNGRADE,
1825
- False,
1826
- {"demoted": 0, "restored": 0},
1827
- f"Failed to downgrade: {str(e)}",
1828
- )