claude-smart 0.2.42 → 0.2.44

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 (402) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.md +1 -1
  3. package/bin/claude-smart.js +2 -2
  4. package/package.json +9 -3
  5. package/plugin/.claude-plugin/plugin.json +9 -3
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +23 -3
  8. package/plugin/pyproject.toml +3 -3
  9. package/plugin/scripts/_lib.sh +91 -0
  10. package/plugin/scripts/backend-service.sh +51 -4
  11. package/plugin/scripts/cli.sh +3 -1
  12. package/plugin/scripts/codex-hook.js +72 -4
  13. package/plugin/scripts/dashboard-build.sh +1 -0
  14. package/plugin/scripts/dashboard-service.sh +1 -0
  15. package/plugin/scripts/ensure-plugin-root.sh +1 -0
  16. package/plugin/scripts/hook_entry.sh +6 -3
  17. package/plugin/scripts/smart-install.sh +3 -2
  18. package/plugin/src/README.md +57 -0
  19. package/plugin/src/claude_smart/context_format.py +11 -12
  20. package/plugin/src/claude_smart/cs_cite.py +26 -12
  21. package/plugin/src/claude_smart/ids.py +13 -5
  22. package/plugin/uv.lock +126 -5
  23. package/plugin/vendor/reflexio/.env.example +62 -0
  24. package/plugin/vendor/reflexio/LICENSE +201 -0
  25. package/plugin/vendor/reflexio/README.md +338 -0
  26. package/plugin/vendor/reflexio/pyproject.toml +274 -0
  27. package/plugin/vendor/reflexio/reflexio/README.md +184 -0
  28. package/plugin/vendor/reflexio/reflexio/__init__.py +166 -0
  29. package/plugin/vendor/reflexio/reflexio/benchmarks/__init__.py +1 -0
  30. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/README.md +109 -0
  31. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/__init__.py +1 -0
  32. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/backends.py +175 -0
  33. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/bench.py +642 -0
  34. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/embed_cache.py +330 -0
  35. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/report.py +317 -0
  36. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/report.md +43 -0
  37. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/results.json +4478 -0
  38. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/scenarios.py +134 -0
  39. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/seed.py +255 -0
  40. package/plugin/vendor/reflexio/reflexio/cli/README.md +287 -0
  41. package/plugin/vendor/reflexio/reflexio/cli/__init__.py +0 -0
  42. package/plugin/vendor/reflexio/reflexio/cli/__main__.py +56 -0
  43. package/plugin/vendor/reflexio/reflexio/cli/_client.py +86 -0
  44. package/plugin/vendor/reflexio/reflexio/cli/app.py +127 -0
  45. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +265 -0
  46. package/plugin/vendor/reflexio/reflexio/cli/codex_auth.py +503 -0
  47. package/plugin/vendor/reflexio/reflexio/cli/commands/__init__.py +0 -0
  48. package/plugin/vendor/reflexio/reflexio/cli/commands/admin_cmd.py +65 -0
  49. package/plugin/vendor/reflexio/reflexio/cli/commands/agent_playbooks.py +503 -0
  50. package/plugin/vendor/reflexio/reflexio/cli/commands/api.py +114 -0
  51. package/plugin/vendor/reflexio/reflexio/cli/commands/auth.py +109 -0
  52. package/plugin/vendor/reflexio/reflexio/cli/commands/config_cmd.py +511 -0
  53. package/plugin/vendor/reflexio/reflexio/cli/commands/doctor.py +127 -0
  54. package/plugin/vendor/reflexio/reflexio/cli/commands/embeddings.py +53 -0
  55. package/plugin/vendor/reflexio/reflexio/cli/commands/interactions.py +478 -0
  56. package/plugin/vendor/reflexio/reflexio/cli/commands/profiles.py +303 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/commands/services.py +289 -0
  58. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +964 -0
  59. package/plugin/vendor/reflexio/reflexio/cli/commands/shortcuts.py +285 -0
  60. package/plugin/vendor/reflexio/reflexio/cli/commands/status_cmd.py +143 -0
  61. package/plugin/vendor/reflexio/reflexio/cli/commands/user_playbooks.py +373 -0
  62. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +284 -0
  63. package/plugin/vendor/reflexio/reflexio/cli/errors.py +217 -0
  64. package/plugin/vendor/reflexio/reflexio/cli/log_format.py +247 -0
  65. package/plugin/vendor/reflexio/reflexio/cli/output.py +867 -0
  66. package/plugin/vendor/reflexio/reflexio/cli/paths.py +41 -0
  67. package/plugin/vendor/reflexio/reflexio/cli/run_services.py +391 -0
  68. package/plugin/vendor/reflexio/reflexio/cli/state.py +204 -0
  69. package/plugin/vendor/reflexio/reflexio/cli/stop_services.py +96 -0
  70. package/plugin/vendor/reflexio/reflexio/cli/utils.py +329 -0
  71. package/plugin/vendor/reflexio/reflexio/client/__init__.py +3 -0
  72. package/plugin/vendor/reflexio/reflexio/client/cache.py +150 -0
  73. package/plugin/vendor/reflexio/reflexio/client/client.py +2613 -0
  74. package/plugin/vendor/reflexio/reflexio/defaults.py +23 -0
  75. package/plugin/vendor/reflexio/reflexio/integrations/__init__.py +0 -0
  76. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/.clawhubignore +7 -0
  77. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/README.md +274 -0
  78. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/TESTING.md +517 -0
  79. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/hook/handler.js +473 -0
  80. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package-lock.json +2156 -0
  81. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package.json +18 -0
  82. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/handler.ts +241 -0
  83. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/setup.ts +140 -0
  84. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/index.ts +130 -0
  85. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/publish.ts +113 -0
  86. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/search.ts +52 -0
  87. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/server.ts +103 -0
  88. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/sqlite-buffer.ts +156 -0
  89. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/user-id.ts +134 -0
  90. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/openclaw.plugin.json +41 -0
  91. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/package.json +17 -0
  92. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/rules/reflexio.md +24 -0
  93. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/reflexio/SKILL.md +48 -0
  94. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/publish_clawhub.sh +278 -0
  95. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/references/HOOK.md +164 -0
  96. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/install.sh +36 -0
  97. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/uninstall.sh +35 -0
  98. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/publish.test.ts +27 -0
  99. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/search.test.ts +31 -0
  100. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/server.test.ts +42 -0
  101. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/setup.test.ts +49 -0
  102. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/sqlite-buffer.test.ts +91 -0
  103. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/user-id.test.ts +50 -0
  104. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tsconfig.json +16 -0
  105. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/types/openclaw.d.ts +230 -0
  106. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/vitest.config.ts +13 -0
  107. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/README.md +120 -0
  108. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/TESTING.md +168 -0
  109. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package-lock.json +1657 -0
  110. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package.json +16 -0
  111. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/HEARTBEAT.md +6 -0
  112. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/README.md +84 -0
  113. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/SKILL.md +194 -0
  114. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/_meta.json +6 -0
  115. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/agents/reflexio-extractor.md +45 -0
  116. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/handler.ts +214 -0
  117. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/setup.ts +55 -0
  118. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/index.ts +327 -0
  119. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/consolidate.ts +233 -0
  120. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/dedup.ts +80 -0
  121. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/io.ts +155 -0
  122. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/openclaw-cli.ts +67 -0
  123. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/search.ts +33 -0
  124. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-playbook.ts +76 -0
  125. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-profile.ts +79 -0
  126. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/openclaw.plugin.json +46 -0
  127. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/package.json +18 -0
  128. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/README.md +36 -0
  129. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/full_consolidation.md +56 -0
  130. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/playbook_extraction.md +217 -0
  131. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/profile_extraction.md +132 -0
  132. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-consolidate/SKILL.md +33 -0
  133. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-embedded/SKILL.md +194 -0
  134. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/HOOK.md +18 -0
  135. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/architecture.md +49 -0
  136. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/comparison.md +31 -0
  137. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/future-work.md +47 -0
  138. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/porting-notes.md +52 -0
  139. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/install.sh +52 -0
  140. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/uninstall.sh +36 -0
  141. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/consolidate.test.ts +135 -0
  142. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/dedup.test.ts +104 -0
  143. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/io.test.ts +175 -0
  144. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/search.test.ts +66 -0
  145. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/smoke-test.ts +140 -0
  146. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-playbook.test.ts +93 -0
  147. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-profile.test.ts +174 -0
  148. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tsconfig.json +16 -0
  149. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/types/openclaw.d.ts +230 -0
  150. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/vitest.config.ts +7 -0
  151. package/plugin/vendor/reflexio/reflexio/lib/__init__.py +23 -0
  152. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +310 -0
  153. package/plugin/vendor/reflexio/reflexio/lib/_base.py +225 -0
  154. package/plugin/vendor/reflexio/reflexio/lib/_config.py +83 -0
  155. package/plugin/vendor/reflexio/reflexio/lib/_dashboard.py +266 -0
  156. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +176 -0
  157. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +334 -0
  158. package/plugin/vendor/reflexio/reflexio/lib/_operations.py +153 -0
  159. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +545 -0
  160. package/plugin/vendor/reflexio/reflexio/lib/_reflection.py +52 -0
  161. package/plugin/vendor/reflexio/reflexio/lib/_search.py +167 -0
  162. package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +103 -0
  163. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +288 -0
  164. package/plugin/vendor/reflexio/reflexio/lib/reflexio_lib.py +27 -0
  165. package/plugin/vendor/reflexio/reflexio/models/__init__.py +0 -0
  166. package/plugin/vendor/reflexio/reflexio/models/api_schema/__init__.py +0 -0
  167. package/plugin/vendor/reflexio/reflexio/models/api_schema/braintrust_schema.py +141 -0
  168. package/plugin/vendor/reflexio/reflexio/models/api_schema/common.py +41 -0
  169. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +3 -0
  170. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +1112 -0
  171. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +63 -0
  172. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +487 -0
  173. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +28 -0
  174. package/plugin/vendor/reflexio/reflexio/models/api_schema/pending_tool_call_schema.py +83 -0
  175. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +768 -0
  176. package/plugin/vendor/reflexio/reflexio/models/api_schema/service_schemas.py +9 -0
  177. package/plugin/vendor/reflexio/reflexio/models/api_schema/stall_state_schema.py +32 -0
  178. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/__init__.py +3 -0
  179. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +177 -0
  180. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +129 -0
  181. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/enums.py +25 -0
  182. package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +333 -0
  183. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +908 -0
  184. package/plugin/vendor/reflexio/reflexio/models/py.typed +0 -0
  185. package/plugin/vendor/reflexio/reflexio/server/OVERVIEW.md +90 -0
  186. package/plugin/vendor/reflexio/reflexio/server/README.md +622 -0
  187. package/plugin/vendor/reflexio/reflexio/server/__init__.py +210 -0
  188. package/plugin/vendor/reflexio/reflexio/server/__main__.py +132 -0
  189. package/plugin/vendor/reflexio/reflexio/server/_auth.py +25 -0
  190. package/plugin/vendor/reflexio/reflexio/server/api.py +2868 -0
  191. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +34 -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 +562 -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 +359 -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 +1871 -0
  206. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +140 -0
  207. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +479 -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 +338 -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 +288 -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 +187 -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 +716 -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_consolidation/v2.3.1.prompt.md +69 -0
  245. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +71 -0
  246. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.0.2.prompt.md +254 -0
  247. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.1.0.prompt.md +274 -0
  248. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +283 -0
  249. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.2.prompt.md +234 -0
  250. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +244 -0
  251. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v1.0.0.prompt.md +73 -0
  252. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v2.0.0.prompt.md +86 -0
  253. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.0.0.prompt.md +97 -0
  254. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.1.0.prompt.md +119 -0
  255. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.2.0.prompt.md +123 -0
  256. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +137 -0
  257. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.0.0.prompt.md +14 -0
  258. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.1.0.prompt.md +24 -0
  259. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.2.0.prompt.md +29 -0
  260. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.0.0.prompt.md +11 -0
  261. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.1.0.prompt.md +21 -0
  262. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.2.0.prompt.md +25 -0
  263. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.0.0.prompt.md +37 -0
  264. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.1.0.prompt.md +40 -0
  265. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.2.0.prompt.md +36 -0
  266. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v1.0.0.prompt.md +45 -0
  267. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v2.0.0.prompt.md +81 -0
  268. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v3.0.0.prompt.md +80 -0
  269. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate_expert/v1.0.0.prompt.md +34 -0
  270. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_deduplication/v1.0.0.prompt.md +116 -0
  271. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate/v1.0.0.prompt.md +33 -0
  272. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate_override/v1.0.0.prompt.md +16 -0
  273. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.0.0.prompt.md +140 -0
  274. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.1.0.prompt.md +160 -0
  275. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_main/v1.0.0.prompt.md +14 -0
  276. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +19 -0
  277. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/rerank_relevance/v1.1.0.prompt.md +44 -0
  278. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +43 -0
  279. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_content_evaluation/v1.0.0.prompt.md +33 -0
  280. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/feedback_extraction_main_v1.jsonl +10 -0
  281. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/profile_update_main_v1.jsonl +10 -0
  282. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_manager.py +280 -0
  283. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_schema.py +11 -0
  284. package/plugin/vendor/reflexio/reflexio/server/services/README.md +58 -0
  285. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +131 -0
  286. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_constants.py +60 -0
  287. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_service.py +228 -0
  288. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_utils.py +87 -0
  289. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluator.py +372 -0
  290. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/delayed_group_evaluator.py +156 -0
  291. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +336 -0
  292. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +471 -0
  293. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +1668 -0
  294. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/__init__.py +0 -0
  295. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_cron.py +196 -0
  296. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_encryption.py +101 -0
  297. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/client.py +167 -0
  298. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/service.py +281 -0
  299. package/plugin/vendor/reflexio/reflexio/server/services/configurator/base_configurator.py +179 -0
  300. package/plugin/vendor/reflexio/reflexio/server/services/configurator/config_storage.py +62 -0
  301. package/plugin/vendor/reflexio/reflexio/server/services/configurator/configurator.py +87 -0
  302. package/plugin/vendor/reflexio/reflexio/server/services/configurator/local_file_config_storage.py +187 -0
  303. package/plugin/vendor/reflexio/reflexio/server/services/configurator/test_config_storage.py +162 -0
  304. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +112 -0
  305. package/plugin/vendor/reflexio/reflexio/server/services/embedding_text.py +62 -0
  306. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/__init__.py +0 -0
  307. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/distribution.py +33 -0
  308. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/eval_sampler.py +126 -0
  309. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/group_aggregation.py +192 -0
  310. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/hero_state.py +75 -0
  311. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/rule_attribution.py +97 -0
  312. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +515 -0
  313. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/shadow_aggregation.py +90 -0
  314. package/plugin/vendor/reflexio/reflexio/server/services/extraction/__init__.py +0 -0
  315. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +91 -0
  316. package/plugin/vendor/reflexio/reflexio/server/services/extraction/invariants.py +303 -0
  317. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +25 -0
  318. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +358 -0
  319. package/plugin/vendor/reflexio/reflexio/server/services/extraction/plan.py +138 -0
  320. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +217 -0
  321. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +535 -0
  322. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +171 -0
  323. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +779 -0
  324. package/plugin/vendor/reflexio/reflexio/server/services/extraction/tools.py +1125 -0
  325. package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +94 -0
  326. package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +251 -0
  327. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +702 -0
  328. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +835 -0
  329. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +89 -0
  330. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_aggregator.py +1388 -0
  331. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +1045 -0
  332. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_extractor.py +436 -0
  333. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_generation_service.py +808 -0
  334. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_constants.py +28 -0
  335. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_utils.py +362 -0
  336. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/__init__.py +24 -0
  337. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/assistant_webhook.py +246 -0
  338. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/gepa_adapter.py +291 -0
  339. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/judge.py +97 -0
  340. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/models.py +96 -0
  341. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +645 -0
  342. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/rollout.py +35 -0
  343. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scenario_resolver.py +93 -0
  344. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +174 -0
  345. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/__init__.py +26 -0
  346. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_document_expander.py +179 -0
  347. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +297 -0
  348. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +772 -0
  349. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_extractor.py +462 -0
  350. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +737 -0
  351. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service_utils.py +290 -0
  352. package/plugin/vendor/reflexio/reflexio/server/services/reflection/__init__.py +17 -0
  353. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_extractor.py +247 -0
  354. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +803 -0
  355. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service_utils.py +146 -0
  356. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/__init__.py +0 -0
  357. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +80 -0
  358. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
  359. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +756 -0
  360. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/__init__.py +1 -0
  361. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +184 -0
  362. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/outcome.py +81 -0
  363. package/plugin/vendor/reflexio/reflexio/server/services/storage/constants.py +2 -0
  364. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +11 -0
  365. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +154 -0
  366. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +155 -0
  367. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +59 -0
  368. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +1298 -0
  369. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +1945 -0
  370. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +600 -0
  371. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_operations.py +346 -0
  372. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1378 -0
  373. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +747 -0
  374. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +263 -0
  375. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +193 -0
  376. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +166 -0
  377. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_stall_state.py +217 -0
  378. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +153 -0
  379. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +384 -0
  380. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_base.py +71 -0
  381. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +235 -0
  382. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +170 -0
  383. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +677 -0
  384. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +250 -0
  385. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +154 -0
  386. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +130 -0
  387. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +93 -0
  388. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_stall_state.py +76 -0
  389. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +572 -0
  390. package/plugin/vendor/reflexio/reflexio/server/site_var/README.md +77 -0
  391. package/plugin/vendor/reflexio/reflexio/server/site_var/feature_flags.py +116 -0
  392. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_manager.py +263 -0
  393. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/feature_flags.json +13 -0
  394. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/llm_model_setting.json +7 -0
  395. package/plugin/vendor/reflexio/reflexio/server/tracing.py +158 -0
  396. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +113 -0
  397. package/plugin/vendor/reflexio/reflexio/server/uvicorn_logging.py +76 -0
  398. package/plugin/vendor/reflexio/reflexio/test_support/__init__.py +1 -0
  399. package/plugin/vendor/reflexio/reflexio/test_support/llm_fixtures.py +62 -0
  400. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +242 -0
  401. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +129 -0
  402. package/plugin/vendor/reflexio/reflexio/test_support/skip_decorators.py +43 -0
