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,772 @@
1
+ """
2
+ Profile deduplication service that merges duplicate profiles from multiple extractors
3
+ and against existing profiles in the database using hybrid search and LLM.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ import uuid
9
+ from datetime import UTC, datetime
10
+ from typing import Any, cast
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ from reflexio.models.api_schema.retriever_schema import SearchUserProfileRequest
15
+ from reflexio.models.api_schema.service_schemas import Status, UserProfile
16
+ from reflexio.server.api_endpoints.request_context import RequestContext
17
+ from reflexio.server.llm.litellm_client import LiteLLMClient
18
+ from reflexio.server.services.deduplication_utils import (
19
+ BaseDeduplicator,
20
+ format_dedup_timestamp,
21
+ parse_item_id,
22
+ )
23
+ from reflexio.server.services.embedding_text import embedding_input
24
+ from reflexio.server.services.profile.profile_generation_service_utils import (
25
+ ProfileTimeToLive,
26
+ calculate_expiration_timestamp,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # Backward-compat alias — existing unit tests import this name from this
33
+ # module. Delegates to the shared helper in deduplication_utils.
34
+ _format_profile_timestamp = format_dedup_timestamp
35
+
36
+
37
+ # Canonical prefix emitted by the extractor for forget/delete requests. The
38
+ # dedup LLM routes matching NEW profiles into `deletions`; any fallback path
39
+ # that skips the LLM step must strip these markers before returning so they
40
+ # are never persisted as facts.
41
+ _DELETION_MARKER_PREFIX = "Requested removal of"
42
+
43
+
44
+ def _strip_deletion_markers(
45
+ profiles: list[UserProfile],
46
+ ) -> list[UserProfile]:
47
+ """
48
+ Drop profiles whose content is a canonical deletion marker.
49
+
50
+ Used on fallback paths (LLM error, unexpected response type, empty dedup
51
+ output) to prevent "Requested removal of …" markers emitted by the
52
+ extractor from being persisted as regular profile facts when the dedup
53
+ LLM step is skipped or yields no deletions. Persisting such markers would
54
+ recreate the exact zombie-profile failure mode the deletion-directive
55
+ channel was introduced to eliminate.
56
+
57
+ Args:
58
+ profiles (list[UserProfile]): Profiles to filter.
59
+
60
+ Returns:
61
+ list[UserProfile]: Profiles with deletion markers removed.
62
+ """
63
+ return [
64
+ p
65
+ for p in profiles
66
+ if not (p.content or "").lstrip().startswith(_DELETION_MARKER_PREFIX)
67
+ ]
68
+
69
+
70
+ # ===============================
71
+ # Profile-specific Pydantic Output Schemas for LLM
72
+ # ===============================
73
+
74
+
75
+ class ProfileDuplicateGroup(BaseModel):
76
+ """
77
+ Represents a group of duplicate profiles across NEW and EXISTING sets.
78
+
79
+ Attributes:
80
+ item_ids: List of item IDs matching prompt format (e.g., 'NEW-0', 'EXISTING-1')
81
+ merged_content: The consolidated profile content combining information from all duplicates
82
+ merged_time_to_live: The chosen time_to_live for the merged profile
83
+ """
84
+
85
+ item_ids: list[str] = Field(
86
+ description="IDs of items in this group matching prompt format (e.g., 'NEW-0', 'EXISTING-1')"
87
+ )
88
+ merged_content: str = Field(
89
+ description="Consolidated profile content combining all duplicate information"
90
+ )
91
+ merged_time_to_live: str = Field(
92
+ description="Time to live for merged profile: one_day, one_week, one_month, one_quarter, one_year, infinity"
93
+ )
94
+
95
+ model_config = ConfigDict(
96
+ extra="allow",
97
+ json_schema_extra={"additionalProperties": False},
98
+ )
99
+
100
+
101
+ class ProfileDeletionDirective(BaseModel):
102
+ """
103
+ Represents a NEW profile that is a meta-request to forget an EXISTING fact.
104
+
105
+ Used when the user explicitly asks the system to erase a previously-stored
106
+ profile (e.g. "forget that I like X"). Unlike a duplicate group, a deletion
107
+ directive removes the matched EXISTING profile(s) without writing any merged
108
+ or replacement profile — the NEW directive is consumed, not retained.
109
+
110
+ Attributes:
111
+ new_id: ID of the NEW profile that expresses the deletion directive (e.g. 'NEW-0')
112
+ existing_ids: IDs of EXISTING profiles to delete without replacement (e.g. ['EXISTING-0'])
113
+ reasoning: Brief explanation of why this was classified as a deletion directive
114
+ rather than a fact update
115
+ """
116
+
117
+ new_id: str = Field(
118
+ description="ID of the NEW profile that is a deletion directive (e.g. 'NEW-0')"
119
+ )
120
+ existing_ids: list[str] = Field(
121
+ description="IDs of EXISTING profiles to delete without replacement (e.g. ['EXISTING-0'])"
122
+ )
123
+ reasoning: str = Field(
124
+ description="Brief explanation of the deletion classification"
125
+ )
126
+
127
+ model_config = ConfigDict(
128
+ extra="allow",
129
+ json_schema_extra={"additionalProperties": False},
130
+ )
131
+
132
+
133
+ class ProfileDeduplicationOutput(BaseModel):
134
+ """
135
+ Output schema for profile deduplication with NEW/EXISTING format.
136
+
137
+ Attributes:
138
+ duplicate_groups: List of duplicate groups to merge
139
+ unique_ids: List of IDs of unique NEW profiles (e.g., 'NEW-2')
140
+ deletions: List of deletion directives — NEW profiles that are pure
141
+ meta-requests to erase an EXISTING profile. Both the NEW and the
142
+ matched EXISTING profile(s) are removed; no merged replacement is
143
+ produced.
144
+ """
145
+
146
+ duplicate_groups: list[ProfileDuplicateGroup] = Field(
147
+ default=[], description="Groups of duplicate profiles that should be merged"
148
+ )
149
+ unique_ids: list[str] = Field(
150
+ default=[],
151
+ description="IDs of unique NEW profiles (e.g., 'NEW-2')",
152
+ )
153
+ deletions: list[ProfileDeletionDirective] = Field(
154
+ default=[],
155
+ description=(
156
+ "NEW profiles that are pure deletion directives (the user asked to "
157
+ "forget/remove a stored fact). Both the NEW and matched EXISTING "
158
+ "profiles are removed; no merged replacement is written."
159
+ ),
160
+ )
161
+
162
+ model_config = ConfigDict(
163
+ extra="allow",
164
+ json_schema_extra={"additionalProperties": False},
165
+ )
166
+
167
+
168
+ class ProfileDeduplicator(BaseDeduplicator):
169
+ """
170
+ Deduplicates new profiles against each other and against existing profiles
171
+ in the database using hybrid search (vector + FTS) and LLM-based merging.
172
+
173
+ Follows the same pattern as PlaybookConsolidator.
174
+ """
175
+
176
+ DEDUPLICATION_PROMPT_ID = "profile_deduplication"
177
+
178
+ def __init__(
179
+ self,
180
+ request_context: RequestContext,
181
+ llm_client: LiteLLMClient,
182
+ output_pending_status: bool = False,
183
+ ):
184
+ """
185
+ Initialize the profile deduplicator.
186
+
187
+ Args:
188
+ request_context: Request context with storage and prompt manager
189
+ llm_client: Unified LLM client for LLM calls
190
+ output_pending_status: When True (rerun preview mode), the deduplicator
191
+ searches against existing PENDING profiles instead of CURRENT
192
+ profiles. This makes rerun idempotent against pending state and
193
+ leaves the user's current profile set untouched. When False
194
+ (normal extraction), searches against CURRENT profiles so newly
195
+ extracted facts can supersede stale ones.
196
+ """
197
+ super().__init__(request_context, llm_client)
198
+ self.output_pending_status = output_pending_status
199
+
200
+ def _get_prompt_id(self) -> str:
201
+ """Get the prompt ID for profile deduplication."""
202
+ return self.DEDUPLICATION_PROMPT_ID
203
+
204
+ def _get_item_count_key(self) -> str:
205
+ """Get the key name for item count in prompt variables."""
206
+ return "new_profile_count"
207
+
208
+ def _get_items_key(self) -> str:
209
+ """Get the key name for items in prompt variables."""
210
+ return "new_profiles"
211
+
212
+ def _get_output_schema_class(self) -> type[BaseModel]:
213
+ """Get the profile-specific output schema with NEW/EXISTING format."""
214
+ return ProfileDeduplicationOutput
215
+
216
+ def _format_items_for_prompt(self, profiles: list[UserProfile]) -> str:
217
+ """
218
+ Format profiles list for LLM prompt with NEW-N prefix.
219
+
220
+ Args:
221
+ profiles: List of profiles
222
+
223
+ Returns:
224
+ Formatted string representation
225
+ """
226
+ return self._format_profiles_with_prefix(profiles, "NEW")
227
+
228
+ def _format_profiles_with_prefix(
229
+ self, profiles: list[UserProfile], prefix: str
230
+ ) -> str:
231
+ """
232
+ Format profiles with a given prefix (NEW or EXISTING).
233
+
234
+ Args:
235
+ profiles: List of profiles to format
236
+ prefix: Prefix string for indices
237
+
238
+ Returns:
239
+ Formatted string
240
+ """
241
+ if not profiles:
242
+ return "(None)"
243
+ lines = []
244
+ for idx, profile in enumerate(profiles):
245
+ ttl = (
246
+ profile.profile_time_to_live.value
247
+ if profile.profile_time_to_live
248
+ else "unknown"
249
+ )
250
+ source = profile.source or "unknown"
251
+ modified_date = _format_profile_timestamp(profile.last_modified_timestamp)
252
+ lines.append(
253
+ f'[{prefix}-{idx}] Content: "{profile.content}" | TTL: {ttl} | Source: {source} | Last Modified: {modified_date}'
254
+ )
255
+ return "\n".join(lines)
256
+
257
+ def _format_new_and_existing_for_prompt(
258
+ self,
259
+ new_profiles: list[UserProfile],
260
+ existing_profiles: list[UserProfile],
261
+ ) -> tuple[str, str]:
262
+ """
263
+ Format new and existing profiles for the deduplication prompt.
264
+
265
+ Args:
266
+ new_profiles: New profiles to deduplicate
267
+ existing_profiles: Existing profiles from the database
268
+
269
+ Returns:
270
+ Tuple of (new_profiles_text, existing_profiles_text)
271
+ """
272
+ new_text = self._format_profiles_with_prefix(new_profiles, "NEW")
273
+ existing_text = self._format_profiles_with_prefix(existing_profiles, "EXISTING")
274
+ return new_text, existing_text
275
+
276
+ def _retrieve_existing_profiles(
277
+ self,
278
+ new_profiles: list[UserProfile],
279
+ user_id: str,
280
+ ) -> list[UserProfile]:
281
+ """
282
+ Retrieve existing profiles from the database using hybrid search.
283
+
284
+ For each new profile, uses its profile_content as the query with
285
+ pre-computed embeddings for vector search.
286
+
287
+ Args:
288
+ new_profiles: List of new profiles to search against
289
+ user_id: User ID to scope the search
290
+
291
+ Returns:
292
+ Deduplicated list of existing UserProfile objects from the database
293
+ """
294
+ storage = self.request_context.storage
295
+
296
+ # Collect profile content strings for embedding
297
+ query_texts = []
298
+ for profile in new_profiles:
299
+ text = profile.content
300
+ if text and text.strip():
301
+ query_texts.append(text.strip())
302
+
303
+ if not query_texts:
304
+ return []
305
+
306
+ # Generate embeddings with the request storage backend so dedup search
307
+ # uses the same model/prefix/routing as normal profile search.
308
+ embeddings: list[list[float] | None]
309
+ try:
310
+ get_storage_embedding = getattr(storage, "_get_embedding", None)
311
+ if callable(get_storage_embedding):
312
+ logger.info(
313
+ "Profile dedup query embeddings: source=storage model=%s",
314
+ getattr(storage, "embedding_model_name", "unknown"),
315
+ )
316
+ embeddings = [
317
+ cast(
318
+ list[float],
319
+ get_storage_embedding(query_text, purpose="query"),
320
+ )
321
+ for query_text in query_texts
322
+ ]
323
+ else:
324
+ storage_with_embeddings = cast(Any, storage)
325
+ embedding_model_name = storage_with_embeddings.embedding_model_name
326
+ embedding_dimensions = storage_with_embeddings.embedding_dimensions
327
+ logger.info(
328
+ "Profile dedup query embeddings: source=llm_client model=%s",
329
+ embedding_model_name,
330
+ )
331
+ embeddings = list(
332
+ self.client.get_embeddings(
333
+ [
334
+ embedding_input(query_text, purpose="query")
335
+ for query_text in query_texts
336
+ ],
337
+ model=embedding_model_name,
338
+ dimensions=embedding_dimensions,
339
+ )
340
+ )
341
+ except Exception as e:
342
+ logger.warning("Failed to generate embeddings for dedup search: %s", e)
343
+ embeddings = [None] * len(query_texts)
344
+
345
+ # Search for each new profile.
346
+ #
347
+ # When output_pending_status=True (rerun preview mode), search against
348
+ # existing PENDING profiles so dedup is idempotent: a rerun should
349
+ # collapse duplicates within the pending preview set without ever
350
+ # touching the user's CURRENT profiles. Searching against [None]
351
+ # (current) here would cause newly-extracted profiles to be flagged
352
+ # as duplicates of current ones, and the downstream deletion step
353
+ # in ProfileGenerationService._process_results would then delete the
354
+ # CURRENT profiles -- collapsing the user's profile set to zero
355
+ # during what is supposed to be a non-destructive preview.
356
+ if self.output_pending_status:
357
+ search_status_filter: list[Status | None] = [Status.PENDING]
358
+ else:
359
+ search_status_filter = [None] # Only current profiles
360
+
361
+ seen_ids: set[str] = set()
362
+ existing_profiles: list[UserProfile] = []
363
+
364
+ for i, query_text in enumerate(query_texts):
365
+ try:
366
+ results = storage.search_user_profile( # type: ignore[reportOptionalMemberAccess]
367
+ SearchUserProfileRequest(
368
+ query=query_text,
369
+ user_id=user_id,
370
+ top_k=10,
371
+ threshold=0.4,
372
+ ),
373
+ status_filter=search_status_filter,
374
+ query_embedding=embeddings[i],
375
+ )
376
+ for profile in results:
377
+ if profile.profile_id and profile.profile_id not in seen_ids:
378
+ seen_ids.add(profile.profile_id)
379
+ existing_profiles.append(profile)
380
+ except Exception as e: # noqa: PERF203
381
+ logger.warning(
382
+ "Failed to search existing profiles for query %d: %s", i, e
383
+ )
384
+
385
+ logger.info(
386
+ "Retrieved %d unique existing profiles for deduplication "
387
+ "(status_filter=%s, output_pending_status=%s)",
388
+ len(existing_profiles),
389
+ search_status_filter,
390
+ self.output_pending_status,
391
+ )
392
+ return existing_profiles
393
+
394
+ def deduplicate(
395
+ self,
396
+ new_profiles: list[UserProfile],
397
+ user_id: str,
398
+ request_id: str,
399
+ ) -> tuple[list[UserProfile], list[str], list[UserProfile]]:
400
+ """
401
+ Deduplicate profiles across extractors and against existing profiles in DB.
402
+
403
+ Args:
404
+ new_profiles: List of new UserProfile objects from extractors
405
+ request_id: Request ID for context
406
+ user_id: User ID to scope the existing profile search
407
+
408
+ Returns:
409
+ Tuple of (deduplicated profiles, existing profile IDs to delete, superseded existing profiles)
410
+ """
411
+ # Check if mock mode is enabled
412
+ if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
413
+ logger.info("Mock mode: skipping deduplication")
414
+ return new_profiles, [], []
415
+
416
+ if not new_profiles:
417
+ return [], [], []
418
+
419
+ # Retrieve existing profiles via hybrid search
420
+ existing_profiles = self._retrieve_existing_profiles(new_profiles, user_id)
421
+
422
+ # Format for prompt
423
+ new_text, existing_text = self._format_new_and_existing_for_prompt(
424
+ new_profiles, existing_profiles
425
+ )
426
+
427
+ # Build and call LLM
428
+ prompt = self.request_context.prompt_manager.render_prompt(
429
+ self._get_prompt_id(),
430
+ {
431
+ "new_profile_count": len(new_profiles),
432
+ "new_profiles": new_text,
433
+ "existing_profile_count": len(existing_profiles),
434
+ "existing_profiles": existing_text,
435
+ },
436
+ )
437
+
438
+ output_schema_class = self._get_output_schema_class()
439
+
440
+ try:
441
+ from reflexio.server.services.service_utils import (
442
+ log_llm_messages,
443
+ log_model_response,
444
+ )
445
+
446
+ log_llm_messages(
447
+ logger, "Profile deduplication", [{"role": "user", "content": prompt}]
448
+ )
449
+
450
+ response = self.client.generate_chat_response(
451
+ messages=[{"role": "user", "content": prompt}],
452
+ model=self.model_name,
453
+ response_format=output_schema_class,
454
+ )
455
+
456
+ log_model_response(logger, "Deduplication response", response)
457
+
458
+ if not isinstance(response, ProfileDeduplicationOutput):
459
+ logger.warning(
460
+ "Unexpected response type from deduplication LLM: %s",
461
+ type(response),
462
+ )
463
+ return _strip_deletion_markers(new_profiles), [], []
464
+
465
+ dedup_output = response
466
+ except Exception as e:
467
+ logger.error("Failed to identify duplicates: %s", str(e))
468
+ return _strip_deletion_markers(new_profiles), [], []
469
+
470
+ if not dedup_output.duplicate_groups and not dedup_output.deletions:
471
+ logger.info("No duplicate or deletion actions for request %s", request_id)
472
+ return _strip_deletion_markers(new_profiles), [], []
473
+
474
+ logger.info(
475
+ "Found %d duplicate profile groups and %d deletion directives for request %s",
476
+ len(dedup_output.duplicate_groups),
477
+ len(dedup_output.deletions),
478
+ request_id,
479
+ )
480
+
481
+ # Build deduplicated result
482
+ return self._build_deduplicated_results(
483
+ new_profiles=new_profiles,
484
+ existing_profiles=existing_profiles,
485
+ dedup_output=dedup_output,
486
+ user_id=user_id,
487
+ request_id=request_id,
488
+ )
489
+
490
+ def _build_deduplicated_results(
491
+ self,
492
+ new_profiles: list[UserProfile],
493
+ existing_profiles: list[UserProfile],
494
+ dedup_output: ProfileDeduplicationOutput,
495
+ user_id: str,
496
+ request_id: str,
497
+ ) -> tuple[list[UserProfile], list[str], list[UserProfile]]:
498
+ """
499
+ Build the deduplicated profile list from LLM output.
500
+
501
+ Args:
502
+ new_profiles: Flattened list of new profiles
503
+ existing_profiles: List of existing profiles from DB
504
+ dedup_output: LLM deduplication output
505
+ user_id: User ID
506
+ request_id: Request ID
507
+
508
+ Returns:
509
+ Tuple of (profiles ready to save, existing profile IDs to delete, superseded existing profiles)
510
+ """
511
+ handled_new_indices: set[int] = set()
512
+ result_profiles: list[UserProfile] = []
513
+ existing_ids_to_delete: list[str] = []
514
+ seen_delete_ids: set[str] = set()
515
+ superseded_profiles: list[UserProfile] = []
516
+
517
+ now_ts = int(datetime.now(UTC).timestamp())
518
+
519
+ # Process deletion directives first. A directive is a NEW profile that
520
+ # is a meta-request to forget an EXISTING profile. Both the NEW and the
521
+ # matched EXISTING profile(s) are removed with no merged replacement.
522
+ self._apply_deletion_directives(
523
+ dedup_output.deletions,
524
+ new_profiles=new_profiles,
525
+ existing_profiles=existing_profiles,
526
+ handled_new_indices=handled_new_indices,
527
+ existing_ids_to_delete=existing_ids_to_delete,
528
+ seen_delete_ids=seen_delete_ids,
529
+ superseded_profiles=superseded_profiles,
530
+ )
531
+
532
+ # Process duplicate groups
533
+ for group in dedup_output.duplicate_groups:
534
+ group_new_indices: list[int] = []
535
+ group_existing_indices: list[int] = []
536
+
537
+ for item_id in group.item_ids:
538
+ parsed = parse_item_id(item_id)
539
+ if parsed is None:
540
+ continue
541
+ prefix, idx = parsed
542
+ if prefix == "NEW":
543
+ group_new_indices.append(idx)
544
+ elif prefix == "EXISTING":
545
+ group_existing_indices.append(idx)
546
+
547
+ # Reject groups that overlap with profiles already consumed by a
548
+ # deletion directive. Merging such a group would write a
549
+ # replacement profile containing content the user asked to forget.
550
+ conflicting_new = [i for i in group_new_indices if i in handled_new_indices]
551
+ conflicting_existing = [
552
+ i
553
+ for i in group_existing_indices
554
+ if 0 <= i < len(existing_profiles)
555
+ and existing_profiles[i].profile_id
556
+ and existing_profiles[i].profile_id in seen_delete_ids
557
+ ]
558
+ if conflicting_new or conflicting_existing:
559
+ logger.warning(
560
+ "Skipping duplicate group %s: overlaps with deletion "
561
+ "directives (NEW indices=%s, EXISTING indices=%s)",
562
+ group.item_ids,
563
+ conflicting_new,
564
+ conflicting_existing,
565
+ )
566
+ continue
567
+
568
+ # Mark NEW indices as handled only after the overlap check passes.
569
+ for idx in group_new_indices:
570
+ handled_new_indices.add(idx)
571
+
572
+ # Collect existing profile IDs to delete and their profiles for changelog (deduplicated)
573
+ for eidx in group_existing_indices:
574
+ self._mark_existing_for_deletion(
575
+ f"EXISTING-{eidx}",
576
+ existing_profiles,
577
+ existing_ids_to_delete,
578
+ seen_delete_ids,
579
+ superseded_profiles,
580
+ )
581
+
582
+ # Get template from first NEW profile in group (for metadata)
583
+ template_profile: UserProfile | None = None
584
+ if group_new_indices:
585
+ first_new_idx = group_new_indices[0]
586
+ if 0 <= first_new_idx < len(new_profiles):
587
+ template_profile = new_profiles[first_new_idx]
588
+
589
+ if template_profile is None:
590
+ logger.warning("Could not find template profile for group, skipping")
591
+ continue
592
+
593
+ # Merge custom_features from all NEW profiles in group
594
+ group_new_profiles = [
595
+ new_profiles[i] for i in group_new_indices if 0 <= i < len(new_profiles)
596
+ ]
597
+ merged_custom_features = self._merge_custom_features(group_new_profiles)
598
+
599
+ # Merge extractor_names from all NEW profiles in group
600
+ merged_extractor_names = self._merge_extractor_names(group_new_profiles)
601
+
602
+ # Determine TTL
603
+ try:
604
+ ttl = ProfileTimeToLive(group.merged_time_to_live)
605
+ except ValueError:
606
+ ttl = template_profile.profile_time_to_live
607
+ logger.warning(
608
+ "Invalid TTL '%s' from LLM, using template TTL '%s'",
609
+ group.merged_time_to_live,
610
+ ttl.value,
611
+ )
612
+
613
+ merged_profile = UserProfile(
614
+ profile_id=str(uuid.uuid4()),
615
+ user_id=user_id,
616
+ content=group.merged_content,
617
+ last_modified_timestamp=now_ts,
618
+ generated_from_request_id=request_id,
619
+ profile_time_to_live=ttl,
620
+ expiration_timestamp=calculate_expiration_timestamp(now_ts, ttl),
621
+ custom_features=merged_custom_features,
622
+ source=template_profile.source,
623
+ status=template_profile.status,
624
+ extractor_names=merged_extractor_names,
625
+ )
626
+ result_profiles.append(merged_profile)
627
+
628
+ # Add unique NEW profiles
629
+ for uid in dedup_output.unique_ids:
630
+ parsed = parse_item_id(uid)
631
+ if parsed is None:
632
+ continue
633
+ prefix, idx = parsed
634
+ if (
635
+ prefix == "NEW"
636
+ and idx not in handled_new_indices
637
+ and 0 <= idx < len(new_profiles)
638
+ ):
639
+ result_profiles.append(new_profiles[idx])
640
+ handled_new_indices.add(idx)
641
+
642
+ # Safety fallback: add any NEW profiles not mentioned by LLM
643
+ for idx, profile in enumerate(new_profiles):
644
+ if idx not in handled_new_indices:
645
+ logger.warning(
646
+ "New profile at index %d was not handled by LLM, adding as-is",
647
+ idx,
648
+ )
649
+ result_profiles.append(profile)
650
+
651
+ return result_profiles, existing_ids_to_delete, superseded_profiles
652
+
653
+ def _apply_deletion_directives(
654
+ self,
655
+ directives: list[ProfileDeletionDirective],
656
+ *,
657
+ new_profiles: list[UserProfile],
658
+ existing_profiles: list[UserProfile],
659
+ handled_new_indices: set[int],
660
+ existing_ids_to_delete: list[str],
661
+ seen_delete_ids: set[str],
662
+ superseded_profiles: list[UserProfile],
663
+ ) -> None:
664
+ """
665
+ Apply deletion directives in place: consume the NEW profile and mark matched
666
+ EXISTING profile(s) for deletion without producing a merged replacement.
667
+
668
+ A directive is a NEW profile whose content is a meta-request to forget an
669
+ EXISTING profile (e.g. "Requested removal of interest in X from stored
670
+ profiles"). The NEW is suppressed from the result set and the matched
671
+ EXISTING rows are added to the deletion list.
672
+
673
+ Args:
674
+ directives: Deletion directives from the LLM.
675
+ new_profiles: Flat list of NEW profiles (indexed by NEW-N id).
676
+ existing_profiles: List of EXISTING profiles (indexed by EXISTING-M id).
677
+ handled_new_indices: Set of NEW indices already accounted for; this
678
+ method adds the consumed directive indices to it.
679
+ existing_ids_to_delete: Output list of profile IDs to delete; this
680
+ method appends to it.
681
+ seen_delete_ids: Set used to deduplicate IDs across all deletion paths.
682
+ superseded_profiles: Output list of deleted profile objects for the
683
+ changelog; this method appends to it.
684
+ """
685
+ for directive in directives:
686
+ self._consume_new_index(
687
+ directive.new_id, len(new_profiles), handled_new_indices
688
+ )
689
+ for eid in directive.existing_ids:
690
+ self._mark_existing_for_deletion(
691
+ eid,
692
+ existing_profiles,
693
+ existing_ids_to_delete,
694
+ seen_delete_ids,
695
+ superseded_profiles,
696
+ )
697
+ logger.info(
698
+ "Profile deletion directive %s -> delete %s: %s",
699
+ directive.new_id,
700
+ directive.existing_ids,
701
+ directive.reasoning,
702
+ )
703
+
704
+ @staticmethod
705
+ def _consume_new_index(
706
+ new_id: str, new_profile_count: int, handled_new_indices: set[int]
707
+ ) -> None:
708
+ """Mark a NEW-N id as handled so the safety fallback does not re-add it."""
709
+ parsed = parse_item_id(new_id)
710
+ if parsed is None:
711
+ return
712
+ prefix, idx = parsed
713
+ if prefix == "NEW" and 0 <= idx < new_profile_count:
714
+ handled_new_indices.add(idx)
715
+
716
+ @staticmethod
717
+ def _mark_existing_for_deletion(
718
+ existing_id: str,
719
+ existing_profiles: list[UserProfile],
720
+ existing_ids_to_delete: list[str],
721
+ seen_delete_ids: set[str],
722
+ superseded_profiles: list[UserProfile],
723
+ ) -> None:
724
+ """Resolve an EXISTING-N id to a profile_id and queue it for deletion."""
725
+ parsed = parse_item_id(existing_id)
726
+ if parsed is None:
727
+ return
728
+ prefix, idx = parsed
729
+ if prefix != "EXISTING" or not (0 <= idx < len(existing_profiles)):
730
+ return
731
+ pid = existing_profiles[idx].profile_id
732
+ if pid and pid not in seen_delete_ids:
733
+ seen_delete_ids.add(pid)
734
+ existing_ids_to_delete.append(pid)
735
+ superseded_profiles.append(existing_profiles[idx])
736
+
737
+ def _merge_custom_features(self, profiles: list[UserProfile]) -> dict | None:
738
+ """
739
+ Merge custom_features from multiple profiles.
740
+
741
+ Args:
742
+ profiles: List of profiles to merge custom_features from
743
+
744
+ Returns:
745
+ Merged custom_features dict or None if no custom_features
746
+ """
747
+ merged = {}
748
+ for profile in profiles:
749
+ if profile.custom_features:
750
+ merged.update(profile.custom_features)
751
+
752
+ return merged or None
753
+
754
+ def _merge_extractor_names(self, profiles: list[UserProfile]) -> list[str] | None:
755
+ """
756
+ Merge extractor_names from multiple profiles, preserving order and removing duplicates.
757
+
758
+ Args:
759
+ profiles: List of profiles to merge extractor_names from
760
+
761
+ Returns:
762
+ Merged list of unique extractor names or None if no extractor_names
763
+ """
764
+ seen: set[str] = set()
765
+ merged: list[str] = []
766
+ for profile in profiles:
767
+ if profile.extractor_names:
768
+ for name in profile.extractor_names:
769
+ if name not in seen:
770
+ seen.add(name)
771
+ merged.append(name)
772
+ return merged or None