claude-smart 0.2.41 → 0.2.43

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 (396) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.md +1 -1
  3. package/bin/claude-smart.js +86 -48
  4. package/package.json +10 -3
  5. package/plugin/.claude-plugin/plugin.json +9 -3
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +2 -2
  8. package/plugin/dashboard/next.config.ts +9 -1
  9. package/plugin/pyproject.toml +2 -2
  10. package/plugin/scripts/_lib.sh +91 -0
  11. package/plugin/scripts/backend-service.sh +46 -15
  12. package/plugin/scripts/cli.sh +29 -1
  13. package/plugin/scripts/codex-hook.js +72 -4
  14. package/plugin/scripts/dashboard-build.sh +1 -0
  15. package/plugin/scripts/dashboard-service.sh +1 -0
  16. package/plugin/scripts/ensure-plugin-root.sh +7 -14
  17. package/plugin/scripts/hook_entry.sh +1 -0
  18. package/plugin/scripts/smart-install.sh +18 -2
  19. package/plugin/src/claude_smart/cli.py +72 -38
  20. package/plugin/src/claude_smart/context_format.py +11 -12
  21. package/plugin/src/claude_smart/cs_cite.py +26 -12
  22. package/plugin/src/claude_smart/ids.py +13 -5
  23. package/plugin/uv.lock +1 -1
  24. package/plugin/vendor/reflexio/.env.example +53 -0
  25. package/plugin/vendor/reflexio/LICENSE +201 -0
  26. package/plugin/vendor/reflexio/README.md +338 -0
  27. package/plugin/vendor/reflexio/pyproject.toml +271 -0
  28. package/plugin/vendor/reflexio/reflexio/README.md +184 -0
  29. package/plugin/vendor/reflexio/reflexio/__init__.py +166 -0
  30. package/plugin/vendor/reflexio/reflexio/benchmarks/__init__.py +1 -0
  31. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/README.md +109 -0
  32. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/__init__.py +1 -0
  33. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/backends.py +175 -0
  34. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/bench.py +642 -0
  35. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/embed_cache.py +330 -0
  36. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/report.py +317 -0
  37. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/report.md +43 -0
  38. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/results.json +4478 -0
  39. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/scenarios.py +134 -0
  40. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/seed.py +255 -0
  41. package/plugin/vendor/reflexio/reflexio/cli/README.md +287 -0
  42. package/plugin/vendor/reflexio/reflexio/cli/__init__.py +0 -0
  43. package/plugin/vendor/reflexio/reflexio/cli/__main__.py +56 -0
  44. package/plugin/vendor/reflexio/reflexio/cli/_client.py +86 -0
  45. package/plugin/vendor/reflexio/reflexio/cli/app.py +127 -0
  46. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +266 -0
  47. package/plugin/vendor/reflexio/reflexio/cli/codex_auth.py +503 -0
  48. package/plugin/vendor/reflexio/reflexio/cli/commands/__init__.py +0 -0
  49. package/plugin/vendor/reflexio/reflexio/cli/commands/admin_cmd.py +65 -0
  50. package/plugin/vendor/reflexio/reflexio/cli/commands/agent_playbooks.py +503 -0
  51. package/plugin/vendor/reflexio/reflexio/cli/commands/api.py +114 -0
  52. package/plugin/vendor/reflexio/reflexio/cli/commands/auth.py +109 -0
  53. package/plugin/vendor/reflexio/reflexio/cli/commands/config_cmd.py +511 -0
  54. package/plugin/vendor/reflexio/reflexio/cli/commands/doctor.py +127 -0
  55. package/plugin/vendor/reflexio/reflexio/cli/commands/embeddings.py +53 -0
  56. package/plugin/vendor/reflexio/reflexio/cli/commands/interactions.py +478 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/commands/profiles.py +303 -0
  58. package/plugin/vendor/reflexio/reflexio/cli/commands/services.py +289 -0
  59. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +961 -0
  60. package/plugin/vendor/reflexio/reflexio/cli/commands/shortcuts.py +285 -0
  61. package/plugin/vendor/reflexio/reflexio/cli/commands/status_cmd.py +143 -0
  62. package/plugin/vendor/reflexio/reflexio/cli/commands/user_playbooks.py +373 -0
  63. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +284 -0
  64. package/plugin/vendor/reflexio/reflexio/cli/errors.py +217 -0
  65. package/plugin/vendor/reflexio/reflexio/cli/log_format.py +247 -0
  66. package/plugin/vendor/reflexio/reflexio/cli/output.py +867 -0
  67. package/plugin/vendor/reflexio/reflexio/cli/paths.py +41 -0
  68. package/plugin/vendor/reflexio/reflexio/cli/run_services.py +391 -0
  69. package/plugin/vendor/reflexio/reflexio/cli/state.py +204 -0
  70. package/plugin/vendor/reflexio/reflexio/cli/stop_services.py +96 -0
  71. package/plugin/vendor/reflexio/reflexio/cli/utils.py +329 -0
  72. package/plugin/vendor/reflexio/reflexio/client/__init__.py +3 -0
  73. package/plugin/vendor/reflexio/reflexio/client/cache.py +150 -0
  74. package/plugin/vendor/reflexio/reflexio/client/client.py +2613 -0
  75. package/plugin/vendor/reflexio/reflexio/defaults.py +23 -0
  76. package/plugin/vendor/reflexio/reflexio/integrations/__init__.py +0 -0
  77. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/.clawhubignore +7 -0
  78. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/README.md +274 -0
  79. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/TESTING.md +517 -0
  80. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/hook/handler.js +473 -0
  81. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package-lock.json +2156 -0
  82. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package.json +18 -0
  83. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/handler.ts +241 -0
  84. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/setup.ts +140 -0
  85. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/index.ts +130 -0
  86. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/publish.ts +113 -0
  87. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/search.ts +52 -0
  88. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/server.ts +103 -0
  89. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/sqlite-buffer.ts +156 -0
  90. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/user-id.ts +134 -0
  91. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/openclaw.plugin.json +41 -0
  92. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/package.json +17 -0
  93. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/rules/reflexio.md +24 -0
  94. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/reflexio/SKILL.md +48 -0
  95. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/publish_clawhub.sh +278 -0
  96. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/references/HOOK.md +164 -0
  97. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/install.sh +36 -0
  98. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/uninstall.sh +35 -0
  99. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/publish.test.ts +27 -0
  100. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/search.test.ts +31 -0
  101. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/server.test.ts +42 -0
  102. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/setup.test.ts +49 -0
  103. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/sqlite-buffer.test.ts +91 -0
  104. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/user-id.test.ts +50 -0
  105. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tsconfig.json +16 -0
  106. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/types/openclaw.d.ts +230 -0
  107. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/vitest.config.ts +13 -0
  108. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/README.md +120 -0
  109. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/TESTING.md +168 -0
  110. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package-lock.json +1657 -0
  111. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package.json +16 -0
  112. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/HEARTBEAT.md +6 -0
  113. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/README.md +84 -0
  114. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/SKILL.md +194 -0
  115. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/_meta.json +6 -0
  116. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/agents/reflexio-extractor.md +45 -0
  117. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/handler.ts +214 -0
  118. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/setup.ts +55 -0
  119. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/index.ts +327 -0
  120. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/consolidate.ts +233 -0
  121. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/dedup.ts +80 -0
  122. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/io.ts +155 -0
  123. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/openclaw-cli.ts +67 -0
  124. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/search.ts +33 -0
  125. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-playbook.ts +76 -0
  126. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-profile.ts +79 -0
  127. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/openclaw.plugin.json +46 -0
  128. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/package.json +18 -0
  129. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/README.md +36 -0
  130. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/full_consolidation.md +56 -0
  131. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/playbook_extraction.md +217 -0
  132. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/profile_extraction.md +132 -0
  133. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-consolidate/SKILL.md +33 -0
  134. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-embedded/SKILL.md +194 -0
  135. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/HOOK.md +18 -0
  136. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/architecture.md +49 -0
  137. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/comparison.md +31 -0
  138. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/future-work.md +47 -0
  139. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/porting-notes.md +52 -0
  140. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/install.sh +52 -0
  141. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/uninstall.sh +36 -0
  142. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/consolidate.test.ts +135 -0
  143. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/dedup.test.ts +104 -0
  144. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/io.test.ts +175 -0
  145. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/search.test.ts +66 -0
  146. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/smoke-test.ts +140 -0
  147. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-playbook.test.ts +93 -0
  148. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-profile.test.ts +174 -0
  149. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tsconfig.json +16 -0
  150. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/types/openclaw.d.ts +230 -0
  151. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/vitest.config.ts +7 -0
  152. package/plugin/vendor/reflexio/reflexio/lib/__init__.py +23 -0
  153. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +310 -0
  154. package/plugin/vendor/reflexio/reflexio/lib/_base.py +225 -0
  155. package/plugin/vendor/reflexio/reflexio/lib/_config.py +83 -0
  156. package/plugin/vendor/reflexio/reflexio/lib/_dashboard.py +266 -0
  157. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +176 -0
  158. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +334 -0
  159. package/plugin/vendor/reflexio/reflexio/lib/_operations.py +153 -0
  160. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +545 -0
  161. package/plugin/vendor/reflexio/reflexio/lib/_reflection.py +52 -0
  162. package/plugin/vendor/reflexio/reflexio/lib/_search.py +167 -0
  163. package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +103 -0
  164. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +288 -0
  165. package/plugin/vendor/reflexio/reflexio/lib/reflexio_lib.py +27 -0
  166. package/plugin/vendor/reflexio/reflexio/models/__init__.py +0 -0
  167. package/plugin/vendor/reflexio/reflexio/models/api_schema/__init__.py +0 -0
  168. package/plugin/vendor/reflexio/reflexio/models/api_schema/braintrust_schema.py +141 -0
  169. package/plugin/vendor/reflexio/reflexio/models/api_schema/common.py +41 -0
  170. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +3 -0
  171. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +1103 -0
  172. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +63 -0
  173. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +487 -0
  174. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +28 -0
  175. package/plugin/vendor/reflexio/reflexio/models/api_schema/pending_tool_call_schema.py +83 -0
  176. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +766 -0
  177. package/plugin/vendor/reflexio/reflexio/models/api_schema/service_schemas.py +9 -0
  178. package/plugin/vendor/reflexio/reflexio/models/api_schema/stall_state_schema.py +32 -0
  179. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/__init__.py +3 -0
  180. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +177 -0
  181. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +129 -0
  182. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/enums.py +25 -0
  183. package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +280 -0
  184. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +908 -0
  185. package/plugin/vendor/reflexio/reflexio/models/py.typed +0 -0
  186. package/plugin/vendor/reflexio/reflexio/server/OVERVIEW.md +90 -0
  187. package/plugin/vendor/reflexio/reflexio/server/README.md +616 -0
  188. package/plugin/vendor/reflexio/reflexio/server/__init__.py +210 -0
  189. package/plugin/vendor/reflexio/reflexio/server/__main__.py +132 -0
  190. package/plugin/vendor/reflexio/reflexio/server/_auth.py +25 -0
  191. package/plugin/vendor/reflexio/reflexio/server/api.py +2714 -0
  192. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/account_api.py +143 -0
  193. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/health_api.py +91 -0
  194. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/pending_tool_call_api.py +572 -0
  195. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/precondition_checks.py +66 -0
  196. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +540 -0
  197. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +50 -0
  198. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/stall_state_api.py +100 -0
  199. package/plugin/vendor/reflexio/reflexio/server/cache/__init__.py +15 -0
  200. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +208 -0
  201. package/plugin/vendor/reflexio/reflexio/server/correlation.py +46 -0
  202. package/plugin/vendor/reflexio/reflexio/server/llm/__init__.py +30 -0
  203. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +110 -0
  204. package/plugin/vendor/reflexio/reflexio/server/llm/image_utils.py +55 -0
  205. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1595 -0
  206. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +112 -0
  207. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +469 -0
  208. package/plugin/vendor/reflexio/reflexio/server/llm/providers/__init__.py +1 -0
  209. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1122 -0
  210. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_stream_parser.py +197 -0
  211. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +210 -0
  212. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +213 -0
  213. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +255 -0
  214. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/__init__.py +6 -0
  215. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +177 -0
  216. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/llm_reranker.py +148 -0
  217. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +699 -0
  218. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +179 -0
  219. package/plugin/vendor/reflexio/reflexio/server/prompt/__init__.py +0 -0
  220. package/plugin/vendor/reflexio/reflexio/server/prompt/_dispatchers.py +54 -0
  221. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/README.md +121 -0
  222. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation/v1.0.0.prompt.md +58 -0
  223. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation_with_comparison/v1.0.0.prompt.md +76 -0
  224. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/answer_synthesis/v1.5.2.prompt.md +88 -0
  225. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/compress_session_for_query/v1.3.0.prompt.md +31 -0
  226. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +20 -0
  227. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.0.0.prompt.md +53 -0
  228. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.1.0.prompt.md +57 -0
  229. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.2.0.prompt.md +68 -0
  230. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.3.0.prompt.md +70 -0
  231. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.4.0.prompt.md +77 -0
  232. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.5.0.prompt.md +82 -0
  233. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.6.0.prompt.md +83 -0
  234. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.1.0.prompt.md +193 -0
  235. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.2.0.prompt.md +206 -0
  236. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0-deprecated.prompt.md +66 -0
  237. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0.prompt.md +43 -0
  238. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.1.0.prompt.md +46 -0
  239. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0-deprecated.prompt.md +64 -0
  240. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0.prompt.md +39 -0
  241. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.1.0.prompt.md +39 -0
  242. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.2.0.prompt.md +47 -0
  243. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +58 -0
  244. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.0.2.prompt.md +254 -0
  245. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.1.0.prompt.md +274 -0
  246. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +279 -0
  247. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v1.0.0.prompt.md +73 -0
  248. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v2.0.0.prompt.md +86 -0
  249. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.0.0.prompt.md +97 -0
  250. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.1.0.prompt.md +119 -0
  251. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.2.0.prompt.md +123 -0
  252. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +127 -0
  253. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.0.0.prompt.md +14 -0
  254. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.1.0.prompt.md +24 -0
  255. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.2.0.prompt.md +29 -0
  256. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.0.0.prompt.md +11 -0
  257. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.1.0.prompt.md +21 -0
  258. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.2.0.prompt.md +25 -0
  259. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.0.0.prompt.md +37 -0
  260. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.1.0.prompt.md +40 -0
  261. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.2.0.prompt.md +36 -0
  262. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v1.0.0.prompt.md +45 -0
  263. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v2.0.0.prompt.md +81 -0
  264. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v3.0.0.prompt.md +80 -0
  265. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate_expert/v1.0.0.prompt.md +34 -0
  266. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_deduplication/v1.0.0.prompt.md +116 -0
  267. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate/v1.0.0.prompt.md +33 -0
  268. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate_override/v1.0.0.prompt.md +16 -0
  269. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.0.0.prompt.md +140 -0
  270. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.1.0.prompt.md +160 -0
  271. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_main/v1.0.0.prompt.md +14 -0
  272. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +19 -0
  273. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/rerank_relevance/v1.1.0.prompt.md +44 -0
  274. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +43 -0
  275. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_content_evaluation/v1.0.0.prompt.md +33 -0
  276. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/feedback_extraction_main_v1.jsonl +10 -0
  277. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/profile_update_main_v1.jsonl +10 -0
  278. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_manager.py +280 -0
  279. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_schema.py +11 -0
  280. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +131 -0
  281. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_constants.py +60 -0
  282. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_service.py +228 -0
  283. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_utils.py +87 -0
  284. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluator.py +372 -0
  285. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/delayed_group_evaluator.py +156 -0
  286. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +340 -0
  287. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +471 -0
  288. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +1626 -0
  289. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/__init__.py +0 -0
  290. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_cron.py +196 -0
  291. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_encryption.py +101 -0
  292. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/client.py +167 -0
  293. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/service.py +281 -0
  294. package/plugin/vendor/reflexio/reflexio/server/services/configurator/base_configurator.py +179 -0
  295. package/plugin/vendor/reflexio/reflexio/server/services/configurator/config_storage.py +62 -0
  296. package/plugin/vendor/reflexio/reflexio/server/services/configurator/configurator.py +87 -0
  297. package/plugin/vendor/reflexio/reflexio/server/services/configurator/local_file_config_storage.py +187 -0
  298. package/plugin/vendor/reflexio/reflexio/server/services/configurator/test_config_storage.py +162 -0
  299. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +112 -0
  300. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/__init__.py +0 -0
  301. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/distribution.py +33 -0
  302. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/eval_sampler.py +126 -0
  303. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/group_aggregation.py +192 -0
  304. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/hero_state.py +75 -0
  305. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/rule_attribution.py +97 -0
  306. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +515 -0
  307. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/shadow_aggregation.py +90 -0
  308. package/plugin/vendor/reflexio/reflexio/server/services/extraction/__init__.py +0 -0
  309. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +91 -0
  310. package/plugin/vendor/reflexio/reflexio/server/services/extraction/invariants.py +303 -0
  311. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +25 -0
  312. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +351 -0
  313. package/plugin/vendor/reflexio/reflexio/server/services/extraction/plan.py +138 -0
  314. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +217 -0
  315. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +468 -0
  316. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +171 -0
  317. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +777 -0
  318. package/plugin/vendor/reflexio/reflexio/server/services/extraction/tools.py +1125 -0
  319. package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +91 -0
  320. package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +251 -0
  321. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +689 -0
  322. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +835 -0
  323. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +89 -0
  324. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_aggregator.py +1388 -0
  325. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +960 -0
  326. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_extractor.py +436 -0
  327. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_generation_service.py +808 -0
  328. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_constants.py +28 -0
  329. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_utils.py +362 -0
  330. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/__init__.py +24 -0
  331. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/assistant_webhook.py +246 -0
  332. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/gepa_adapter.py +291 -0
  333. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/judge.py +97 -0
  334. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/models.py +96 -0
  335. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +645 -0
  336. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/rollout.py +35 -0
  337. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scenario_resolver.py +93 -0
  338. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +174 -0
  339. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/__init__.py +26 -0
  340. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_document_expander.py +179 -0
  341. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +297 -0
  342. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +741 -0
  343. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_extractor.py +462 -0
  344. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +734 -0
  345. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service_utils.py +290 -0
  346. package/plugin/vendor/reflexio/reflexio/server/services/reflection/__init__.py +17 -0
  347. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_extractor.py +247 -0
  348. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +800 -0
  349. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service_utils.py +146 -0
  350. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/__init__.py +0 -0
  351. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +70 -0
  352. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
  353. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +671 -0
  354. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/__init__.py +1 -0
  355. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +184 -0
  356. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/outcome.py +81 -0
  357. package/plugin/vendor/reflexio/reflexio/server/services/storage/constants.py +2 -0
  358. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +11 -0
  359. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +154 -0
  360. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +155 -0
  361. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +59 -0
  362. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +1253 -0
  363. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +1945 -0
  364. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +600 -0
  365. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_operations.py +346 -0
  366. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1378 -0
  367. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +747 -0
  368. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +263 -0
  369. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +193 -0
  370. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +166 -0
  371. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_stall_state.py +217 -0
  372. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +153 -0
  373. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +372 -0
  374. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_base.py +71 -0
  375. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +235 -0
  376. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +170 -0
  377. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +677 -0
  378. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +250 -0
  379. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +154 -0
  380. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +130 -0
  381. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +93 -0
  382. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_stall_state.py +76 -0
  383. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +568 -0
  384. package/plugin/vendor/reflexio/reflexio/server/site_var/README.md +77 -0
  385. package/plugin/vendor/reflexio/reflexio/server/site_var/feature_flags.py +116 -0
  386. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_manager.py +263 -0
  387. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/feature_flags.json +13 -0
  388. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/llm_model_setting.json +7 -0
  389. package/plugin/vendor/reflexio/reflexio/server/tracing.py +158 -0
  390. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +113 -0
  391. package/plugin/vendor/reflexio/reflexio/server/uvicorn_logging.py +76 -0
  392. package/plugin/vendor/reflexio/reflexio/test_support/__init__.py +1 -0
  393. package/plugin/vendor/reflexio/reflexio/test_support/llm_fixtures.py +62 -0
  394. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +242 -0
  395. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +129 -0
  396. package/plugin/vendor/reflexio/reflexio/test_support/skip_decorators.py +43 -0