@@ -0,0 +1,1045 @@
1
+ """
2
+ Playbook consolidation service that merges duplicate user playbook entries using LLM
3
+ and hybrid search against existing entries in the database.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ from datetime import UTC, datetime
9
+ from typing import Annotated, Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
12
+
13
+ from reflexio.models.api_schema.retriever_schema import SearchUserPlaybookRequest
14
+ from reflexio.models.api_schema.service_schemas import UserPlaybook
15
+ from reflexio.models.config_schema import (
16
+ EMBEDDING_DIMENSIONS,
17
+ DeduplicationConfig,
18
+ SearchOptions,
19
+ )
20
+ from reflexio.server.api_endpoints.request_context import RequestContext
21
+ from reflexio.server.llm.litellm_client import LiteLLMClient
22
+ from reflexio.server.services.deduplication_utils import (
23
+ BaseDeduplicator,
24
+ format_dedup_timestamp,
25
+ )
26
+ from reflexio.server.tracing import sentry_tags
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # ===============================
32
+ # Playbook-specific Pydantic Output Schemas for LLM
33
+ # ===============================
34
+
35
+
36
+ def _coerce_existing_position(value: object) -> int:
37
+ """Accept either a bare int position or an ``"EXISTING-N"`` label.
38
+
39
+ Wired ONLY to ``UnifyDecision.archive_existing_ids`` — the one field whose
40
+ int contract is genuinely a list **position** (resolved via
41
+ ``existing_by_position`` as ``f"EXISTING-{idx}"`` in ``_apply_unify``).
42
+ ``RejectNewDecision.superseded_by_existing_id`` and
43
+ ``DifferentiateDecision.existing_id`` are DB ``user_playbook_id`` values
44
+ (resolved via ``existing_by_id`` in ``_apply_reject_new`` /
45
+ ``_apply_differentiate``) — coercing ``"EXISTING-N"`` to ``N`` on those
46
+ fields would silently misroute decisions when the position int doesn't
47
+ map to a real DB id.
48
+
49
+ The consolidation prompt labels rows as ``[EXISTING-0]``, ``[EXISTING-1]``
50
+ etc. (see ``_format_playbooks_with_prefix``) and ``_apply_unify``
51
+ reconstructs ``f"EXISTING-{position}"`` from the integer the LLM returns.
52
+ Strong structured-output models (GPT-4o, Claude) honor the ``list[int]``
53
+ schema and return the bare integer ``0``; weaker models (e.g. MiniMax-M3)
54
+ ignore the int constraint and return the literal label ``"EXISTING-0"``
55
+ instead — which then fails pydantic validation and the whole
56
+ consolidation batch dies.
57
+
58
+ Strip the prefix when present so the schema tolerates both shapes
59
+ without changing the int contract downstream consumers rely on. Plain
60
+ numeric strings (``"5"``) are also accepted for symmetry with how
61
+ most JSON-coerced models handle ID-like values. Negative values are
62
+ rejected — list positions are always ``>= 0``.
63
+
64
+ Raises:
65
+ ValueError: when ``value`` is not a non-negative int or a recognized
66
+ position-label / numeric string.
67
+ """
68
+ if isinstance(value, bool):
69
+ # ``bool`` is a subclass of ``int`` in Python; reject explicitly so a
70
+ # stray ``True`` doesn't silently become position 1.
71
+ raise ValueError(f"existing-position must be int, got bool: {value!r}")
72
+ if isinstance(value, int):
73
+ if value < 0:
74
+ raise ValueError(f"existing-position must be >= 0, got {value!r}")
75
+ return value
76
+ if isinstance(value, str):
77
+ stripped = value.strip()
78
+ for prefix in ("EXISTING-", "EXISTING_", "existing-", "existing_"):
79
+ if stripped.startswith(prefix):
80
+ stripped = stripped[len(prefix) :]
81
+ break
82
+ try:
83
+ parsed = int(stripped)
84
+ except ValueError as exc:
85
+ raise ValueError(
86
+ f"existing-position must be int or 'EXISTING-N' label, got {value!r}"
87
+ ) from exc
88
+ if parsed < 0:
89
+ raise ValueError(f"existing-position must be >= 0, got {value!r}")
90
+ return parsed
91
+ raise ValueError(
92
+ f"existing-position must be int or 'EXISTING-N' label, got {type(value).__name__}: {value!r}"
93
+ )
94
+
95
+
96
+ class UnifyDecision(BaseModel):
97
+ """Collapse NEW (+ 0..N EXISTING) into one row with LLM-supplied content.
98
+
99
+ Subsumes the legacy ``duplicate`` and ``prefer_new`` kinds AND the
100
+ ``compose`` case: the LLM picks the final ``content`` / ``trigger`` /
101
+ ``rationale`` and lists which EXISTING ids (if any) are absorbed. An empty
102
+ ``archive_existing_ids`` is allowed and behaves as an insert-without-archive
103
+ distinguished from ``independent`` by the prompt's intent contract.
104
+
105
+ A unified skill MAY hold mixed-polarity rules (do-rules and avoid-rules for
106
+ different sub-aspects of the one task). There is no mechanical polarity
107
+ field or apply-time polarity check: the no-self-contradiction judgment
108
+ (do not merge rules that contradict on the same situation) is made by the
109
+ LLM in the consolidation prompt, not by the apply path.
110
+ """
111
+
112
+ kind: Literal["unify"] = "unify"
113
+ new_id: str
114
+ archive_existing_ids: list[int] = Field(default_factory=list)
115
+ content: str
116
+ trigger: str
117
+ rationale: str
118
+ reason: str = ""
119
+
120
+ @field_validator("archive_existing_ids", mode="before")
121
+ @classmethod
122
+ def _coerce_archive_ids(cls, value: object) -> object:
123
+ if value is None:
124
+ return []
125
+ if isinstance(value, list):
126
+ return [_coerce_existing_position(item) for item in value]
127
+ return value
128
+
129
+ model_config = ConfigDict(json_schema_extra={"additionalProperties": False})
130
+
131
+
132
+ class RejectNewDecision(BaseModel):
133
+ """The new candidate is redundant; an existing row supersedes it (storage no-op).
134
+
135
+ ``superseded_by_existing_id`` is a DB ``user_playbook_id`` (resolved
136
+ against ``existing_by_id`` in ``_apply_reject_new``), NOT a list
137
+ position — no ``"EXISTING-N"`` coercion is wired here. If the LLM
138
+ returns ``"EXISTING-N"`` for this field, pydantic rejects it loudly
139
+ rather than silently misrouting the decision (see
140
+ ``_coerce_existing_position`` docstring).
141
+ """
142
+
143
+ kind: Literal["reject_new"] = "reject_new"
144
+ new_id: str
145
+ superseded_by_existing_id: int
146
+ reason: str = ""
147
+
148
+ model_config = ConfigDict(json_schema_extra={"additionalProperties": False})
149
+
150
+
151
+ class DifferentiateDecision(BaseModel):
152
+ """Both rules valid in distinct contexts: refine both triggers.
153
+
154
+ ``existing_id`` is a DB ``user_playbook_id`` (resolved against
155
+ ``existing_by_id`` in ``_apply_differentiate``), NOT a list position —
156
+ no ``"EXISTING-N"`` coercion is wired here. See
157
+ ``_coerce_existing_position`` docstring.
158
+ """
159
+
160
+ kind: Literal["differentiate"] = "differentiate"
161
+ new_id: str
162
+ existing_id: int
163
+ refined_new_trigger: str
164
+ refined_existing_trigger: str
165
+ reason: str = ""
166
+
167
+ @field_validator("refined_new_trigger", "refined_existing_trigger")
168
+ @classmethod
169
+ def _non_empty(cls, v: str) -> str:
170
+ if not v or not v.strip():
171
+ raise ValueError("differentiate requires non-empty refined triggers")
172
+ return v
173
+
174
+ model_config = ConfigDict(json_schema_extra={"additionalProperties": False})
175
+
176
+
177
+ class IndependentDecision(BaseModel):
178
+ """Unrelated to any existing row: insert new as-is, no archive."""
179
+
180
+ kind: Literal["independent"] = "independent"
181
+ new_id: str
182
+ reason: str = ""
183
+
184
+ model_config = ConfigDict(json_schema_extra={"additionalProperties": False})
185
+
186
+
187
+ ConsolidationDecision = Annotated[
188
+ UnifyDecision | RejectNewDecision | DifferentiateDecision | IndependentDecision,
189
+ Field(discriminator="kind"),
190
+ ]
191
+
192
+
193
+ class PlaybookConsolidationOutput(BaseModel):
194
+ """Output schema for playbook consolidation as a 4-kind discriminated union.
195
+
196
+ Each decision is one of ``UnifyDecision``, ``RejectNewDecision``,
197
+ ``DifferentiateDecision``, or ``IndependentDecision``; the ``kind`` literal
198
+ selects the concrete shape.
199
+ """
200
+
201
+ decisions: list[ConsolidationDecision] = Field(default_factory=list)
202
+
203
+ model_config = ConfigDict(json_schema_extra={"additionalProperties": False})
204
+
205
+
206
+ class PlaybookConsolidationResult(BaseModel):
207
+ """Per-kind counters tracked over one consolidation batch.
208
+
209
+ Bumped once per successfully-applied decision; ``failed_count`` is bumped
210
+ when a single decision's apply path raises, allowing the rest of the batch
211
+ to proceed unaffected.
212
+ """
213
+
214
+ unify_count: int = 0
215
+ reject_new_count: int = 0
216
+ differentiate_count: int = 0
217
+ independent_count: int = 0
218
+ failed_count: int = 0
219
+
220
+
221
+ _COUNTER_BY_KIND: dict[str, str] = {
222
+ "unify": "unify_count",
223
+ "reject_new": "reject_new_count",
224
+ "differentiate": "differentiate_count",
225
+ "independent": "independent_count",
226
+ }
227
+
228
+
229
+ class PlaybookConsolidator(BaseDeduplicator):
230
+ """
231
+ Consolidates new user playbook entries against each other and against existing entries
232
+ in the database using hybrid search (vector + FTS) and LLM-based merging.
233
+ """
234
+
235
+ DEDUPLICATION_PROMPT_ID = "playbook_consolidation"
236
+
237
+ def __init__(
238
+ self,
239
+ request_context: RequestContext,
240
+ llm_client: LiteLLMClient,
241
+ dedup_config: DeduplicationConfig | None = None,
242
+ ):
243
+ """
244
+ Initialize the playbook consolidator.
245
+
246
+ Args:
247
+ request_context: Request context with storage and prompt manager
248
+ llm_client: Unified LLM client for LLM calls
249
+ dedup_config: Optional consolidation search parameters (threshold, top_k)
250
+ """
251
+ super().__init__(request_context, llm_client)
252
+ self._dedup_config = dedup_config or DeduplicationConfig()
253
+
254
+ def _get_prompt_id(self) -> str:
255
+ """Get the prompt ID for playbook consolidation."""
256
+ return self.DEDUPLICATION_PROMPT_ID
257
+
258
+ def _get_item_count_key(self) -> str:
259
+ """Get the key name for item count in prompt variables."""
260
+ return "new_playbook_count"
261
+
262
+ def _get_items_key(self) -> str:
263
+ """Get the key name for items in prompt variables."""
264
+ return "new_playbooks"
265
+
266
+ def _get_output_schema_class(self) -> type[BaseModel]:
267
+ """Return the discriminated-union output schema for consolidation."""
268
+ return PlaybookConsolidationOutput
269
+
270
+ def _format_items_for_prompt(self, playbooks: list[UserPlaybook]) -> str:
271
+ """
272
+ Format user playbook entries list for LLM prompt with NEW-N prefix.
273
+
274
+ Args:
275
+ playbooks: List of user playbook entries
276
+
277
+ Returns:
278
+ Formatted string representation
279
+ """
280
+ return self._format_playbooks_with_prefix(playbooks, "NEW")
281
+
282
+ def _format_playbooks_with_prefix(
283
+ self, playbooks: list[UserPlaybook], prefix: str
284
+ ) -> str:
285
+ """
286
+ Format user playbook entries with a given prefix (NEW or EXISTING).
287
+
288
+ Args:
289
+ playbooks: List of user playbook entries to format
290
+ prefix: Prefix string for indices
291
+
292
+ Returns:
293
+ Formatted string
294
+ """
295
+ if not playbooks:
296
+ return "(None)"
297
+ lines = []
298
+ for idx, playbook in enumerate(playbooks):
299
+ playbook_name = playbook.playbook_name or "unknown"
300
+ source = playbook.source or "unknown"
301
+ created_date = format_dedup_timestamp(playbook.created_at)
302
+ # ``Trigger`` and ``Rationale`` are included alongside ``Content``
303
+ # so the model can actually compare the fields it is asked to
304
+ # refine (``differentiate``, same-trigger contradictions, trigger
305
+ # refinements). Without ``trigger`` exposed the decisions become
306
+ # guesswork.
307
+ lines.append(
308
+ f'[{prefix}-{idx}] Content: "{playbook.content}"'
309
+ f' | Trigger: "{playbook.trigger or ""}"'
310
+ f' | Rationale: "{playbook.rationale or ""}"'
311
+ f" | Name: {playbook_name}"
312
+ f" | Source: {source} | Last Modified: {created_date}"
313
+ )
314
+ return "\n".join(lines)
315
+
316
+ def _retrieve_existing_playbooks(
317
+ self,
318
+ new_playbooks: list[UserPlaybook],
319
+ user_id: str | None = None,
320
+ agent_version: str | None = None,
321
+ ) -> list[UserPlaybook]:
322
+ """
323
+ Retrieve existing user playbook entries from the database using hybrid search.
324
+
325
+ For each new entry, uses its trigger field as the query with
326
+ pre-computed embeddings for vector search.
327
+
328
+ Args:
329
+ new_playbooks: List of new entries to search against
330
+ user_id: Optional user ID to scope the search
331
+ agent_version: Optional agent version to scope the search
332
+
333
+ Returns:
334
+ Deduplicated list of existing UserPlaybook objects from the database
335
+ """
336
+ storage = self.request_context.storage
337
+
338
+ # Collect trigger strings for embedding
339
+ query_texts = []
340
+ for playbook in new_playbooks:
341
+ trigger = playbook.trigger or playbook.content
342
+ if trigger and trigger.strip():
343
+ query_texts.append(trigger.strip())
344
+
345
+ if not query_texts:
346
+ return []
347
+
348
+ # Batch-generate embeddings
349
+ try:
350
+ embeddings = self.client.get_embeddings(
351
+ query_texts, dimensions=EMBEDDING_DIMENSIONS
352
+ )
353
+ except Exception as e:
354
+ logger.warning("Failed to generate embeddings for dedup search: %s", e)
355
+ # Fall back to text-only search
356
+ embeddings = [None] * len(query_texts)
357
+
358
+ # Search for each new entry
359
+ seen_ids: set[int] = set()
360
+ existing_playbooks: list[UserPlaybook] = []
361
+
362
+ for i, query_text in enumerate(query_texts):
363
+ try:
364
+ search_request = SearchUserPlaybookRequest(
365
+ query=query_text,
366
+ user_id=user_id,
367
+ agent_version=agent_version,
368
+ status_filter=[None], # Only current entries
369
+ threshold=self._dedup_config.search_threshold,
370
+ top_k=self._dedup_config.search_top_k,
371
+ )
372
+ search_options = SearchOptions(query_embedding=embeddings[i])
373
+ results = storage.search_user_playbooks( # type: ignore[reportOptionalMemberAccess]
374
+ search_request, search_options
375
+ )
376
+ for fb in results:
377
+ if fb.user_playbook_id and fb.user_playbook_id not in seen_ids:
378
+ seen_ids.add(fb.user_playbook_id)
379
+ existing_playbooks.append(fb)
380
+ except Exception as e: # noqa: PERF203
381
+ logger.warning(
382
+ "Failed to search existing entries for query %d: %s", i, e
383
+ )
384
+
385
+ logger.info(
386
+ "Retrieved %d unique existing user playbook entries for deduplication "
387
+ "(scoped to user_id=%r agent_version=%r)",
388
+ len(existing_playbooks),
389
+ user_id,
390
+ agent_version,
391
+ )
392
+ return existing_playbooks
393
+
394
+ def _format_new_and_existing_for_prompt(
395
+ self,
396
+ new_playbooks: list[UserPlaybook],
397
+ existing_playbooks: list[UserPlaybook],
398
+ ) -> tuple[str, str]:
399
+ """
400
+ Format new and existing entries for the deduplication prompt.
401
+
402
+ Args:
403
+ new_playbooks: New entries to deduplicate
404
+ existing_playbooks: Existing entries from the database
405
+
406
+ Returns:
407
+ Tuple of (new_playbooks_text, existing_playbooks_text)
408
+ """
409
+ new_text = self._format_playbooks_with_prefix(new_playbooks, "NEW")
410
+ existing_text = self._format_playbooks_with_prefix(
411
+ existing_playbooks, "EXISTING"
412
+ )
413
+ return new_text, existing_text
414
+
415
+ def _consolidation_decisions(
416
+ self,
417
+ new_playbooks: list[UserPlaybook],
418
+ existing_playbooks: list[UserPlaybook],
419
+ ) -> PlaybookConsolidationOutput:
420
+ """Render the consolidation prompt for NEW + EXISTING playbooks and run the
421
+ LLM decision step (prompt render + LLM call + parse only — no hybrid search,
422
+ no apply). Returns the parsed decisions, or an empty ``PlaybookConsolidationOutput``
423
+ if the LLM returned the wrong shape.
424
+
425
+ EXISTING-id <-> prompt-label mapping (for downstream eval providers that
426
+ must map a returned decision back to a source playbook):
427
+ * Both ``new_playbooks`` and ``existing_playbooks`` are rendered by
428
+ ``_format_playbooks_with_prefix``, which labels rows by **list
429
+ position**, not by ``user_playbook_id``: NEW rows become
430
+ ``[NEW-0]``, ``[NEW-1]``, ... and EXISTING rows become
431
+ ``[EXISTING-0]``, ``[EXISTING-1]``, ... in the order passed in.
432
+ * Consequently the integer ids returned in decisions are interpreted
433
+ against EITHER positions OR ``user_playbook_id`` depending on the
434
+ decision kind, in the apply path (``_build_deduplicated_results``):
435
+ - ``UnifyDecision.archive_existing_ids`` -> **list positions**
436
+ (resolved as ``EXISTING-{idx}``).
437
+ - ``DifferentiateDecision.existing_id`` and
438
+ ``RejectNewDecision.superseded_by_existing_id`` ->
439
+ ``user_playbook_id`` (resolved against ``existing_by_id``).
440
+ - All decisions' ``new_id`` is the ``NEW-{idx}`` position label of
441
+ the candidate.
442
+ * A provider that controls the inputs should therefore choose its
443
+ ``existing_playbooks`` ordering and ``user_playbook_id`` values so it
444
+ can map a returned ``existing_id`` (position for unify;
445
+ ``user_playbook_id`` for differentiate/reject_new) back to its case.
446
+
447
+ Args:
448
+ new_playbooks: Flattened list of new (candidate) entries.
449
+ existing_playbooks: Existing entries to consolidate against.
450
+
451
+ Returns:
452
+ Parsed ``PlaybookConsolidationOutput``; an empty output (no
453
+ decisions) if the LLM returned an unexpected response shape.
454
+ """
455
+ # Format for prompt
456
+ new_text, existing_text = self._format_new_and_existing_for_prompt(
457
+ new_playbooks, existing_playbooks
458
+ )
459
+
460
+ # Build and call LLM
461
+ prompt = self.request_context.prompt_manager.render_prompt(
462
+ self._get_prompt_id(),
463
+ {
464
+ "new_playbook_count": len(new_playbooks),
465
+ "new_playbooks": new_text,
466
+ "existing_playbooks": existing_text,
467
+ },
468
+ )
469
+
470
+ output_schema_class = self._get_output_schema_class()
471
+
472
+ from reflexio.server.services.service_utils import (
473
+ log_llm_messages,
474
+ log_model_response,
475
+ )
476
+
477
+ log_llm_messages(
478
+ logger,
479
+ "Playbook consolidation",
480
+ [{"role": "user", "content": prompt}],
481
+ )
482
+
483
+ response = self.client.generate_chat_response(
484
+ messages=[{"role": "user", "content": prompt}],
485
+ model=self.model_name,
486
+ response_format=output_schema_class,
487
+ )
488
+
489
+ log_model_response(logger, "Consolidation response", response)
490
+
491
+ if not isinstance(response, PlaybookConsolidationOutput):
492
+ logger.warning(
493
+ "Unexpected response type from consolidation LLM: %s",
494
+ type(response),
495
+ )
496
+ return PlaybookConsolidationOutput()
497
+
498
+ return response
499
+
500
+ def deduplicate(
501
+ self,
502
+ results: list[list[UserPlaybook]],
503
+ request_id: str,
504
+ agent_version: str,
505
+ user_id: str | None = None,
506
+ ) -> tuple[list[UserPlaybook], list[int]]:
507
+ """
508
+ Consolidate user playbook entries across extractors and against existing entries in DB.
509
+
510
+ Args:
511
+ results: List of entry lists from extractors (each extractor returns list[UserPlaybook])
512
+ request_id: Request ID for context
513
+ agent_version: Agent version for context
514
+ user_id: Optional user ID to scope the existing entry search
515
+
516
+ Returns:
517
+ Tuple of (consolidated entries, list of existing entry IDs to delete after save)
518
+ """
519
+ # Check if mock mode is enabled
520
+ if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
521
+ logger.info("Mock mode: skipping consolidation")
522
+ all_playbooks: list[UserPlaybook] = []
523
+ for result in results:
524
+ if isinstance(result, list):
525
+ all_playbooks.extend(result)
526
+ return all_playbooks, []
527
+
528
+ # Flatten all new entries
529
+ new_playbooks: list[UserPlaybook] = []
530
+ for result in results:
531
+ if isinstance(result, list):
532
+ new_playbooks.extend(result)
533
+
534
+ if not new_playbooks:
535
+ return [], []
536
+
537
+ # Retrieve existing entries via hybrid search
538
+ existing_playbooks = self._retrieve_existing_playbooks(
539
+ new_playbooks, user_id=user_id, agent_version=agent_version
540
+ )
541
+
542
+ # Run the LLM decision step (prompt render + LLM call + parse only).
543
+ try:
544
+ dedup_output = self._consolidation_decisions(
545
+ new_playbooks, existing_playbooks
546
+ )
547
+ except Exception as e:
548
+ with sentry_tags(
549
+ subsystem="playbook_consolidator",
550
+ op="identify_duplicates",
551
+ org_id=self.request_context.org_id,
552
+ user_id=user_id,
553
+ request_id=request_id,
554
+ agent_version=agent_version,
555
+ error_type=type(e).__name__,
556
+ ):
557
+ logger.exception("Failed to identify duplicates")
558
+ return new_playbooks, []
559
+
560
+ if not dedup_output.decisions:
561
+ logger.info(
562
+ "No consolidation decisions returned for request %s", request_id
563
+ )
564
+ return new_playbooks, []
565
+
566
+ logger.info(
567
+ "Received %d consolidation decisions for request %s",
568
+ len(dedup_output.decisions),
569
+ request_id,
570
+ )
571
+
572
+ # Build consolidated result via the discriminated-union apply path
573
+ return self._build_deduplicated_results(
574
+ new_playbooks=new_playbooks,
575
+ existing_playbooks=existing_playbooks,
576
+ dedup_output=dedup_output,
577
+ request_id=request_id,
578
+ agent_version=agent_version,
579
+ )
580
+
581
+ # ===============================
582
+ # Apply path: discriminated-union decisions -> (new rows, archive ids)
583
+ # ===============================
584
+
585
+ def _build_deduplicated_results(
586
+ self,
587
+ new_playbooks: list[UserPlaybook],
588
+ existing_playbooks: list[UserPlaybook],
589
+ dedup_output: PlaybookConsolidationOutput,
590
+ request_id: str,
591
+ agent_version: str, # noqa: ARG002
592
+ ) -> tuple[list[UserPlaybook], list[int]]:
593
+ """
594
+ Build the deduplicated entry list from LLM decisions.
595
+
596
+ Dispatches each ``ConsolidationDecision`` to its kind-specific apply
597
+ method, accumulates resulting rows + archive ids, and adds any NEW
598
+ playbooks the LLM didn't reference as a safety fallback so a
599
+ misbehaving LLM cannot silently drop extracted playbooks.
600
+
601
+ Args:
602
+ new_playbooks: Flattened list of new (candidate) entries.
603
+ existing_playbooks: List of existing entries from the DB.
604
+ dedup_output: LLM decisions output (discriminated union).
605
+ request_id: Request ID stamped onto newly-built rows.
606
+ agent_version: Agent version (currently unused, kept for symmetry).
607
+
608
+ Returns:
609
+ Tuple of (entries ready to save, existing entry IDs to delete).
610
+ """
611
+ candidates_by_id = {
612
+ f"NEW-{idx}": playbook for idx, playbook in enumerate(new_playbooks)
613
+ }
614
+ existing_by_id = {
615
+ playbook.user_playbook_id: playbook
616
+ for playbook in existing_playbooks
617
+ if playbook.user_playbook_id
618
+ }
619
+ existing_by_position = {
620
+ f"EXISTING-{idx}": playbook
621
+ for idx, playbook in enumerate(existing_playbooks)
622
+ }
623
+
624
+ result_counters = PlaybookConsolidationResult()
625
+ archive_ids: list[int] = []
626
+ seen_archive: set[int] = set()
627
+ new_rows: list[UserPlaybook] = []
628
+ handled_new_ids: set[str] = set()
629
+
630
+ for decision in dedup_output.decisions:
631
+ try:
632
+ rows, marked_new_ids = self._apply_one(
633
+ decision=decision,
634
+ candidates_by_id=candidates_by_id,
635
+ existing_by_id=existing_by_id,
636
+ existing_by_position=existing_by_position,
637
+ archive_ids=archive_ids,
638
+ seen_archive=seen_archive,
639
+ request_id=request_id,
640
+ )
641
+ except Exception as exc: # noqa: BLE001 — per-decision isolation
642
+ result_counters.failed_count += 1
643
+ new_id_str = getattr(decision, "new_id", "unknown")
644
+ existing_id_str = getattr(decision, "existing_id", "unknown")
645
+ with sentry_tags(
646
+ subsystem="playbook_consolidator",
647
+ op="apply_decision",
648
+ org_id=self.request_context.org_id,
649
+ request_id=request_id,
650
+ kind=decision.kind,
651
+ new_id=new_id_str,
652
+ existing_id=existing_id_str,
653
+ error_type=type(exc).__name__,
654
+ ):
655
+ logger.exception(
656
+ "event=consolidation_apply_failed kind=%s new_id=%s existing_id=%s",
657
+ decision.kind,
658
+ new_id_str,
659
+ existing_id_str,
660
+ )
661
+ continue
662
+ new_rows.extend(rows)
663
+ handled_new_ids.update(marked_new_ids)
664
+ self._bump_counter(result_counters, decision.kind)
665
+ self._log_decision(decision, candidates_by_id, existing_by_id)
666
+
667
+ # Safety fallback: add any NEW entries the LLM did not reference, so a
668
+ # misbehaving model cannot silently drop extracted playbooks.
669
+ for new_id, candidate in candidates_by_id.items():
670
+ if new_id not in handled_new_ids:
671
+ logger.warning(
672
+ "event=consolidation_unhandled_new id=%s — adding as-is",
673
+ new_id,
674
+ )
675
+ new_rows.append(candidate)
676
+
677
+ logger.info(
678
+ "event=playbook_consolidation_done unify=%d reject_new=%d "
679
+ "differentiate=%d independent=%d failed=%d",
680
+ result_counters.unify_count,
681
+ result_counters.reject_new_count,
682
+ result_counters.differentiate_count,
683
+ result_counters.independent_count,
684
+ result_counters.failed_count,
685
+ )
686
+
687
+ return new_rows, archive_ids
688
+
689
+ def _apply_one(
690
+ self,
691
+ *,
692
+ decision: ConsolidationDecision,
693
+ candidates_by_id: dict[str, UserPlaybook],
694
+ existing_by_id: dict[int, UserPlaybook],
695
+ existing_by_position: dict[str, UserPlaybook],
696
+ archive_ids: list[int],
697
+ seen_archive: set[int],
698
+ request_id: str,
699
+ ) -> tuple[list[UserPlaybook], list[str]]:
700
+ """Dispatch a single decision to its kind-specific apply method.
701
+
702
+ Args:
703
+ decision: The decision to apply (one of four kinds).
704
+ candidates_by_id: Mapping ``"NEW-N"`` -> candidate ``UserPlaybook``.
705
+ existing_by_id: Mapping ``user_playbook_id`` -> existing playbook.
706
+ existing_by_position: Mapping ``"EXISTING-M"`` -> existing playbook
707
+ (used by ``unify`` to resolve the EXISTING-M ids it archives in
708
+ ``archive_existing_ids``).
709
+ archive_ids: Accumulator list mutated with ids to archive/delete.
710
+ seen_archive: Accumulator set guarding ``archive_ids`` against
711
+ duplicate ids.
712
+ request_id: Request ID stamped onto newly-built rows.
713
+
714
+ Returns:
715
+ Tuple of ``(rows_to_insert, handled_new_ids)`` where the second
716
+ element is the set of ``"NEW-N"`` candidate ids consumed by this
717
+ decision (used to suppress the safety fallback).
718
+ """
719
+ if isinstance(decision, UnifyDecision):
720
+ return self._apply_unify(
721
+ decision,
722
+ candidates_by_id=candidates_by_id,
723
+ existing_by_position=existing_by_position,
724
+ archive_ids=archive_ids,
725
+ seen_archive=seen_archive,
726
+ request_id=request_id,
727
+ )
728
+ if isinstance(decision, RejectNewDecision):
729
+ return self._apply_reject_new(
730
+ decision,
731
+ existing_by_id=existing_by_id,
732
+ )
733
+ if isinstance(decision, DifferentiateDecision):
734
+ return self._apply_differentiate(
735
+ decision,
736
+ candidates_by_id=candidates_by_id,
737
+ existing_by_id=existing_by_id,
738
+ archive_ids=archive_ids,
739
+ seen_archive=seen_archive,
740
+ request_id=request_id,
741
+ )
742
+ if isinstance(decision, IndependentDecision):
743
+ return self._apply_independent(decision, candidates_by_id=candidates_by_id)
744
+ raise ValueError(f"unknown decision kind: {decision}")
745
+
746
+ def _apply_unify(
747
+ self,
748
+ decision: UnifyDecision,
749
+ *,
750
+ candidates_by_id: dict[str, UserPlaybook],
751
+ existing_by_position: dict[str, UserPlaybook],
752
+ archive_ids: list[int],
753
+ seen_archive: set[int],
754
+ request_id: str,
755
+ ) -> tuple[list[UserPlaybook], list[str]]:
756
+ """Collapse / compose NEW (+ 0..N EXISTING) into one row.
757
+
758
+ Looks up each ``archive_existing_ids`` entry by position
759
+ (``EXISTING-{idx}``) and archives it. The unified skill may carry
760
+ mixed-polarity rules (do-rules and avoid-rules for different
761
+ sub-aspects); there is **no** mechanical same-polarity check here. The
762
+ no-self-contradiction judgment (do not merge rules that contradict on
763
+ the same situation) is made by the LLM in the consolidation prompt, not
764
+ the apply path. The new row is built by copying identity/metadata from
765
+ the NEW candidate and overlaying ``content``, ``trigger``, and
766
+ ``rationale`` from the decision.
767
+
768
+ Args:
769
+ decision: The ``UnifyDecision`` to apply.
770
+ candidates_by_id: Mapping ``"NEW-N"`` -> candidate playbook.
771
+ existing_by_position: Mapping ``"EXISTING-M"`` -> existing playbook.
772
+ archive_ids: Accumulator mutated with EXISTING ids to archive.
773
+ seen_archive: Dedup set for ``archive_ids``.
774
+ request_id: Request ID stamped on the unified row.
775
+
776
+ Returns:
777
+ Tuple of ([unified_row], [consumed NEW-N ids]).
778
+
779
+ Raises:
780
+ KeyError: If ``decision.new_id`` does not resolve to a known
781
+ candidate.
782
+ ValueError: If an ``archive_existing_ids`` entry has no matching
783
+ ``EXISTING-{idx}`` row in the position map.
784
+ """
785
+ candidate = candidates_by_id.get(decision.new_id)
786
+ if candidate is None:
787
+ raise KeyError(f"unify references unknown NEW id: {decision.new_id}")
788
+
789
+ existing_members: list[UserPlaybook] = []
790
+ for existing_position in decision.archive_existing_ids:
791
+ existing = existing_by_position.get(f"EXISTING-{existing_position}")
792
+ if existing is None:
793
+ raise ValueError(
794
+ f"unify references unknown existing_id={existing_position}"
795
+ )
796
+ existing_members.append(existing)
797
+
798
+ for existing in existing_members:
799
+ pid = existing.user_playbook_id
800
+ if pid and pid not in seen_archive:
801
+ seen_archive.add(pid)
802
+ archive_ids.append(pid)
803
+
804
+ budget = self._dedup_config.max_unified_content_chars
805
+ content_len = len(decision.content)
806
+ if content_len > budget:
807
+ # Soft backstop only: the prompt instructs the model to prefer
808
+ # `differentiate` over an over-long unify. We log a signal rather
809
+ # than hard-fail or downgrade so we don't destabilize the 4-kind
810
+ # apply logic; the merge still proceeds.
811
+ logger.warning(
812
+ "event=consolidation_over_budget new_id=%s len=%d budget=%d",
813
+ decision.new_id,
814
+ content_len,
815
+ budget,
816
+ )
817
+
818
+ combined_source_ids = self._merge_source_ids([candidate, *existing_members])
819
+ unified_row = UserPlaybook(
820
+ user_playbook_id=0,
821
+ user_id=candidate.user_id,
822
+ agent_version=candidate.agent_version,
823
+ request_id=request_id,
824
+ playbook_name=candidate.playbook_name,
825
+ created_at=int(datetime.now(UTC).timestamp()),
826
+ content=decision.content,
827
+ trigger=decision.trigger,
828
+ rationale=decision.rationale,
829
+ status=candidate.status,
830
+ source=candidate.source,
831
+ source_interaction_ids=combined_source_ids,
832
+ )
833
+ return [unified_row], [decision.new_id]
834
+
835
+ def _apply_reject_new(
836
+ self,
837
+ decision: RejectNewDecision,
838
+ *,
839
+ existing_by_id: dict[int, UserPlaybook],
840
+ ) -> tuple[list[UserPlaybook], list[str]]:
841
+ """No-op apply: the existing row wins and the new candidate is dropped.
842
+
843
+ If ``decision.superseded_by_existing_id`` does not resolve to a known
844
+ existing row, the decision is treated as malformed: we log a warning
845
+ and return ``([], [])`` so the safety fallback re-inserts the candidate
846
+ rather than silently dropping extracted data.
847
+
848
+ Args:
849
+ decision: The ``RejectNewDecision`` to apply.
850
+ existing_by_id: Mapping ``user_playbook_id`` -> existing playbook,
851
+ used to validate ``decision.superseded_by_existing_id``.
852
+
853
+ Returns:
854
+ Tuple of ([], [consumed NEW-N id]) when the existing id resolves,
855
+ or ``([], [])`` when the existing id is unknown.
856
+ """
857
+ if decision.superseded_by_existing_id not in existing_by_id:
858
+ logger.warning(
859
+ "event=consolidation_reject_new_invalid new_id=%s existing_id=%d",
860
+ decision.new_id,
861
+ decision.superseded_by_existing_id,
862
+ )
863
+ return [], []
864
+ logger.info(
865
+ "event=consolidation_reject_new new_id=%s existing_id=%d",
866
+ decision.new_id,
867
+ decision.superseded_by_existing_id,
868
+ )
869
+ return [], [decision.new_id]
870
+
871
+ def _apply_differentiate(
872
+ self,
873
+ decision: DifferentiateDecision,
874
+ *,
875
+ candidates_by_id: dict[str, UserPlaybook],
876
+ existing_by_id: dict[int, UserPlaybook],
877
+ archive_ids: list[int],
878
+ seen_archive: set[int],
879
+ request_id: str,
880
+ ) -> tuple[list[UserPlaybook], list[str]]:
881
+ """Archive the existing row and emit two refined rows in its place.
882
+
883
+ Builds one ``UserPlaybook`` from the candidate's content/polarity with
884
+ ``refined_new_trigger``, and a second from the existing row's
885
+ content/polarity with ``refined_existing_trigger``. Polarity is
886
+ threaded through unchanged for each side.
887
+
888
+ Args:
889
+ decision: The ``DifferentiateDecision`` to apply.
890
+ candidates_by_id: Mapping ``"NEW-N"`` -> candidate playbook.
891
+ existing_by_id: Mapping ``user_playbook_id`` -> existing playbook.
892
+ archive_ids: Accumulator mutated with the existing id to archive.
893
+ seen_archive: Dedup set for ``archive_ids``.
894
+ request_id: Request ID stamped on both new rows.
895
+
896
+ Returns:
897
+ Tuple of ([refined_new_row, refined_existing_row], [NEW-N id]).
898
+ """
899
+ candidate = candidates_by_id.get(decision.new_id)
900
+ if candidate is None:
901
+ raise KeyError(
902
+ f"differentiate references unknown NEW id: {decision.new_id}"
903
+ )
904
+ existing = existing_by_id.get(decision.existing_id)
905
+ if existing is None:
906
+ raise KeyError(
907
+ f"differentiate references unknown EXISTING id: {decision.existing_id}"
908
+ )
909
+
910
+ if decision.existing_id not in seen_archive:
911
+ seen_archive.add(decision.existing_id)
912
+ archive_ids.append(decision.existing_id)
913
+
914
+ now_ts = int(datetime.now(UTC).timestamp())
915
+ refined_candidate = candidate.model_copy(
916
+ update={
917
+ "user_playbook_id": 0,
918
+ "request_id": request_id,
919
+ "trigger": decision.refined_new_trigger,
920
+ "created_at": now_ts,
921
+ }
922
+ )
923
+ refined_existing = existing.model_copy(
924
+ update={
925
+ "user_playbook_id": 0,
926
+ "request_id": request_id,
927
+ "trigger": decision.refined_existing_trigger,
928
+ "created_at": now_ts,
929
+ "source_interaction_ids": list(existing.source_interaction_ids),
930
+ }
931
+ )
932
+ return [refined_candidate, refined_existing], [decision.new_id]
933
+
934
+ def _apply_independent(
935
+ self,
936
+ decision: IndependentDecision,
937
+ *,
938
+ candidates_by_id: dict[str, UserPlaybook],
939
+ ) -> tuple[list[UserPlaybook], list[str]]:
940
+ """Insert the new candidate unchanged; no archive.
941
+
942
+ Args:
943
+ decision: The ``IndependentDecision`` to apply.
944
+ candidates_by_id: Mapping ``"NEW-N"`` -> candidate playbook.
945
+
946
+ Returns:
947
+ Tuple of ([candidate row], [consumed NEW-N id]).
948
+ """
949
+ candidate = candidates_by_id.get(decision.new_id)
950
+ if candidate is None:
951
+ raise KeyError(f"independent references unknown NEW id: {decision.new_id}")
952
+ return [candidate], [decision.new_id]
953
+
954
+ @staticmethod
955
+ def _merge_source_ids(playbooks: list[UserPlaybook]) -> list[int]:
956
+ """Combine ``source_interaction_ids`` across playbooks, preserving order.
957
+
958
+ Args:
959
+ playbooks: The playbooks whose source ids should be combined.
960
+
961
+ Returns:
962
+ Order-preserving deduplicated list of source interaction ids.
963
+ """
964
+ seen: set[int] = set()
965
+ combined: list[int] = []
966
+ for playbook in playbooks:
967
+ for sid in playbook.source_interaction_ids:
968
+ if sid not in seen:
969
+ seen.add(sid)
970
+ combined.append(sid)
971
+ return combined
972
+
973
+ @staticmethod
974
+ def _bump_counter(result: PlaybookConsolidationResult, kind: str) -> None:
975
+ """Increment the per-kind counter on ``result`` for a successful apply.
976
+
977
+ Args:
978
+ result: The result counters object to mutate.
979
+ kind: One of ``unify``, ``reject_new``, ``differentiate``, or
980
+ ``independent``.
981
+ """
982
+ field = _COUNTER_BY_KIND[kind]
983
+ setattr(result, field, getattr(result, field) + 1)
984
+
985
+ @staticmethod
986
+ def _log_decision(
987
+ decision: ConsolidationDecision,
988
+ candidates_by_id: dict[str, UserPlaybook],
989
+ existing_by_id: dict[int, UserPlaybook],
990
+ ) -> None:
991
+ """Emit a structured per-decision log line for probe ingest.
992
+
993
+ Emits ``playbook_consolidation.decision`` with the 4-kind name,
994
+ new/existing ids, and trigger_match. Polarity is intentionally NOT
995
+ derived or logged: under Option B a skill may hold mixed-polarity
996
+ rules, so a single whole-content polarity label is no longer
997
+ meaningful. The no-self-contradiction judgment lives in the LLM.
998
+
999
+ Args:
1000
+ decision: The applied consolidation decision.
1001
+ candidates_by_id: Mapping ``"NEW-N"`` -> candidate playbook.
1002
+ existing_by_id: Mapping ``user_playbook_id`` -> existing playbook.
1003
+ """
1004
+ kind = decision.kind
1005
+ new_id: str = getattr(decision, "new_id", "")
1006
+ new_pb = candidates_by_id.get(new_id)
1007
+
1008
+ # UnifyDecision archives by position (EXISTING-{idx}) rather than a
1009
+ # single existing_id; log a synthetic "multi" so the probe parser sees
1010
+ # one line per decision regardless of arity.
1011
+ if isinstance(decision, UnifyDecision):
1012
+ existing_id_label: str = (
1013
+ "multi" if decision.archive_existing_ids else "none"
1014
+ )
1015
+ logger.info(
1016
+ "playbook_consolidation.decision kind=%s new_id=%s existing_id=%s "
1017
+ "trigger_match=%s",
1018
+ kind,
1019
+ new_id,
1020
+ existing_id_label,
1021
+ "unknown",
1022
+ )
1023
+ return
1024
+
1025
+ # RejectNewDecision exposes ``superseded_by_existing_id``; the other
1026
+ # two surviving kinds expose ``existing_id`` directly.
1027
+ existing_id_raw: int = getattr(
1028
+ decision,
1029
+ "existing_id",
1030
+ getattr(decision, "superseded_by_existing_id", 0),
1031
+ )
1032
+ existing_pb = existing_by_id.get(existing_id_raw)
1033
+ trigger_match = (
1034
+ new_pb is not None
1035
+ and existing_pb is not None
1036
+ and new_pb.trigger == existing_pb.trigger
1037
+ )
1038
+ logger.info(
1039
+ "playbook_consolidation.decision kind=%s new_id=%s existing_id=%s "
1040
+ "trigger_match=%s",
1041
+ kind,
1042
+ new_id,
1043
+ existing_id_raw,
1044
+ str(trigger_match).lower(),
1045
+ )