@@ -0,0 +1,1626 @@
1
+ """
2
+ Base class for generation services
3
+ """
4
+
5
+ import contextvars
6
+ import logging
7
+ import os
8
+ import re
9
+ import time
10
+ import uuid
11
+ from abc import ABC, abstractmethod
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from concurrent.futures import TimeoutError as FuturesTimeoutError
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime, timedelta
16
+ from enum import StrEnum
17
+ from typing import Any, Generic, TypeVar
18
+
19
+ from reflexio.models.api_schema.internal_schema import RequestInteractionDataModel
20
+ from reflexio.models.api_schema.service_schemas import Status
21
+ from reflexio.server.api_endpoints.request_context import RequestContext
22
+ from reflexio.server.llm.litellm_client import LiteLLMClient
23
+ from reflexio.server.services.extraction.outcome import ExtractionOutcome
24
+ from reflexio.server.services.extractor_config_utils import (
25
+ filter_extractor_configs,
26
+ get_extractor_name,
27
+ )
28
+ from reflexio.server.services.extractor_interaction_utils import (
29
+ get_effective_source_filter,
30
+ get_extractor_window_params,
31
+ should_extractor_run_by_stride,
32
+ )
33
+ from reflexio.server.services.operation_state_utils import OperationStateManager
34
+ from reflexio.server.services.service_utils import log_llm_messages, log_model_response
35
+ from reflexio.server.services.storage.storage_base import AgentRunStatus
36
+ from reflexio.server.usage_metrics import record_usage_event
37
+
38
+
39
+ class StatusChangeOperation(StrEnum):
40
+ """Operation type for upgrade/downgrade responses."""
41
+
42
+ UPGRADE = "upgrade"
43
+ DOWNGRADE = "downgrade"
44
+
45
+
46
+ class ExtractorExecutionError(RuntimeError):
47
+ """Raised when the configured extractor fails for a request/user context."""
48
+
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # Cheap-signal thresholds for the pre-LLM should_run filter. Tuned for
54
+ # coding-assistant traffic where most turns are either slash commands
55
+ # or tool scaffolding, and where the LLM should_run gate costs 5–7s
56
+ # even when it ultimately votes False.
57
+ _MIN_USER_CONTENT_LEN = 30
58
+ # Heuristic match for reflexio's own extractor system prompts that
59
+ # sometimes leak into the corpus via the claude-code LLM provider's
60
+ # self-invocation. Kept conservative — false positives just mean one
61
+ # real interaction gets skipped this cycle (it'll re-enter at the next
62
+ # publish), false negatives are what we're actually trying to avoid.
63
+ _EXTRACTOR_PROMPT_PREFIXES = (
64
+ "you are a detector",
65
+ "you are an user signal",
66
+ "you are a signal detection",
67
+ "you are an extractor",
68
+ )
69
+ # Matches a single slash-command token at the start of a message. The
70
+ # ``:`` allows plugin-namespaced commands like ``/claude-smart:tag``.
71
+ _SLASH_COMMAND_TOKEN_RE = re.compile(r"^/[A-Za-z0-9_:-]+\s*")
72
+
73
+
74
+ def _is_pure_slash_command(content: str) -> bool:
75
+ """Whether ``content`` is a bare slash command with no substantive text.
76
+
77
+ ``/learn`` and ``/claude-smart:tag`` return True. ``/btw some note``
78
+ and ``/claude-smart:tag fix the foo`` return False because the text
79
+ after the command token carries user signal the extractors should see.
80
+ """
81
+ stripped = content.lstrip()
82
+ if not stripped.startswith("/"):
83
+ return False
84
+ remainder = _SLASH_COMMAND_TOKEN_RE.sub("", stripped, count=1)
85
+ return not remainder.strip()
86
+
87
+
88
+ def _iter_user_contents(
89
+ session_data_models: list[RequestInteractionDataModel],
90
+ ) -> list[str]:
91
+ """Collect the ``content`` of every User-role interaction, order-preserving."""
92
+ out: list[str] = []
93
+ for model in session_data_models:
94
+ out.extend(
95
+ interaction.content
96
+ for interaction in model.interactions
97
+ if interaction.role == "User" and interaction.content
98
+ )
99
+ return out
100
+
101
+
102
+ def _cheap_should_run_reject(
103
+ session_data_models: list[RequestInteractionDataModel],
104
+ ) -> str | None:
105
+ """Cheap pre-filter for the consolidated should_run LLM gate.
106
+
107
+ Returns a short reason string when we can cheaply decide the batch
108
+ has no learnable signal — the caller logs the reason and skips the
109
+ LLM call. Returns None when we cannot decide cheaply and the LLM
110
+ should run.
111
+
112
+ Rejection rules:
113
+ - No user message at least ``_MIN_USER_CONTENT_LEN`` chars long
114
+ (purely short commands / confirmations).
115
+ - Every user message is a bare slash-command dispatch with no
116
+ substantive trailing text (e.g. ``/commit``, ``/review``,
117
+ ``/claude-smart:tag``). Slash commands that carry user text
118
+ after the token (e.g. ``/btw some note``) are kept.
119
+ - Any user message begins with a known extractor-prompt prefix
120
+ (reflexio talking to itself via the claude-code LLM provider).
121
+
122
+ Args:
123
+ session_data_models: The deduplicated per-session interaction
124
+ batch built by ``_collect_scoped_interactions_for_precheck``.
125
+
126
+ Returns:
127
+ str | None: Reason code for the reject, or None to fall through.
128
+ """
129
+ user_contents = _iter_user_contents(session_data_models)
130
+ if not user_contents:
131
+ return "no_user_turns"
132
+
133
+ for content in user_contents:
134
+ lowered = content.lstrip().lower()
135
+ if any(lowered.startswith(p) for p in _EXTRACTOR_PROMPT_PREFIXES):
136
+ return "extractor_prompt_echo"
137
+
138
+ if not any(len(c.strip()) >= _MIN_USER_CONTENT_LEN for c in user_contents):
139
+ return "all_user_turns_too_short"
140
+
141
+ if all(_is_pure_slash_command(c) for c in user_contents):
142
+ return "all_slash_commands"
143
+
144
+ return None
145
+
146
+
147
+ # Timeout for individual extractor execution (safety net if LLM provider ignores its own timeout)
148
+ EXTRACTOR_TIMEOUT_SECONDS = 300
149
+
150
+ TExtractorConfig = TypeVar("TExtractorConfig")
151
+ TExtractor = TypeVar("TExtractor")
152
+ TGenerationServiceConfig = TypeVar("TGenerationServiceConfig")
153
+ TRequest = TypeVar("TRequest")
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class PreparedGenerationRun(Generic[TExtractorConfig]): # noqa: UP046
158
+ extractor_config: TExtractorConfig
159
+ extractor_name: str
160
+ identifier: str
161
+
162
+
163
+ # Unified base class for all generation services (evaluation, playbook, profile)
164
+ class BaseGenerationService(
165
+ ABC,
166
+ Generic[TExtractorConfig, TExtractor, TGenerationServiceConfig, TRequest], # noqa: UP046
167
+ ):
168
+ """
169
+ Base class for generation services that run one configured extractor.
170
+
171
+ This unified class supports two types of services:
172
+ 1. Evaluation services (playbook, agent success) - process interactions and save UserPlaybook
173
+ 2. Profile services - process interactions with existing data and apply updates
174
+
175
+ Type Parameters:
176
+ TExtractorConfig: The extractor configuration type from YAML (e.g., PlaybookConfig, ProfileExtractorConfig)
177
+ TExtractor: The extractor type (e.g., PlaybookExtractor, ProfileExtractor, AgentSuccessEvaluator)
178
+ TGenerationServiceConfig: The runtime service configuration type (e.g., PlaybookGenerationServiceConfig, ProfileGenerationServiceConfig)
179
+ TRequest: The request type (e.g., ProfileGenerationRequest, PlaybookGenerationRequest, AgentSuccessEvaluationRequest)
180
+
181
+ Child classes must implement:
182
+ - _load_extractor_config(): Load extractor configuration from configurator
183
+ - _load_generation_service_config(): Extract parameters from request and return GenerationServiceConfig
184
+ - _create_extractor(): Create extractor instance with extractor config and service config
185
+ - _get_service_name(): Get service name for logging
186
+ - _process_results(): Process and save results (can access self.service_config)
187
+ """
188
+
189
+ def __init__(
190
+ self, llm_client: LiteLLMClient, request_context: RequestContext
191
+ ) -> None:
192
+ """
193
+ Initialize the base generation service.
194
+
195
+ Args:
196
+ llm_client: Unified LLM client supporting both OpenAI and Claude
197
+ request_context: Request context with storage, configurator, and org_id
198
+ """
199
+ self.client = llm_client
200
+ self.storage = request_context.storage
201
+ self.org_id = request_context.org_id
202
+ self.configurator = request_context.configurator
203
+ self.request_context = request_context
204
+ self.service_config: TGenerationServiceConfig | None = None
205
+ self._is_batch_mode: bool = False
206
+ self._last_extractor_run_stats: dict[str, int] = {
207
+ "total": 0,
208
+ "failed": 0,
209
+ "timed_out": 0,
210
+ }
211
+ self._last_extraction_run_ids: list[str] = []
212
+
213
+ def _usage_pipeline(self) -> str | None:
214
+ service_name = self._get_service_name()
215
+ if "profile" in service_name:
216
+ return "profile"
217
+ if "playbook" in service_name:
218
+ return "playbook"
219
+ if "evaluation" in service_name:
220
+ return "evaluation"
221
+ return None
222
+
223
+ def _usage_context(self) -> dict[str, Any]:
224
+ service_config = self.service_config
225
+ return {
226
+ "org_id": self.org_id,
227
+ "user_id": getattr(service_config, "user_id", None),
228
+ "request_id": getattr(service_config, "request_id", None),
229
+ "source": getattr(service_config, "source", None),
230
+ "agent_version": getattr(service_config, "agent_version", None),
231
+ "pipeline": self._usage_pipeline(),
232
+ }
233
+
234
+ def _record_generation_event(
235
+ self,
236
+ *,
237
+ event_name: str,
238
+ outcome: str,
239
+ count_value: int = 1,
240
+ duration_ms: int | None = None,
241
+ error_kind: str | None = None,
242
+ metadata: dict[str, Any] | None = None,
243
+ ) -> None:
244
+ record_usage_event(
245
+ **self._usage_context(),
246
+ event_name=event_name,
247
+ event_category="generation",
248
+ outcome=outcome,
249
+ count_value=count_value,
250
+ duration_ms=duration_ms,
251
+ error_kind=error_kind,
252
+ metadata=metadata,
253
+ )
254
+
255
+ @staticmethod
256
+ def _count_generated_results(result: Any) -> int:
257
+ if isinstance(result, list):
258
+ return len(result)
259
+ return 1 if result else 0
260
+
261
+ @abstractmethod
262
+ def _load_extractor_config(self) -> TExtractorConfig | None:
263
+ """
264
+ Load extractor configuration from the configurator.
265
+
266
+ Returns:
267
+ Extractor configuration object from YAML, or None when disabled.
268
+ """
269
+
270
+ @abstractmethod
271
+ def _load_generation_service_config(
272
+ self, request: TRequest
273
+ ) -> TGenerationServiceConfig:
274
+ """
275
+ Extract parameters from request object and return GenerationServiceConfig.
276
+
277
+ Args:
278
+ request: The request object
279
+
280
+ Returns:
281
+ GenerationServiceConfig object (e.g., PlaybookGenerationServiceConfig, ProfileGenerationServiceConfig)
282
+ """
283
+
284
+ @abstractmethod
285
+ def _create_extractor(
286
+ self,
287
+ extractor_config: TExtractorConfig,
288
+ service_config: TGenerationServiceConfig,
289
+ ) -> TExtractor:
290
+ """
291
+ Create an extractor instance from extractor config and service config.
292
+
293
+ Args:
294
+ extractor_config: The extractor configuration object from YAML (e.g., PlaybookConfig, ProfileExtractorConfig)
295
+ service_config: The runtime service configuration object (e.g., PlaybookGenerationServiceConfig, ProfileGenerationServiceConfig)
296
+
297
+ Returns:
298
+ An extractor instance
299
+ """
300
+
301
+ @abstractmethod
302
+ def _get_service_name(self) -> str:
303
+ """
304
+ Get the name of the service for logging purposes.
305
+
306
+ Returns:
307
+ Service name string
308
+ """
309
+
310
+ @abstractmethod
311
+ def _get_base_service_name(self) -> str:
312
+ """
313
+ Get the base service name for OperationStateManager keys.
314
+
315
+ This is the service identity used for progress/lock key construction,
316
+ independent of whether the operation is a rerun or regular run.
317
+
318
+ Returns:
319
+ Base service name (e.g., "profile_generation", "playbook_generation")
320
+ """
321
+
322
+ @abstractmethod
323
+ def _process_results(self, results: list) -> None:
324
+ """
325
+ Process and save all results from extractors. Called once after all extractors complete.
326
+
327
+ Responsible for flattening, deduplication (if applicable), and saving results.
328
+ Can access self.service_config for context.
329
+
330
+ Args:
331
+ results: List of all results from extractors (one per successful extractor)
332
+ """
333
+
334
+ def _finalize_extracted_items(self, items: list) -> None:
335
+ """Persist already-flattened extracted items through the service path."""
336
+ if items:
337
+ self._process_results([items])
338
+
339
+ @abstractmethod
340
+ def _should_track_in_progress(self) -> bool:
341
+ """
342
+ Return True if this service should track in-progress state to prevent duplicates.
343
+
344
+ Profile and Feedback services should return True to prevent duplicate generation
345
+ when back-to-back requests arrive. AgentSuccess services should return False
346
+ as they process per-request and don't have the same duplication issue.
347
+
348
+ Returns:
349
+ bool: True if in-progress tracking should be enabled
350
+ """
351
+
352
+ @abstractmethod
353
+ def _get_lock_scope_id(self, request: TRequest) -> str | None:
354
+ """
355
+ Get the scope ID for lock key construction.
356
+
357
+ Profile services return user_id (per-user lock), playbook services return None (per-org lock).
358
+
359
+ Args:
360
+ request: The generation request
361
+
362
+ Returns:
363
+ Optional[str]: Scope ID (e.g., user_id) or None for org-level scope
364
+ """
365
+
366
+ def _filter_extractor_config_by_service_config(
367
+ self,
368
+ extractor_config: TExtractorConfig,
369
+ service_config: TGenerationServiceConfig,
370
+ ) -> TExtractorConfig | None:
371
+ """
372
+ Filter the extractor config based on request_sources_enabled and manual_trigger.
373
+ """
374
+ filtered = filter_extractor_configs(
375
+ extractor_configs=[extractor_config],
376
+ source=getattr(service_config, "source", None),
377
+ allow_manual_trigger=getattr(service_config, "allow_manual_trigger", False),
378
+ )
379
+ return filtered[0] if filtered else None
380
+
381
+ def _get_extractor_state_service_name(self) -> str | None:
382
+ """
383
+ Get the service name used for extractor state (stride_size bookmark) lookups.
384
+
385
+ Override in subclasses that support stride_size-based pre-filtering to return
386
+ the OperationStateManager service name (e.g., "profile_extractor", "playbook_extractor").
387
+ Returns None by default, meaning stride_size pre-filtering is skipped.
388
+
389
+ Returns:
390
+ Optional[str]: Service name for OperationStateManager, or None to skip stride_size pre-filtering
391
+ """
392
+ return None
393
+
394
+ def _filter_config_by_stride(
395
+ self, extractor_config: TExtractorConfig
396
+ ) -> TExtractorConfig | None:
397
+ """
398
+ Filter extractor config by stride_size check before the should_run LLM call.
399
+
400
+ Skips filtering when:
401
+ - _get_extractor_state_service_name() returns None (service doesn't support stride_size)
402
+ - auto_run is False (rerun/manual flows skip stride_size)
403
+
404
+ Args:
405
+ extractor_config: Extractor config after source/manual_trigger filtering
406
+
407
+ Returns:
408
+ Extractor config when it passes the stride_size check, otherwise None.
409
+ """
410
+ state_service_name = self._get_extractor_state_service_name()
411
+ if state_service_name is None:
412
+ return extractor_config
413
+
414
+ if not getattr(self.service_config, "auto_run", True):
415
+ return extractor_config
416
+
417
+ if getattr(self.service_config, "force_extraction", False):
418
+ return extractor_config
419
+
420
+ root_config = self.request_context.configurator.get_config()
421
+ global_window_size = (
422
+ getattr(root_config, "window_size", None) if root_config else None
423
+ )
424
+ global_stride_size = (
425
+ getattr(root_config, "stride_size", None) if root_config else None
426
+ )
427
+
428
+ state_manager = OperationStateManager(
429
+ self.storage, # type: ignore[reportArgumentType]
430
+ self.org_id,
431
+ state_service_name, # type: ignore[reportArgumentType]
432
+ )
433
+
434
+ name = get_extractor_name(extractor_config)
435
+ _, stride_size = get_extractor_window_params(
436
+ extractor_config, global_window_size, global_stride_size
437
+ )
438
+
439
+ # Resolve effective source filter for this extractor
440
+ should_skip, effective_source = get_effective_source_filter(
441
+ extractor_config, getattr(self.service_config, "source", None)
442
+ )
443
+ if should_skip:
444
+ return None
445
+
446
+ (
447
+ _,
448
+ new_interactions,
449
+ ) = state_manager.get_extractor_state_with_new_interactions(
450
+ extractor_name=name,
451
+ user_id=getattr(self.service_config, "user_id", None),
452
+ sources=effective_source,
453
+ )
454
+ new_count = sum(len(ri.interactions) for ri in new_interactions)
455
+
456
+ if should_extractor_run_by_stride(new_count, stride_size):
457
+ return extractor_config
458
+
459
+ logger.info(
460
+ "Stride pre-filter: skipping extractor '%s' (new=%d, stride_size=%s)",
461
+ name,
462
+ new_count,
463
+ stride_size,
464
+ )
465
+ return None
466
+
467
+ # ===============================
468
+ # In-progress state management via OperationStateManager
469
+ # ===============================
470
+
471
+ def _create_state_manager(self) -> OperationStateManager:
472
+ """Create an OperationStateManager for this service.
473
+
474
+ Returns:
475
+ OperationStateManager instance configured for this service
476
+ """
477
+ return OperationStateManager(
478
+ self.storage, # type: ignore[reportArgumentType]
479
+ self.org_id,
480
+ self._get_base_service_name(), # type: ignore[reportArgumentType]
481
+ )
482
+
483
+ def _serialize_request_for_queue(self, request: TRequest) -> dict | None:
484
+ """Serialize a request for the pending-request queue.
485
+
486
+ Default implementation handles Pydantic ``BaseModel`` requests via
487
+ ``model_dump(mode="json")``. Override in subclasses whose requests
488
+ are not Pydantic models.
489
+
490
+ The queued payload is what the rerun loop will run when this request
491
+ comes off the queue — so it MUST capture every field the run needs to
492
+ reproduce the original publish (user_id, request_id, agent_version,
493
+ source, force_extraction, etc.). Without this, the rerun runs with the
494
+ wrong holder's request and the queued user's interactions are silently
495
+ skipped (R2 / reflexio-enterprise#59).
496
+
497
+ Returns ``None`` to opt out — the queue then stores only the
498
+ request_id and the rerun falls back to the original holder's request,
499
+ which is the pre-fix behaviour. Use only for services where the
500
+ per-request payload doesn't differ between concurrent callers.
501
+ """
502
+ # Pydantic BaseModel — handles the common case (PlaybookGenerationRequest,
503
+ # ProfileGenerationRequest).
504
+ model_dump = getattr(request, "model_dump", None)
505
+ if callable(model_dump):
506
+ try:
507
+ dumped = model_dump(mode="json")
508
+ except Exception: # pragma: no cover — defensive
509
+ logger.warning(
510
+ "Failed to model_dump %s request for queue; "
511
+ "rerun will fall back to original holder's request",
512
+ self._get_service_name(),
513
+ )
514
+ return None
515
+ if isinstance(dumped, dict):
516
+ return dumped
517
+ return None
518
+
519
+ def _deserialize_request_from_queue(
520
+ self,
521
+ payload: dict,
522
+ original_request: TRequest,
523
+ ) -> TRequest:
524
+ """Reconstruct a request object from a queued payload.
525
+
526
+ Default implementation calls ``type(original_request).model_validate(payload)``
527
+ for Pydantic-backed requests. Override in subclasses with non-Pydantic
528
+ request types.
529
+
530
+ Args:
531
+ payload: The dict previously produced by ``_serialize_request_for_queue``
532
+ original_request: The request the lock holder ran with — used as a
533
+ fallback type and for any fields the payload doesn't carry
534
+ """
535
+ request_cls = type(original_request)
536
+ model_validate = getattr(request_cls, "model_validate", None)
537
+ if callable(model_validate):
538
+ try:
539
+ rebuilt = model_validate(payload)
540
+ except Exception as exc: # pragma: no cover — defensive
541
+ logger.warning(
542
+ "Failed to model_validate queued payload for %s: %s; "
543
+ "falling back to original request",
544
+ self._get_service_name(),
545
+ exc,
546
+ )
547
+ return original_request
548
+ # Narrow the object type to TRequest — model_validate on
549
+ # type(original_request) returns the same class, so the cast is
550
+ # safe in practice. Pyright can't see through getattr, so we
551
+ # use isinstance to satisfy the type checker.
552
+ if isinstance(rebuilt, request_cls):
553
+ return rebuilt # type: ignore[reportReturnType]
554
+ return original_request
555
+
556
+ def run(self, request: TRequest) -> None:
557
+ """
558
+ Run the generation service for the given request.
559
+
560
+ This is the main entry point that:
561
+ 1. If in-progress tracking is enabled, handles lock acquisition/release
562
+ 2. Validates and extracts parameters from the request into GenerationServiceConfig
563
+ 3. Runs extractors sequentially (each extractor handles its own data collection)
564
+ 4. Processes results
565
+ 5. Re-runs if new requests came in during generation
566
+
567
+ Args:
568
+ request: The request object containing parameters
569
+ """
570
+ # Check if this service tracks in-progress state
571
+ if not self._should_track_in_progress():
572
+ self._run_generation(request)
573
+ return
574
+
575
+ # Get scope ID and request ID for in-progress tracking
576
+ scope_id = self._get_lock_scope_id(request)
577
+ my_request_id = getattr(request, "request_id", None) or str(uuid.uuid4())
578
+
579
+ state_manager = self._create_state_manager()
580
+
581
+ # Try to acquire lock — pass the serialized payload so blocked
582
+ # publishes land in the queue with their own data attached. This is
583
+ # the fix for R2 / reflexio-enterprise#59: without the payload, the
584
+ # rerun re-uses the holder's request and the queued users' batches
585
+ # never get extracted.
586
+ my_payload = self._serialize_request_for_queue(request)
587
+ if not state_manager.acquire_lock(
588
+ my_request_id, scope_id=scope_id, payload=my_payload
589
+ ):
590
+ return # Another operation is running, we've enqueued ourselves
591
+
592
+ current_request: TRequest = request
593
+
594
+ # Re-run loop: drain the pending queue (FIFO) until empty
595
+ try:
596
+ while True:
597
+ self._run_generation(current_request)
598
+
599
+ # If in batch mode and cancellation was requested, clear lock
600
+ # to prevent queued pending requests from running, then stop
601
+ if self._is_batch_mode and state_manager.is_cancellation_requested():
602
+ state_manager.clear_lock(scope_id=scope_id)
603
+ logger.info(
604
+ "Cancellation detected in run() for %s, cleared lock to prevent pending re-runs",
605
+ self._get_service_name(),
606
+ )
607
+ break
608
+
609
+ # Pop the next queued request (if any). Returns the queued
610
+ # request's ID + payload so the rerun runs against THAT
611
+ # publish's data, not the original holder's.
612
+ next_entry = state_manager.release_lock_pop_queue(
613
+ my_request_id, scope_id=scope_id
614
+ )
615
+
616
+ if next_entry is None:
617
+ break # Queue empty — we're done
618
+
619
+ next_request_id = next_entry["request_id"]
620
+ next_payload = next_entry.get("payload")
621
+
622
+ logger.info(
623
+ "Draining queued %s request: prev_request_id=%s, next_request_id=%s, "
624
+ "payload_present=%s",
625
+ self._get_service_name(),
626
+ my_request_id,
627
+ next_request_id,
628
+ next_payload is not None,
629
+ )
630
+
631
+ # Reconstruct the queued request. If the payload is missing
632
+ # (legacy state row from a pre-fix server), fall back to the
633
+ # original request — matches pre-fix behaviour.
634
+ if next_payload:
635
+ current_request = self._deserialize_request_from_queue(
636
+ next_payload, request
637
+ )
638
+ else:
639
+ current_request = request
640
+
641
+ my_request_id = next_request_id
642
+
643
+ except Exception:
644
+ # Clear lock on error to prevent deadlock
645
+ state_manager.clear_lock(scope_id=scope_id)
646
+ raise
647
+
648
+ def _run_generation(self, request: TRequest) -> None:
649
+ """
650
+ Run the actual generation logic.
651
+
652
+ Orchestrates validation, config loading, extractor execution, and result
653
+ processing by delegating to _prepare_generation_run and _execute_extractor.
654
+
655
+ Args:
656
+ request: The request object containing parameters
657
+ """
658
+ if not request:
659
+ logger.error("Received None request for %s", self._get_service_name())
660
+ return
661
+
662
+ generation_start = time.perf_counter()
663
+ try:
664
+ prepared = self._prepare_generation_run(request)
665
+ if prepared is None:
666
+ return
667
+
668
+ self._record_generation_event(
669
+ event_name="generation_started",
670
+ outcome="started",
671
+ count_value=1,
672
+ metadata={
673
+ "identifier": prepared.identifier,
674
+ "extractor_name": prepared.extractor_name,
675
+ },
676
+ )
677
+ self._last_extraction_run_ids = []
678
+ result = self._execute_extractor(
679
+ prepared.extractor_config, prepared.identifier
680
+ )
681
+ generated_count = self._count_generated_results(result)
682
+
683
+ try:
684
+ if result:
685
+ self._process_results([result])
686
+ self._finalize_extraction_runs()
687
+ except Exception as exc:
688
+ self._mark_extraction_runs_finalization_failed(exc)
689
+ raise
690
+
691
+ self._record_generation_event(
692
+ event_name="generation_succeeded",
693
+ outcome="success",
694
+ count_value=generated_count,
695
+ duration_ms=int((time.perf_counter() - generation_start) * 1000),
696
+ metadata={
697
+ "identifier": prepared.identifier,
698
+ "extractor_name": prepared.extractor_name,
699
+ "extractor_failed": bool(
700
+ self._last_extractor_run_stats.get("failed")
701
+ ),
702
+ "extractor_timed_out": bool(
703
+ self._last_extractor_run_stats.get("timed_out")
704
+ ),
705
+ },
706
+ )
707
+
708
+ except Exception as e:
709
+ self._record_generation_event(
710
+ event_name="generation_failed",
711
+ outcome="failed",
712
+ duration_ms=int((time.perf_counter() - generation_start) * 1000),
713
+ error_kind=type(e).__name__,
714
+ )
715
+ logger.error(
716
+ "Failed to run %s due to %s, exception type: %s",
717
+ self._get_service_name(),
718
+ str(e),
719
+ type(e).__name__,
720
+ )
721
+ if isinstance(e, ExtractorExecutionError):
722
+ raise
723
+
724
+ def _prepare_generation_run(
725
+ self, request: TRequest
726
+ ) -> PreparedGenerationRun[TExtractorConfig] | None:
727
+ """
728
+ Validate request, load config, filter extractor config, and run pre-extraction checks.
729
+
730
+ Loads the generation service config from the request, loads and filters the
731
+ extractor config by source, manual trigger, and stride_size, then runs the
732
+ pre-extraction gate.
733
+
734
+ Args:
735
+ request: The request object containing parameters
736
+
737
+ Returns:
738
+ PreparedGenerationRun when generation should proceed, otherwise None.
739
+ """
740
+ self.service_config = self._load_generation_service_config(request)
741
+
742
+ extractor_config = self._load_extractor_config()
743
+ if extractor_config is None:
744
+ logger.warning("No %s extractor config found", self._get_service_name())
745
+ return None
746
+
747
+ extractor_config = self._filter_extractor_config_by_service_config(
748
+ extractor_config, self.service_config
749
+ )
750
+
751
+ if extractor_config is None:
752
+ source = getattr(self.service_config, "source", "N/A")
753
+ source_display = source or "N/A"
754
+ logger.info(
755
+ "No %s extractor config enabled for source: %s",
756
+ self._get_service_name(),
757
+ source_display,
758
+ )
759
+ return None
760
+
761
+ extractor_config = self._filter_config_by_stride(extractor_config)
762
+ if extractor_config is None:
763
+ logger.info(
764
+ "Extractor config did not pass stride_size check for %s",
765
+ self._get_service_name(),
766
+ )
767
+ return None
768
+
769
+ identifier = getattr(self.service_config, "user_id", None) or getattr(
770
+ self.service_config, "request_id", "unknown"
771
+ )
772
+ extractor_name = get_extractor_name(extractor_config)
773
+
774
+ should_run = self._should_run_before_extraction(extractor_config)
775
+ self._record_generation_event(
776
+ event_name="generation_gate_evaluated",
777
+ outcome="should_run" if should_run else "should_skip",
778
+ count_value=1,
779
+ metadata={
780
+ "identifier": identifier,
781
+ "extractor_name": extractor_name,
782
+ },
783
+ )
784
+
785
+ if not should_run:
786
+ logger.info(
787
+ "Pre-extraction check returned False for %s identifier=%s, skipping",
788
+ self._get_service_name(),
789
+ identifier,
790
+ )
791
+ return None
792
+
793
+ return PreparedGenerationRun(
794
+ extractor_config=extractor_config,
795
+ extractor_name=extractor_name,
796
+ identifier=identifier,
797
+ )
798
+
799
+ def _execute_extractor(
800
+ self,
801
+ extractor_config: TExtractorConfig,
802
+ identifier: str,
803
+ ) -> Any | None:
804
+ """
805
+ Run the configured extractor with timeout and error handling.
806
+
807
+ The extractor runs in a thread pool with a timeout guard so providers that
808
+ ignore their own timeout cannot block generation forever.
809
+
810
+ Args:
811
+ extractor_config: Filtered extractor config to execute
812
+ identifier: Logging context identifier (user_id or request_id)
813
+
814
+ Returns:
815
+ Extractor result, or None if the extractor succeeded with no output.
816
+
817
+ Raises:
818
+ ExtractorExecutionError: If the extractor fails with an exception or timeout.
819
+ """
820
+ if (
821
+ self.service_config is None
822
+ ): # pragma: no cover — set by _prepare_generation_run
823
+ raise RuntimeError("service_config must be set before executing extractor")
824
+
825
+ self._last_extractor_run_stats = {"total": 1, "failed": 0, "timed_out": 0}
826
+ extractor = self._create_extractor(extractor_config, self.service_config)
827
+ executor: ThreadPoolExecutor | None = None
828
+ try:
829
+ executor = ThreadPoolExecutor(max_workers=1)
830
+ # Copy context so correlation ID propagates to worker thread
831
+ ctx = contextvars.copy_context()
832
+ future = executor.submit(ctx.run, extractor.run) # type: ignore[reportAttributeAccessIssue]
833
+ result = future.result(timeout=EXTRACTOR_TIMEOUT_SECONDS)
834
+ if isinstance(result, ExtractionOutcome):
835
+ if result.run_id:
836
+ self._last_extraction_run_ids.append(result.run_id)
837
+ if result.status == "completed" and result.items:
838
+ return result.items
839
+ logger.info(
840
+ "No results generated for %s identifier: %s",
841
+ self._get_service_name(),
842
+ identifier,
843
+ )
844
+ return None
845
+ if result:
846
+ return result
847
+ logger.info(
848
+ "No results generated for %s identifier: %s",
849
+ self._get_service_name(),
850
+ identifier,
851
+ )
852
+ return None
853
+ except FuturesTimeoutError as exc:
854
+ self._last_extractor_run_stats = {"total": 1, "failed": 1, "timed_out": 1}
855
+ error_msg = (
856
+ f"Extractor timed out after {EXTRACTOR_TIMEOUT_SECONDS} seconds "
857
+ f"for {self._get_service_name()} identifier={identifier}"
858
+ )
859
+ logger.error(error_msg)
860
+ raise ExtractorExecutionError(error_msg) from exc
861
+ except Exception as exc:
862
+ self._last_extractor_run_stats = {"total": 1, "failed": 1, "timed_out": 0}
863
+ error_msg = (
864
+ f"Extractor failed for {self._get_service_name()} "
865
+ f"identifier={identifier}: {exc} (type={type(exc).__name__})"
866
+ )
867
+ logger.error(error_msg)
868
+ raise ExtractorExecutionError(error_msg) from exc
869
+ finally:
870
+ if executor is not None:
871
+ executor.shutdown(wait=False, cancel_futures=True)
872
+
873
+ def _finalize_extraction_runs(self) -> None:
874
+ if self.storage is None:
875
+ return
876
+ for run_id in self._last_extraction_run_ids:
877
+ run = self.storage.get_agent_run(run_id)
878
+ if run is None:
879
+ continue
880
+ status = (
881
+ AgentRunStatus.FINALIZED_PENDING_TOOL
882
+ if run.pending_tool_call_ids
883
+ else AgentRunStatus.FINALIZED
884
+ )
885
+ self.storage.update_agent_run_status(
886
+ run_id,
887
+ status,
888
+ pending_tool_call_ids=run.pending_tool_call_ids,
889
+ )
890
+
891
+ def _mark_extraction_runs_finalization_failed(self, exc: Exception) -> None:
892
+ if self.storage is None:
893
+ return
894
+ root_config = self.request_context.configurator.get_config()
895
+ pending_config = getattr(root_config, "pending_tool_call_config", None)
896
+ for run_id in self._last_extraction_run_ids:
897
+ run = self.storage.get_agent_run(run_id)
898
+ if run is None or run.committed_output is None:
899
+ continue
900
+ next_attempt_count = run.finalization_attempts + 1
901
+ max_attempts = (
902
+ pending_config.max_finalization_attempts
903
+ if pending_config is not None
904
+ else 3
905
+ )
906
+ status = (
907
+ AgentRunStatus.FAILED
908
+ if next_attempt_count >= max_attempts
909
+ else AgentRunStatus.FINALIZATION_FAILED
910
+ )
911
+ delay_seconds = min(300, max(1, 2 ** max(0, next_attempt_count - 1)))
912
+ self.storage.update_agent_run_status(
913
+ run_id,
914
+ status,
915
+ next_resume_at=datetime.now(UTC) + timedelta(seconds=delay_seconds),
916
+ last_error=str(exc),
917
+ increment_finalization_attempts=True,
918
+ )
919
+
920
+ def _should_run_before_extraction(self, extractor_config: TExtractorConfig) -> bool:
921
+ """
922
+ Pre-extraction check called before extractor execution.
923
+
924
+ Template method that:
925
+ 1. Skips for non-auto runs and mock mode
926
+ 2. Returns True immediately when service_config.force_extraction=True
927
+ (bypasses cheap pre-filter and LLM should_run vote)
928
+ 3. Collects scoped interactions via _collect_scoped_interactions_for_precheck
929
+ 4. Delegates prompt building to _build_should_run_prompt (subclass hook)
930
+ 5. Makes a single LLM call to determine if extraction should proceed
931
+
932
+ Override _build_should_run_prompt in subclasses to provide service-specific
933
+ criteria and prompt construction. Default returns True (always run) when
934
+ no prompt hook is provided.
935
+
936
+ Args:
937
+ extractor_config: Enabled extractor config that will be run
938
+
939
+ Returns:
940
+ bool: True if extraction should proceed, False to skip
941
+ """
942
+ # Skip for non-auto runs (rerun/manual flows always run)
943
+ if not getattr(self.service_config, "auto_run", True):
944
+ return True
945
+
946
+ # Skip for mock mode
947
+ if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
948
+ return True
949
+
950
+ # `force_extraction=True` is the caller's explicit "no gates" signal —
951
+ # corrections, manual /learn, anything time-sensitive. Bypass the
952
+ # cheap pre-filter (slash-only / too-short rejects) and the LLM
953
+ # should_run vote so the extractor always runs on this batch.
954
+ if getattr(self.service_config, "force_extraction", False):
955
+ return True
956
+
957
+ # Skip if org config disables the pre-extraction check
958
+ root_config = self.request_context.configurator.get_config()
959
+ if root_config and root_config.skip_should_run_check:
960
+ logger.info(
961
+ "skip_should_run_check is enabled for %s, bypassing pre-extraction check",
962
+ self._get_service_name(),
963
+ )
964
+ return True
965
+
966
+ # Collect scoped interactions
967
+ session_data_models, scoped_config = (
968
+ self._collect_scoped_interactions_for_precheck(extractor_config)
969
+ )
970
+ if not session_data_models:
971
+ logger.info(
972
+ "No interactions found for consolidated should_generate check for %s",
973
+ self._get_service_name(),
974
+ )
975
+ return False
976
+
977
+ # Cheap pre-filter: reject batches that are structurally unable
978
+ # to yield signal (slash-commands only, too-short user turns,
979
+ # extractor-prompt echoes) without burning a 5–7s LLM call. See
980
+ # _cheap_should_run_reject for the rule set.
981
+ reject_reason = _cheap_should_run_reject(session_data_models)
982
+ if reject_reason is not None:
983
+ logger.info(
984
+ "Cheap pre-filter rejected %s should_run: reason=%s identifier=%s",
985
+ self._get_service_name(),
986
+ reject_reason,
987
+ getattr(self.service_config, "user_id", None) or "unknown",
988
+ )
989
+ return False
990
+
991
+ # Build prompt via subclass hook
992
+ prompt = self._build_should_run_prompt(scoped_config, session_data_models)
993
+ if not prompt:
994
+ return True # No prompt means no check needed, proceed
995
+
996
+ # Resolve model and make LLM call
997
+ should_run_model = self._resolve_should_run_model()
998
+ identifier = getattr(self.service_config, "user_id", None) or "unknown"
999
+ try:
1000
+ should_start = time.perf_counter()
1001
+ logger.info(
1002
+ "event=consolidated_should_run_start service=%s identifier=%s model=%s extractor=%s",
1003
+ self._get_service_name(),
1004
+ identifier,
1005
+ should_run_model,
1006
+ get_extractor_name(extractor_config),
1007
+ )
1008
+ log_llm_messages(
1009
+ logger,
1010
+ "Should extract check",
1011
+ [{"role": "user", "content": prompt}],
1012
+ )
1013
+
1014
+ content = self.client.generate_chat_response(
1015
+ messages=[{"role": "user", "content": prompt}],
1016
+ model=should_run_model,
1017
+ )
1018
+ log_model_response(
1019
+ logger,
1020
+ f"Consolidated {self._get_service_name()} should_run response",
1021
+ content,
1022
+ )
1023
+ decision = bool(content and "true" in content.lower()) # type: ignore[reportAttributeAccessIssue]
1024
+ logger.info(
1025
+ "event=consolidated_should_run_end service=%s identifier=%s elapsed_seconds=%.3f decision=%s",
1026
+ self._get_service_name(),
1027
+ identifier,
1028
+ time.perf_counter() - should_start,
1029
+ decision,
1030
+ )
1031
+ return decision
1032
+ except Exception as exc:
1033
+ logger.error(
1034
+ "Consolidated should_generate check failed for %s: %s, defaulting to run",
1035
+ self._get_service_name(),
1036
+ str(exc),
1037
+ )
1038
+ return True
1039
+
1040
+ def _build_should_run_prompt(
1041
+ self,
1042
+ scoped_config: TExtractorConfig, # noqa: ARG002
1043
+ session_data_models: list[RequestInteractionDataModel], # noqa: ARG002
1044
+ ) -> str | None:
1045
+ """
1046
+ Build the prompt for the consolidated should_run LLM check.
1047
+
1048
+ Override in subclasses to provide service-specific criteria building
1049
+ and prompt rendering. Return None if no check is needed (always proceed).
1050
+
1051
+ Args:
1052
+ scoped_config: Extractor config that had scoped interactions
1053
+ session_data_models: Deduplicated request interaction data models
1054
+
1055
+ Returns:
1056
+ Optional[str]: The rendered prompt string, or None to skip the check
1057
+ """
1058
+ return None
1059
+
1060
+ def _collect_scoped_interactions_for_precheck(
1061
+ self, extractor_config: TExtractorConfig
1062
+ ) -> tuple[list[RequestInteractionDataModel], TExtractorConfig]:
1063
+ """
1064
+ Collect interactions for consolidated pre-check using extractor-scoped filters.
1065
+
1066
+ Mirrors each extractor's source/window scope so the consolidated gate
1067
+ does not skip valid extraction because of an unrelated fixed interaction slice.
1068
+
1069
+ Args:
1070
+ extractor_config: Enabled extractor config after request-level filtering
1071
+
1072
+ Returns:
1073
+ tuple: (session data models, extractor config)
1074
+ """
1075
+ root_config = self.request_context.configurator.get_config()
1076
+ global_window_size = (
1077
+ getattr(root_config, "window_size", None) if root_config else None
1078
+ )
1079
+ global_stride_size = (
1080
+ getattr(root_config, "stride_size", None) if root_config else None
1081
+ )
1082
+
1083
+ extra_kwargs = self._get_precheck_interaction_query_kwargs()
1084
+
1085
+ should_skip, effective_source = get_effective_source_filter(
1086
+ extractor_config, getattr(self.service_config, "source", None)
1087
+ )
1088
+ if should_skip:
1089
+ return [], extractor_config
1090
+
1091
+ window_size, _ = get_extractor_window_params(
1092
+ extractor_config, global_window_size, global_stride_size
1093
+ )
1094
+ session_data_models, _ = self.storage.get_last_k_interactions_grouped( # type: ignore[reportOptionalMemberAccess]
1095
+ user_id=getattr(self.service_config, "user_id", None),
1096
+ k=window_size,
1097
+ sources=effective_source,
1098
+ start_time=getattr(self.service_config, "rerun_start_time", None),
1099
+ end_time=getattr(self.service_config, "rerun_end_time", None),
1100
+ **extra_kwargs,
1101
+ )
1102
+
1103
+ return session_data_models, extractor_config
1104
+
1105
+ def _get_precheck_interaction_query_kwargs(self) -> dict:
1106
+ """
1107
+ Return extra keyword arguments for get_last_k_interactions_grouped in precheck.
1108
+
1109
+ Override in subclasses that need additional query parameters
1110
+ (e.g., agent_version for playbook services).
1111
+
1112
+ Returns:
1113
+ dict: Extra kwargs to pass to get_last_k_interactions_grouped
1114
+ """
1115
+ return {}
1116
+
1117
+ def _resolve_should_run_model(self) -> str:
1118
+ """
1119
+ Resolve the model name for should_run/should_generate LLM checks.
1120
+
1121
+ Uses LLM config override if available, falls back to site var setting.
1122
+
1123
+ Returns:
1124
+ str: Model name for the should_run check
1125
+ """
1126
+ from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
1127
+ from reflexio.server.site_var.site_var_manager import SiteVarManager
1128
+
1129
+ root_config = self.request_context.configurator.get_config()
1130
+ llm_config = root_config.llm_config if root_config else None
1131
+ api_key_config = root_config.api_key_config if root_config else None
1132
+
1133
+ model_setting = SiteVarManager().get_site_var("llm_model_setting")
1134
+ site_var = model_setting if isinstance(model_setting, dict) else {}
1135
+
1136
+ return resolve_model_name(
1137
+ ModelRole.SHOULD_RUN,
1138
+ site_var_value=site_var.get("should_run_model_name"),
1139
+ config_override=llm_config.should_run_model_name if llm_config else None,
1140
+ api_key_config=api_key_config,
1141
+ )
1142
+
1143
+ # ===============================
1144
+ # Batch with progress (shared by rerun + manual)
1145
+ # ===============================
1146
+
1147
+ def _run_batch_with_progress(
1148
+ self,
1149
+ user_ids: list[str],
1150
+ request: TRequest,
1151
+ request_params: dict,
1152
+ state_manager: OperationStateManager,
1153
+ ) -> tuple[int, int]:
1154
+ """Run a batch of users with progress tracking.
1155
+
1156
+ Shared logic for both run_rerun() and run_manual_regular().
1157
+ Initializes progress, processes each user, and finalizes.
1158
+ Checks for cancellation before each user.
1159
+
1160
+ Args:
1161
+ user_ids: List of user IDs to process
1162
+ request: The original request object
1163
+ request_params: Parameters dict for progress state
1164
+ state_manager: OperationStateManager instance
1165
+
1166
+ Returns:
1167
+ Tuple of (users_processed, total_generated)
1168
+ """
1169
+ total_users = len(user_ids)
1170
+ self._is_batch_mode = True
1171
+
1172
+ # Initialize progress
1173
+ state_manager.initialize_progress(
1174
+ total_users=total_users,
1175
+ request_params=request_params,
1176
+ )
1177
+
1178
+ try:
1179
+ # Process each user
1180
+ users_processed = 0
1181
+ processed_user_ids: list[str] = []
1182
+ for user_id in user_ids:
1183
+ # Check for cancellation before starting next user
1184
+ if state_manager.is_cancellation_requested():
1185
+ logger.info(
1186
+ "Cancellation requested for %s, stopping after %d/%d users",
1187
+ self._get_base_service_name(),
1188
+ users_processed,
1189
+ total_users,
1190
+ )
1191
+ state_manager.mark_cancelled()
1192
+ return users_processed, self._get_generated_count(
1193
+ request, processed_user_ids=processed_user_ids
1194
+ )
1195
+
1196
+ state_manager.set_current_item(user_id)
1197
+
1198
+ try:
1199
+ run_request = self._create_run_request_for_item(user_id, request)
1200
+ self.run(run_request)
1201
+ users_processed += 1
1202
+ processed_user_ids.append(user_id)
1203
+
1204
+ state_manager.update_progress(
1205
+ item_id=user_id,
1206
+ count=0, # Extractors collect their own data
1207
+ success=True,
1208
+ total_users=total_users,
1209
+ )
1210
+
1211
+ except Exception as e:
1212
+ logger.error(
1213
+ "Failed to process user %s for %s: %s",
1214
+ user_id,
1215
+ self._get_base_service_name(),
1216
+ str(e),
1217
+ )
1218
+ state_manager.update_progress(
1219
+ item_id=user_id,
1220
+ count=0,
1221
+ success=False,
1222
+ total_users=total_users,
1223
+ error=str(e),
1224
+ )
1225
+ continue
1226
+
1227
+ # Get generated count and finalize
1228
+ total_generated = self._get_generated_count(
1229
+ request, processed_user_ids=processed_user_ids
1230
+ )
1231
+ state_manager.finalize_progress(users_processed, total_generated)
1232
+
1233
+ return users_processed, total_generated
1234
+ finally:
1235
+ self._is_batch_mode = False
1236
+
1237
+ # ===============================
1238
+ # Rerun methods (optional - override to enable rerun functionality)
1239
+ # ===============================
1240
+
1241
+ def _get_rerun_user_ids(self, request: TRequest) -> list[str]:
1242
+ """Get user IDs to process during rerun.
1243
+
1244
+ Override this method to enable rerun functionality for the service.
1245
+ Returns a list of user IDs that have interactions matching the request filters.
1246
+ Each extractor collects its own data using its configured window_size.
1247
+
1248
+ Args:
1249
+ request: The rerun request object
1250
+
1251
+ Returns:
1252
+ List of user IDs to process
1253
+ """
1254
+ raise NotImplementedError("Rerun not supported by this service")
1255
+
1256
+ def _build_rerun_request_params(self, request: TRequest) -> dict:
1257
+ """Build request params dict for operation state tracking.
1258
+
1259
+ Override this method to enable rerun functionality for the service.
1260
+
1261
+ Args:
1262
+ request: The rerun request object
1263
+
1264
+ Returns:
1265
+ Dictionary of request parameters for state tracking
1266
+ """
1267
+ raise NotImplementedError("Rerun not supported by this service")
1268
+
1269
+ def _create_run_request_for_item(self, user_id: str, request: TRequest) -> TRequest:
1270
+ """Create the request object to pass to self.run() for a single user.
1271
+
1272
+ Override this method to enable rerun functionality for the service.
1273
+ Each extractor collects its own data using its configured window_size.
1274
+
1275
+ Args:
1276
+ user_id: The user ID to process
1277
+ request: The original rerun request object
1278
+
1279
+ Returns:
1280
+ A request object suitable for self.run()
1281
+ """
1282
+ raise NotImplementedError("Rerun not supported by this service")
1283
+
1284
+ def _create_rerun_response(self, success: bool, msg: str, count: int) -> Any:
1285
+ """Create the rerun response object.
1286
+
1287
+ Override this method to enable rerun functionality for the service.
1288
+
1289
+ Args:
1290
+ success: Whether the operation succeeded
1291
+ msg: Status message
1292
+ count: Number of items generated
1293
+
1294
+ Returns:
1295
+ A response object (e.g., RerunProfileGenerationResponse)
1296
+ """
1297
+ raise NotImplementedError("Rerun not supported by this service")
1298
+
1299
+ def _get_generated_count(
1300
+ self,
1301
+ request: TRequest,
1302
+ processed_user_ids: list[str] | None = None,
1303
+ ) -> int:
1304
+ """Get the count of generated items (profiles or playbooks) after rerun.
1305
+
1306
+ Override this method to enable rerun functionality for the service.
1307
+
1308
+ Args:
1309
+ request: The rerun request object (for filtering)
1310
+ processed_user_ids: List of user IDs that were successfully processed
1311
+ in the batch. Provided by _run_batch_with_progress so overrides
1312
+ don't need to handle user_id=None from batch requests.
1313
+
1314
+ Returns:
1315
+ Number of items generated during rerun
1316
+ """
1317
+ raise NotImplementedError("Rerun not supported by this service")
1318
+
1319
+ def _pre_process_rerun(self, request: TRequest) -> None: # noqa: B027
1320
+ """Hook called before processing rerun items.
1321
+
1322
+ Override in subclasses to perform cleanup or preparation before rerun.
1323
+ Default implementation does nothing.
1324
+
1325
+ Args:
1326
+ request: The rerun request object
1327
+ """
1328
+
1329
+ def run_rerun(self, request: TRequest) -> Any:
1330
+ """Run the rerun workflow for the service.
1331
+
1332
+ This template method orchestrates the rerun process:
1333
+ 1. Check for existing in-progress operations
1334
+ 2. Get user IDs to process
1335
+ 3. Pre-process hook
1336
+ 4. Run batch with progress tracking
1337
+ 5. Return response
1338
+
1339
+ Child classes must implement the hook methods to enable rerun functionality:
1340
+ - _get_rerun_user_ids()
1341
+ - _build_rerun_request_params()
1342
+ - _create_run_request_for_item()
1343
+ - _create_rerun_response()
1344
+
1345
+ Args:
1346
+ request: The rerun request object
1347
+
1348
+ Returns:
1349
+ A response object with success status, message, and count
1350
+ """
1351
+ state_manager = self._create_state_manager()
1352
+
1353
+ try:
1354
+ # 1. Check for existing in-progress operation
1355
+ error = state_manager.check_in_progress()
1356
+ if error:
1357
+ return self._create_rerun_response(False, error, 0)
1358
+
1359
+ # 2. Get user IDs to process
1360
+ user_ids = self._get_rerun_user_ids(request)
1361
+ if not user_ids:
1362
+ return self._create_rerun_response(
1363
+ False, "No interactions found matching the specified filters", 0
1364
+ )
1365
+
1366
+ # 3. Pre-process hook (e.g., delete existing pending items)
1367
+ self._pre_process_rerun(request)
1368
+
1369
+ # 4. Run batch with progress tracking
1370
+ users_processed, total_generated = self._run_batch_with_progress(
1371
+ user_ids=user_ids,
1372
+ request=request,
1373
+ request_params=self._build_rerun_request_params(request),
1374
+ state_manager=state_manager,
1375
+ )
1376
+
1377
+ msg = f"Completed for {users_processed} user(s)"
1378
+ return self._create_rerun_response(True, msg, total_generated)
1379
+
1380
+ except Exception as e:
1381
+ state_manager.mark_progress_failed(str(e))
1382
+ return self._create_rerun_response(
1383
+ False,
1384
+ f"Failed to run {self._get_base_service_name()}: {str(e)}",
1385
+ 0,
1386
+ )
1387
+
1388
+ # ===============================
1389
+ # Upgrade/Downgrade methods (optional - override to enable)
1390
+ # ===============================
1391
+
1392
+ def _has_items_with_status(self, status: Status | None, request: TRequest) -> bool:
1393
+ """Check if items exist with given status and filters from request.
1394
+
1395
+ Override this method to enable upgrade/downgrade functionality for the service.
1396
+
1397
+ Args:
1398
+ status: The status to check for (None for CURRENT)
1399
+ request: The upgrade/downgrade request object with filters
1400
+
1401
+ Returns:
1402
+ bool: True if any matching items exist
1403
+ """
1404
+ raise NotImplementedError("Upgrade/downgrade not supported by this service")
1405
+
1406
+ def _delete_items_by_status(self, status: Status, request: TRequest) -> int:
1407
+ """Delete items with given status matching request filters.
1408
+
1409
+ Override this method to enable upgrade/downgrade functionality for the service.
1410
+
1411
+ Args:
1412
+ status: The status of items to delete
1413
+ request: The upgrade/downgrade request object with filters
1414
+
1415
+ Returns:
1416
+ int: Number of items deleted
1417
+ """
1418
+ raise NotImplementedError("Upgrade/downgrade not supported by this service")
1419
+
1420
+ def _update_items_status(
1421
+ self,
1422
+ old_status: Status | None,
1423
+ new_status: Status | None,
1424
+ request: TRequest,
1425
+ user_ids: list[str] | None = None,
1426
+ ) -> int:
1427
+ """Update items from old_status to new_status with request filters.
1428
+
1429
+ Override this method to enable upgrade/downgrade functionality for the service.
1430
+
1431
+ Args:
1432
+ old_status: The current status to match (None for CURRENT)
1433
+ new_status: The new status to set (None for CURRENT)
1434
+ request: The upgrade/downgrade request object with filters
1435
+ user_ids: Optional pre-computed list of user IDs to filter by
1436
+
1437
+ Returns:
1438
+ int: Number of items updated
1439
+ """
1440
+ raise NotImplementedError("Upgrade/downgrade not supported by this service")
1441
+
1442
+ def _get_affected_user_ids_for_upgrade(self, request: TRequest) -> list[str] | None: # noqa: ARG002
1443
+ """Get user IDs to filter by for upgrade operations.
1444
+
1445
+ Override this method to support the only_affected_users flag.
1446
+ By default returns None (no filtering).
1447
+
1448
+ Args:
1449
+ request: The upgrade request object
1450
+
1451
+ Returns:
1452
+ Optional[list[str]]: List of user IDs to filter by, or None for no filtering
1453
+ """
1454
+ return None
1455
+
1456
+ def _get_affected_user_ids_for_downgrade(
1457
+ self,
1458
+ request: TRequest, # noqa: ARG002
1459
+ ) -> list[str] | None:
1460
+ """Get user IDs to filter by for downgrade operations.
1461
+
1462
+ Override this method to support the only_affected_users flag.
1463
+ By default returns None (no filtering).
1464
+
1465
+ Args:
1466
+ request: The downgrade request object
1467
+
1468
+ Returns:
1469
+ Optional[list[str]]: List of user IDs to filter by, or None for no filtering
1470
+ """
1471
+ return None
1472
+
1473
+ def _create_status_change_response(
1474
+ self,
1475
+ operation: StatusChangeOperation,
1476
+ success: bool,
1477
+ counts: dict,
1478
+ msg: str,
1479
+ ) -> Any:
1480
+ """Create upgrade or downgrade response object based on operation type.
1481
+
1482
+ Override this method to enable upgrade/downgrade functionality for the service.
1483
+
1484
+ Args:
1485
+ operation: The operation type (UPGRADE or DOWNGRADE)
1486
+ success: Whether the operation succeeded
1487
+ counts: Dictionary of counts (upgrade: deleted/archived/promoted, downgrade: demoted/restored)
1488
+ msg: Status message
1489
+
1490
+ Returns:
1491
+ A response object (e.g., UpgradeProfilesResponse, DowngradeUserPlaybooksResponse)
1492
+ """
1493
+ raise NotImplementedError("Upgrade/downgrade not supported by this service")
1494
+
1495
+ def run_upgrade(self, request: TRequest) -> Any:
1496
+ """Run the upgrade workflow for the service.
1497
+
1498
+ This template method orchestrates the upgrade process:
1499
+ 1. Validate that pending items exist
1500
+ 2. Delete old archived items
1501
+ 3. Archive current items (None → ARCHIVED)
1502
+ 4. Promote pending items (PENDING → None/CURRENT)
1503
+
1504
+ Child classes must implement the hook methods to enable upgrade functionality:
1505
+ - _has_items_with_status()
1506
+ - _delete_items_by_status()
1507
+ - _update_items_status()
1508
+ - _create_status_change_response()
1509
+
1510
+ Args:
1511
+ request: The upgrade request object with optional filters
1512
+
1513
+ Returns:
1514
+ A response object with success status, counts, and message
1515
+ """
1516
+ try:
1517
+ # 1. Validate pending items exist
1518
+ if not self._has_items_with_status(Status.PENDING, request):
1519
+ return self._create_status_change_response(
1520
+ StatusChangeOperation.UPGRADE,
1521
+ False,
1522
+ {"deleted": 0, "archived": 0, "promoted": 0},
1523
+ "No pending items found to upgrade",
1524
+ )
1525
+
1526
+ # Get affected user IDs once (child class determines the logic)
1527
+ affected_user_ids = self._get_affected_user_ids_for_upgrade(request)
1528
+
1529
+ # 2. Delete old archived items (skip if archive_current=False)
1530
+ deleted = 0
1531
+ archived = 0
1532
+ if getattr(request, "archive_current", True):
1533
+ deleted = self._delete_items_by_status(Status.ARCHIVED, request)
1534
+
1535
+ # 3. Archive current items (None → ARCHIVED)
1536
+ archived = self._update_items_status(
1537
+ None, Status.ARCHIVED, request, user_ids=affected_user_ids
1538
+ )
1539
+
1540
+ # 4. Promote pending items (PENDING → None)
1541
+ promoted = self._update_items_status(
1542
+ Status.PENDING, None, request, user_ids=affected_user_ids
1543
+ )
1544
+
1545
+ msg = f"Upgraded: {promoted} promoted, {archived} archived, {deleted} old archived deleted"
1546
+ return self._create_status_change_response(
1547
+ StatusChangeOperation.UPGRADE,
1548
+ True,
1549
+ {"deleted": deleted, "archived": archived, "promoted": promoted},
1550
+ msg,
1551
+ )
1552
+
1553
+ except Exception as e:
1554
+ return self._create_status_change_response(
1555
+ StatusChangeOperation.UPGRADE,
1556
+ False,
1557
+ {"deleted": 0, "archived": 0, "promoted": 0},
1558
+ f"Failed to upgrade: {str(e)}",
1559
+ )
1560
+
1561
+ def run_downgrade(self, request: TRequest) -> Any:
1562
+ """Run the downgrade workflow for the service.
1563
+
1564
+ This template method orchestrates the downgrade process:
1565
+ 1. Validate that archived items exist
1566
+ 2. Demote current items (None → ARCHIVE_IN_PROGRESS)
1567
+ 3. Restore archived items (ARCHIVED → None/CURRENT)
1568
+ 4. Complete archiving (ARCHIVE_IN_PROGRESS → ARCHIVED)
1569
+
1570
+ Child classes must implement the hook methods to enable downgrade functionality:
1571
+ - _has_items_with_status()
1572
+ - _update_items_status()
1573
+ - _create_status_change_response()
1574
+
1575
+ Args:
1576
+ request: The downgrade request object with optional filters
1577
+
1578
+ Returns:
1579
+ A response object with success status, counts, and message
1580
+ """
1581
+ try:
1582
+ # 1. Validate archived items exist
1583
+ if not self._has_items_with_status(Status.ARCHIVED, request):
1584
+ return self._create_status_change_response(
1585
+ StatusChangeOperation.DOWNGRADE,
1586
+ False,
1587
+ {"demoted": 0, "restored": 0},
1588
+ "No archived items found to restore",
1589
+ )
1590
+
1591
+ # Get affected user IDs once (child class determines the logic)
1592
+ affected_user_ids = self._get_affected_user_ids_for_downgrade(request)
1593
+
1594
+ # 2. Demote current (None → ARCHIVE_IN_PROGRESS)
1595
+ demoted = self._update_items_status(
1596
+ None, Status.ARCHIVE_IN_PROGRESS, request, user_ids=affected_user_ids
1597
+ )
1598
+
1599
+ # 3. Restore archived (ARCHIVED → None)
1600
+ restored = self._update_items_status(
1601
+ Status.ARCHIVED, None, request, user_ids=affected_user_ids
1602
+ )
1603
+
1604
+ # 4. Complete archiving (ARCHIVE_IN_PROGRESS → ARCHIVED)
1605
+ self._update_items_status(
1606
+ Status.ARCHIVE_IN_PROGRESS,
1607
+ Status.ARCHIVED,
1608
+ request,
1609
+ user_ids=affected_user_ids,
1610
+ )
1611
+
1612
+ msg = f"Downgraded: {demoted} archived, {restored} restored"
1613
+ return self._create_status_change_response(
1614
+ StatusChangeOperation.DOWNGRADE,
1615
+ True,
1616
+ {"demoted": demoted, "restored": restored},
1617
+ msg,
1618
+ )
1619
+
1620
+ except Exception as e:
1621
+ return self._create_status_change_response(
1622
+ StatusChangeOperation.DOWNGRADE,
1623
+ False,
1624
+ {"demoted": 0, "restored": 0},
1625
+ f"Failed to downgrade: {str(e)}",
1626
+ )