claude-smart 0.2.41 → 0.2.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.md +1 -1
  3. package/bin/claude-smart.js +86 -48
  4. package/package.json +10 -3
  5. package/plugin/.claude-plugin/plugin.json +9 -3
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +2 -2
  8. package/plugin/dashboard/next.config.ts +9 -1
  9. package/plugin/pyproject.toml +2 -2
  10. package/plugin/scripts/_lib.sh +91 -0
  11. package/plugin/scripts/backend-service.sh +46 -15
  12. package/plugin/scripts/cli.sh +29 -1
  13. package/plugin/scripts/codex-hook.js +72 -4
  14. package/plugin/scripts/dashboard-build.sh +1 -0
  15. package/plugin/scripts/dashboard-service.sh +1 -0
  16. package/plugin/scripts/ensure-plugin-root.sh +7 -14
  17. package/plugin/scripts/hook_entry.sh +1 -0
  18. package/plugin/scripts/smart-install.sh +18 -2
  19. package/plugin/src/claude_smart/cli.py +72 -38
  20. package/plugin/src/claude_smart/context_format.py +11 -12
  21. package/plugin/src/claude_smart/cs_cite.py +26 -12
  22. package/plugin/src/claude_smart/ids.py +13 -5
  23. package/plugin/uv.lock +1 -1
  24. package/plugin/vendor/reflexio/.env.example +53 -0
  25. package/plugin/vendor/reflexio/LICENSE +201 -0
  26. package/plugin/vendor/reflexio/README.md +338 -0
  27. package/plugin/vendor/reflexio/pyproject.toml +271 -0
  28. package/plugin/vendor/reflexio/reflexio/README.md +184 -0
  29. package/plugin/vendor/reflexio/reflexio/__init__.py +166 -0
  30. package/plugin/vendor/reflexio/reflexio/benchmarks/__init__.py +1 -0
  31. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/README.md +109 -0
  32. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/__init__.py +1 -0
  33. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/backends.py +175 -0
  34. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/bench.py +642 -0
  35. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/embed_cache.py +330 -0
  36. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/report.py +317 -0
  37. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/report.md +43 -0
  38. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/results.json +4478 -0
  39. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/scenarios.py +134 -0
  40. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/seed.py +255 -0
  41. package/plugin/vendor/reflexio/reflexio/cli/README.md +287 -0
  42. package/plugin/vendor/reflexio/reflexio/cli/__init__.py +0 -0
  43. package/plugin/vendor/reflexio/reflexio/cli/__main__.py +56 -0
  44. package/plugin/vendor/reflexio/reflexio/cli/_client.py +86 -0
  45. package/plugin/vendor/reflexio/reflexio/cli/app.py +127 -0
  46. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +266 -0
  47. package/plugin/vendor/reflexio/reflexio/cli/codex_auth.py +503 -0
  48. package/plugin/vendor/reflexio/reflexio/cli/commands/__init__.py +0 -0
  49. package/plugin/vendor/reflexio/reflexio/cli/commands/admin_cmd.py +65 -0
  50. package/plugin/vendor/reflexio/reflexio/cli/commands/agent_playbooks.py +503 -0
  51. package/plugin/vendor/reflexio/reflexio/cli/commands/api.py +114 -0
  52. package/plugin/vendor/reflexio/reflexio/cli/commands/auth.py +109 -0
  53. package/plugin/vendor/reflexio/reflexio/cli/commands/config_cmd.py +511 -0
  54. package/plugin/vendor/reflexio/reflexio/cli/commands/doctor.py +127 -0
  55. package/plugin/vendor/reflexio/reflexio/cli/commands/embeddings.py +53 -0
  56. package/plugin/vendor/reflexio/reflexio/cli/commands/interactions.py +478 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/commands/profiles.py +303 -0
  58. package/plugin/vendor/reflexio/reflexio/cli/commands/services.py +289 -0
  59. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +961 -0
  60. package/plugin/vendor/reflexio/reflexio/cli/commands/shortcuts.py +285 -0
  61. package/plugin/vendor/reflexio/reflexio/cli/commands/status_cmd.py +143 -0
  62. package/plugin/vendor/reflexio/reflexio/cli/commands/user_playbooks.py +373 -0
  63. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +284 -0
  64. package/plugin/vendor/reflexio/reflexio/cli/errors.py +217 -0
  65. package/plugin/vendor/reflexio/reflexio/cli/log_format.py +247 -0
  66. package/plugin/vendor/reflexio/reflexio/cli/output.py +867 -0
  67. package/plugin/vendor/reflexio/reflexio/cli/paths.py +41 -0
  68. package/plugin/vendor/reflexio/reflexio/cli/run_services.py +391 -0
  69. package/plugin/vendor/reflexio/reflexio/cli/state.py +204 -0
  70. package/plugin/vendor/reflexio/reflexio/cli/stop_services.py +96 -0
  71. package/plugin/vendor/reflexio/reflexio/cli/utils.py +329 -0
  72. package/plugin/vendor/reflexio/reflexio/client/__init__.py +3 -0
  73. package/plugin/vendor/reflexio/reflexio/client/cache.py +150 -0
  74. package/plugin/vendor/reflexio/reflexio/client/client.py +2613 -0
  75. package/plugin/vendor/reflexio/reflexio/defaults.py +23 -0
  76. package/plugin/vendor/reflexio/reflexio/integrations/__init__.py +0 -0
  77. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/.clawhubignore +7 -0
  78. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/README.md +274 -0
  79. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/TESTING.md +517 -0
  80. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/hook/handler.js +473 -0
  81. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package-lock.json +2156 -0
  82. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package.json +18 -0
  83. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/handler.ts +241 -0
  84. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/setup.ts +140 -0
  85. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/index.ts +130 -0
  86. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/publish.ts +113 -0
  87. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/search.ts +52 -0
  88. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/server.ts +103 -0
  89. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/sqlite-buffer.ts +156 -0
  90. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/user-id.ts +134 -0
  91. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/openclaw.plugin.json +41 -0
  92. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/package.json +17 -0
  93. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/rules/reflexio.md +24 -0
  94. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/reflexio/SKILL.md +48 -0
  95. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/publish_clawhub.sh +278 -0
  96. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/references/HOOK.md +164 -0
  97. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/install.sh +36 -0
  98. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/uninstall.sh +35 -0
  99. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/publish.test.ts +27 -0
  100. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/search.test.ts +31 -0
  101. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/server.test.ts +42 -0
  102. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/setup.test.ts +49 -0
  103. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/sqlite-buffer.test.ts +91 -0
  104. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/user-id.test.ts +50 -0
  105. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tsconfig.json +16 -0
  106. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/types/openclaw.d.ts +230 -0
  107. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/vitest.config.ts +13 -0
  108. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/README.md +120 -0
  109. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/TESTING.md +168 -0
  110. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package-lock.json +1657 -0
  111. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package.json +16 -0
  112. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/HEARTBEAT.md +6 -0
  113. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/README.md +84 -0
  114. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/SKILL.md +194 -0
  115. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/_meta.json +6 -0
  116. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/agents/reflexio-extractor.md +45 -0
  117. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/handler.ts +214 -0
  118. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/setup.ts +55 -0
  119. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/index.ts +327 -0
  120. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/consolidate.ts +233 -0
  121. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/dedup.ts +80 -0
  122. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/io.ts +155 -0
  123. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/openclaw-cli.ts +67 -0
  124. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/search.ts +33 -0
  125. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-playbook.ts +76 -0
  126. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-profile.ts +79 -0
  127. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/openclaw.plugin.json +46 -0
  128. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/package.json +18 -0
  129. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/README.md +36 -0
  130. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/full_consolidation.md +56 -0
  131. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/playbook_extraction.md +217 -0
  132. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/profile_extraction.md +132 -0
  133. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-consolidate/SKILL.md +33 -0
  134. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-embedded/SKILL.md +194 -0
  135. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/HOOK.md +18 -0
  136. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/architecture.md +49 -0
  137. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/comparison.md +31 -0
  138. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/future-work.md +47 -0
  139. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/porting-notes.md +52 -0
  140. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/install.sh +52 -0
  141. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/uninstall.sh +36 -0
  142. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/consolidate.test.ts +135 -0
  143. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/dedup.test.ts +104 -0
  144. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/io.test.ts +175 -0
  145. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/search.test.ts +66 -0
  146. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/smoke-test.ts +140 -0
  147. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-playbook.test.ts +93 -0
  148. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-profile.test.ts +174 -0
  149. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tsconfig.json +16 -0
  150. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/types/openclaw.d.ts +230 -0
  151. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/vitest.config.ts +7 -0
  152. package/plugin/vendor/reflexio/reflexio/lib/__init__.py +23 -0
  153. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +310 -0
  154. package/plugin/vendor/reflexio/reflexio/lib/_base.py +225 -0
  155. package/plugin/vendor/reflexio/reflexio/lib/_config.py +83 -0
  156. package/plugin/vendor/reflexio/reflexio/lib/_dashboard.py +266 -0
  157. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +176 -0
  158. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +334 -0
  159. package/plugin/vendor/reflexio/reflexio/lib/_operations.py +153 -0
  160. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +545 -0
  161. package/plugin/vendor/reflexio/reflexio/lib/_reflection.py +52 -0
  162. package/plugin/vendor/reflexio/reflexio/lib/_search.py +167 -0
  163. package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +103 -0
  164. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +288 -0
  165. package/plugin/vendor/reflexio/reflexio/lib/reflexio_lib.py +27 -0
  166. package/plugin/vendor/reflexio/reflexio/models/__init__.py +0 -0
  167. package/plugin/vendor/reflexio/reflexio/models/api_schema/__init__.py +0 -0
  168. package/plugin/vendor/reflexio/reflexio/models/api_schema/braintrust_schema.py +141 -0
  169. package/plugin/vendor/reflexio/reflexio/models/api_schema/common.py +41 -0
  170. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +3 -0
  171. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +1103 -0
  172. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +63 -0
  173. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +487 -0
  174. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +28 -0
  175. package/plugin/vendor/reflexio/reflexio/models/api_schema/pending_tool_call_schema.py +83 -0
  176. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +766 -0
  177. package/plugin/vendor/reflexio/reflexio/models/api_schema/service_schemas.py +9 -0
  178. package/plugin/vendor/reflexio/reflexio/models/api_schema/stall_state_schema.py +32 -0
  179. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/__init__.py +3 -0
  180. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +177 -0
  181. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +129 -0
  182. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/enums.py +25 -0
  183. package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +280 -0
  184. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +908 -0
  185. package/plugin/vendor/reflexio/reflexio/models/py.typed +0 -0
  186. package/plugin/vendor/reflexio/reflexio/server/OVERVIEW.md +90 -0
  187. package/plugin/vendor/reflexio/reflexio/server/README.md +616 -0
  188. package/plugin/vendor/reflexio/reflexio/server/__init__.py +210 -0
  189. package/plugin/vendor/reflexio/reflexio/server/__main__.py +132 -0
  190. package/plugin/vendor/reflexio/reflexio/server/_auth.py +25 -0
  191. package/plugin/vendor/reflexio/reflexio/server/api.py +2714 -0
  192. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/account_api.py +143 -0
  193. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/health_api.py +91 -0
  194. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/pending_tool_call_api.py +572 -0
  195. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/precondition_checks.py +66 -0
  196. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +540 -0
  197. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +50 -0
  198. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/stall_state_api.py +100 -0
  199. package/plugin/vendor/reflexio/reflexio/server/cache/__init__.py +15 -0
  200. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +208 -0
  201. package/plugin/vendor/reflexio/reflexio/server/correlation.py +46 -0
  202. package/plugin/vendor/reflexio/reflexio/server/llm/__init__.py +30 -0
  203. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +110 -0
  204. package/plugin/vendor/reflexio/reflexio/server/llm/image_utils.py +55 -0
  205. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1595 -0
  206. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +112 -0
  207. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +469 -0
  208. package/plugin/vendor/reflexio/reflexio/server/llm/providers/__init__.py +1 -0
  209. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1122 -0
  210. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_stream_parser.py +197 -0
  211. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +210 -0
  212. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +213 -0
  213. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +255 -0
  214. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/__init__.py +6 -0
  215. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +177 -0
  216. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/llm_reranker.py +148 -0
  217. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +699 -0
  218. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +179 -0
  219. package/plugin/vendor/reflexio/reflexio/server/prompt/__init__.py +0 -0
  220. package/plugin/vendor/reflexio/reflexio/server/prompt/_dispatchers.py +54 -0
  221. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/README.md +121 -0
  222. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation/v1.0.0.prompt.md +58 -0
  223. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation_with_comparison/v1.0.0.prompt.md +76 -0
  224. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/answer_synthesis/v1.5.2.prompt.md +88 -0
  225. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/compress_session_for_query/v1.3.0.prompt.md +31 -0
  226. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +20 -0
  227. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.0.0.prompt.md +53 -0
  228. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.1.0.prompt.md +57 -0
  229. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.2.0.prompt.md +68 -0
  230. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.3.0.prompt.md +70 -0
  231. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.4.0.prompt.md +77 -0
  232. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.5.0.prompt.md +82 -0
  233. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.6.0.prompt.md +83 -0
  234. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.1.0.prompt.md +193 -0
  235. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.2.0.prompt.md +206 -0
  236. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0-deprecated.prompt.md +66 -0
  237. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0.prompt.md +43 -0
  238. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.1.0.prompt.md +46 -0
  239. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0-deprecated.prompt.md +64 -0
  240. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0.prompt.md +39 -0
  241. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.1.0.prompt.md +39 -0
  242. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.2.0.prompt.md +47 -0
  243. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +58 -0
  244. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.0.2.prompt.md +254 -0
  245. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.1.0.prompt.md +274 -0
  246. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +279 -0
  247. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v1.0.0.prompt.md +73 -0
  248. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v2.0.0.prompt.md +86 -0
  249. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.0.0.prompt.md +97 -0
  250. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.1.0.prompt.md +119 -0
  251. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.2.0.prompt.md +123 -0
  252. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +127 -0
  253. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.0.0.prompt.md +14 -0
  254. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.1.0.prompt.md +24 -0
  255. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.2.0.prompt.md +29 -0
  256. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.0.0.prompt.md +11 -0
  257. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.1.0.prompt.md +21 -0
  258. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.2.0.prompt.md +25 -0
  259. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.0.0.prompt.md +37 -0
  260. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.1.0.prompt.md +40 -0
  261. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.2.0.prompt.md +36 -0
  262. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v1.0.0.prompt.md +45 -0
  263. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v2.0.0.prompt.md +81 -0
  264. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v3.0.0.prompt.md +80 -0
  265. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate_expert/v1.0.0.prompt.md +34 -0
  266. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_deduplication/v1.0.0.prompt.md +116 -0
  267. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate/v1.0.0.prompt.md +33 -0
  268. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate_override/v1.0.0.prompt.md +16 -0
  269. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.0.0.prompt.md +140 -0
  270. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.1.0.prompt.md +160 -0
  271. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_main/v1.0.0.prompt.md +14 -0
  272. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +19 -0
  273. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/rerank_relevance/v1.1.0.prompt.md +44 -0
  274. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +43 -0
  275. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_content_evaluation/v1.0.0.prompt.md +33 -0
  276. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/feedback_extraction_main_v1.jsonl +10 -0
  277. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/profile_update_main_v1.jsonl +10 -0
  278. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_manager.py +280 -0
  279. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_schema.py +11 -0
  280. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +131 -0
  281. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_constants.py +60 -0
  282. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_service.py +228 -0
  283. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_utils.py +87 -0
  284. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluator.py +372 -0
  285. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/delayed_group_evaluator.py +156 -0
  286. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +340 -0
  287. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +471 -0
  288. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +1626 -0
  289. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/__init__.py +0 -0
  290. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_cron.py +196 -0
  291. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_encryption.py +101 -0
  292. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/client.py +167 -0
  293. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/service.py +281 -0
  294. package/plugin/vendor/reflexio/reflexio/server/services/configurator/base_configurator.py +179 -0
  295. package/plugin/vendor/reflexio/reflexio/server/services/configurator/config_storage.py +62 -0
  296. package/plugin/vendor/reflexio/reflexio/server/services/configurator/configurator.py +87 -0
  297. package/plugin/vendor/reflexio/reflexio/server/services/configurator/local_file_config_storage.py +187 -0
  298. package/plugin/vendor/reflexio/reflexio/server/services/configurator/test_config_storage.py +162 -0
  299. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +112 -0
  300. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/__init__.py +0 -0
  301. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/distribution.py +33 -0
  302. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/eval_sampler.py +126 -0
  303. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/group_aggregation.py +192 -0
  304. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/hero_state.py +75 -0
  305. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/rule_attribution.py +97 -0
  306. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +515 -0
  307. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/shadow_aggregation.py +90 -0
  308. package/plugin/vendor/reflexio/reflexio/server/services/extraction/__init__.py +0 -0
  309. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +91 -0
  310. package/plugin/vendor/reflexio/reflexio/server/services/extraction/invariants.py +303 -0
  311. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +25 -0
  312. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +351 -0
  313. package/plugin/vendor/reflexio/reflexio/server/services/extraction/plan.py +138 -0
  314. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +217 -0
  315. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +468 -0
  316. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +171 -0
  317. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +777 -0
  318. package/plugin/vendor/reflexio/reflexio/server/services/extraction/tools.py +1125 -0
  319. package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +91 -0
  320. package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +251 -0
  321. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +689 -0
  322. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +835 -0
  323. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +89 -0
  324. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_aggregator.py +1388 -0
  325. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +960 -0
  326. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_extractor.py +436 -0
  327. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_generation_service.py +808 -0
  328. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_constants.py +28 -0
  329. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_utils.py +362 -0
  330. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/__init__.py +24 -0
  331. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/assistant_webhook.py +246 -0
  332. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/gepa_adapter.py +291 -0
  333. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/judge.py +97 -0
  334. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/models.py +96 -0
  335. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +645 -0
  336. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/rollout.py +35 -0
  337. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scenario_resolver.py +93 -0
  338. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +174 -0
  339. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/__init__.py +26 -0
  340. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_document_expander.py +179 -0
  341. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +297 -0
  342. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +741 -0
  343. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_extractor.py +462 -0
  344. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +734 -0
  345. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service_utils.py +290 -0
  346. package/plugin/vendor/reflexio/reflexio/server/services/reflection/__init__.py +17 -0
  347. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_extractor.py +247 -0
  348. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +800 -0
  349. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service_utils.py +146 -0
  350. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/__init__.py +0 -0
  351. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +70 -0
  352. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
  353. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +671 -0
  354. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/__init__.py +1 -0
  355. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +184 -0
  356. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/outcome.py +81 -0
  357. package/plugin/vendor/reflexio/reflexio/server/services/storage/constants.py +2 -0
  358. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +11 -0
  359. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +154 -0
  360. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +155 -0
  361. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +59 -0
  362. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +1253 -0
  363. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +1945 -0
  364. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +600 -0
  365. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_operations.py +346 -0
  366. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1378 -0
  367. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +747 -0
  368. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +263 -0
  369. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +193 -0
  370. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +166 -0
  371. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_stall_state.py +217 -0
  372. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +153 -0
  373. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +372 -0
  374. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_base.py +71 -0
  375. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +235 -0
  376. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +170 -0
  377. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +677 -0
  378. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +250 -0
  379. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +154 -0
  380. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +130 -0
  381. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +93 -0
  382. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_stall_state.py +76 -0
  383. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +568 -0
  384. package/plugin/vendor/reflexio/reflexio/server/site_var/README.md +77 -0
  385. package/plugin/vendor/reflexio/reflexio/server/site_var/feature_flags.py +116 -0
  386. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_manager.py +263 -0
  387. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/feature_flags.json +13 -0
  388. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/llm_model_setting.json +7 -0
  389. package/plugin/vendor/reflexio/reflexio/server/tracing.py +158 -0
  390. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +113 -0
  391. package/plugin/vendor/reflexio/reflexio/server/uvicorn_logging.py +76 -0
  392. package/plugin/vendor/reflexio/reflexio/test_support/__init__.py +1 -0
  393. package/plugin/vendor/reflexio/reflexio/test_support/llm_fixtures.py +62 -0
  394. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +242 -0
  395. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +129 -0
  396. package/plugin/vendor/reflexio/reflexio/test_support/skip_decorators.py +43 -0
@@ -0,0 +1,2714 @@
1
+ import asyncio
2
+ import inspect
3
+ import logging
4
+ import threading
5
+ from collections.abc import Callable
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from fastapi import (
10
+ APIRouter,
11
+ BackgroundTasks,
12
+ Depends,
13
+ FastAPI,
14
+ HTTPException,
15
+ Request,
16
+ status,
17
+ )
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from slowapi import Limiter, _rate_limit_exceeded_handler
20
+ from slowapi.errors import RateLimitExceeded
21
+ from slowapi.util import get_remote_address
22
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
23
+ from starlette.responses import Response
24
+
25
+ from reflexio.models.api_schema.braintrust_schema import (
26
+ BraintrustStatusResponse,
27
+ ConnectBraintrustRequest,
28
+ ConnectBraintrustResponse,
29
+ SelectProjectsRequest,
30
+ SelectProjectsResponse,
31
+ SyncBraintrustResponse,
32
+ )
33
+ from reflexio.models.api_schema.eval_overview_schema import (
34
+ GetEvaluationOverviewRequest,
35
+ GetEvaluationOverviewResponse,
36
+ GetRecentShadowComparisonsResponse,
37
+ GradeOnDemandRequest,
38
+ GradeOnDemandResponse,
39
+ RegenerateFailure,
40
+ RegenerateRequest,
41
+ RegenerateStartResponse,
42
+ RegenerateStatusResponse,
43
+ )
44
+ from reflexio.models.api_schema.retriever_schema import (
45
+ GetAgentPlaybooksRequest,
46
+ GetAgentPlaybooksViewResponse,
47
+ GetAgentSuccessEvaluationResultsRequest,
48
+ GetDashboardStatsRequest,
49
+ GetDashboardStatsResponse,
50
+ GetEvaluationResultsViewResponse,
51
+ GetInteractionsRequest,
52
+ GetInteractionsViewResponse,
53
+ GetPlaybookApplicationStatsRequest,
54
+ GetPlaybookApplicationStatsResponse,
55
+ GetProfileStatisticsResponse,
56
+ GetProfilesViewResponse,
57
+ GetRequestsRequest,
58
+ GetRequestsViewResponse,
59
+ GetUserPlaybooksRequest,
60
+ GetUserPlaybooksViewResponse,
61
+ GetUserProfilesRequest,
62
+ ProfileChangeLogViewResponse,
63
+ RequestDataView,
64
+ RerankUserProfilesRequest,
65
+ SearchAgentPlaybookRequest,
66
+ SearchAgentPlaybooksViewResponse,
67
+ SearchInteractionRequest,
68
+ SearchInteractionsViewResponse,
69
+ SearchProfilesViewResponse,
70
+ SearchUserPlaybookRequest,
71
+ SearchUserPlaybooksViewResponse,
72
+ SearchUserProfileRequest,
73
+ SessionView,
74
+ SetConfigResponse,
75
+ StorageStatsRequest,
76
+ StorageStatsResponse,
77
+ UnifiedSearchRequest,
78
+ UnifiedSearchViewResponse,
79
+ UpdateAgentPlaybookRequest,
80
+ UpdateAgentPlaybookResponse,
81
+ UpdatePlaybookStatusRequest,
82
+ UpdatePlaybookStatusResponse,
83
+ UpdateUserPlaybookRequest,
84
+ UpdateUserPlaybookResponse,
85
+ UpdateUserProfileRequest,
86
+ UpdateUserProfileResponse,
87
+ )
88
+ from reflexio.models.api_schema.service_schemas import (
89
+ AddAgentPlaybookRequest,
90
+ AddAgentPlaybookResponse,
91
+ AddUserPlaybookRequest,
92
+ AddUserPlaybookResponse,
93
+ AddUserProfileRequest,
94
+ AddUserProfileResponse,
95
+ AdminInvalidateCacheRequest,
96
+ AdminInvalidateCacheResponse,
97
+ BulkDeleteResponse,
98
+ CancelOperationRequest,
99
+ CancelOperationResponse,
100
+ ClearUserDataRequest,
101
+ ClearUserDataResponse,
102
+ DeleteAgentPlaybookRequest,
103
+ DeleteAgentPlaybookResponse,
104
+ DeleteAgentPlaybooksByIdsRequest,
105
+ DeleteProfilesByIdsRequest,
106
+ DeleteRequestRequest,
107
+ DeleteRequestResponse,
108
+ DeleteRequestsByIdsRequest,
109
+ DeleteSessionRequest,
110
+ DeleteSessionResponse,
111
+ DeleteUserInteractionRequest,
112
+ DeleteUserInteractionResponse,
113
+ DeleteUserPlaybookRequest,
114
+ DeleteUserPlaybookResponse,
115
+ DeleteUserPlaybooksByIdsRequest,
116
+ DeleteUserProfileRequest,
117
+ DeleteUserProfileResponse,
118
+ DowngradeProfilesRequest,
119
+ DowngradeProfilesResponse,
120
+ DowngradeUserPlaybooksRequest,
121
+ DowngradeUserPlaybooksResponse,
122
+ GetOperationStatusRequest,
123
+ GetOperationStatusResponse,
124
+ ManualPlaybookGenerationRequest,
125
+ ManualPlaybookGenerationResponse,
126
+ ManualProfileGenerationRequest,
127
+ ManualProfileGenerationResponse,
128
+ MyConfigResponse,
129
+ PlaybookAggregationChangeLogResponse,
130
+ PublishUserInteractionRequest,
131
+ PublishUserInteractionResponse,
132
+ RerunPlaybookGenerationRequest,
133
+ RerunPlaybookGenerationResponse,
134
+ RerunProfileGenerationRequest,
135
+ RerunProfileGenerationResponse,
136
+ RunPlaybookAggregationRequest,
137
+ RunPlaybookAggregationResponse,
138
+ Status,
139
+ UpgradeProfilesRequest,
140
+ UpgradeProfilesResponse,
141
+ UpgradeUserPlaybooksRequest,
142
+ UpgradeUserPlaybooksResponse,
143
+ WhoamiResponse,
144
+ )
145
+ from reflexio.models.api_schema.ui.converters import (
146
+ to_agent_playbook_view,
147
+ to_evaluation_result_view,
148
+ to_interaction_view,
149
+ to_profile_change_log_view,
150
+ to_profile_view,
151
+ to_user_playbook_view,
152
+ )
153
+ from reflexio.models.config_schema import (
154
+ SINGLETON_AGENT_SUCCESS_EVALUATION_NAME,
155
+ Config,
156
+ )
157
+ from reflexio.server._auth import DEFAULT_ORG_ID, default_get_org_id
158
+ from reflexio.server.api_endpoints import (
159
+ account_api,
160
+ health_api,
161
+ pending_tool_call_api,
162
+ publisher_api,
163
+ stall_state_api,
164
+ )
165
+ from reflexio.server.cache.reflexio_cache import (
166
+ get_reflexio,
167
+ invalidate_reflexio_cache,
168
+ )
169
+ from reflexio.server.correlation import correlation_id_var, generate_correlation_id
170
+ from reflexio.server.operation_limiter import (
171
+ OperationName,
172
+ limiter_http_exception,
173
+ run_with_operation_limit,
174
+ )
175
+ from reflexio.server.services.agent_success_evaluation.group_evaluation_runner import (
176
+ run_group_evaluation,
177
+ )
178
+ from reflexio.server.services.agent_success_evaluation.regen_jobs import (
179
+ REGEN_JOBS,
180
+ run_regen,
181
+ )
182
+
183
+ logger = logging.getLogger(__name__)
184
+
185
+ # Re-exported for backwards compatibility — callers that did
186
+ # ``from reflexio.server.api import default_get_org_id`` or ``DEFAULT_ORG_ID``
187
+ # continue to work.
188
+ __all__ = ["DEFAULT_ORG_ID", "create_app", "default_get_org_id"]
189
+
190
+ # Bot protection configuration
191
+ REQUEST_TIMEOUT_SECONDS = 60
192
+ SYNC_REQUEST_TIMEOUT_SECONDS = (
193
+ 600 # Longer timeout for synchronous processing (wait_for_response=true)
194
+ )
195
+ SUSPICIOUS_USER_AGENTS = ["bot", "crawler", "spider", "scraper", "curl", "wget"]
196
+ ALLOWED_EMPTY_UA_PATHS = ["/health", "/"] # Paths that allow empty user agents
197
+
198
+
199
+ def get_rate_limit_key(request: Request) -> str:
200
+ """Get rate limit key based on IP address.
201
+
202
+ Args:
203
+ request (Request): The incoming request
204
+
205
+ Returns:
206
+ str: Rate limit key (IP address)
207
+ """
208
+ return get_remote_address(request)
209
+
210
+
211
+ # Initialize rate limiter
212
+ limiter = Limiter(key_func=get_rate_limit_key)
213
+
214
+
215
+ def _run_limited_api[T](
216
+ org_id: str,
217
+ operation: OperationName,
218
+ fn: Callable[[], T],
219
+ ) -> T:
220
+ try:
221
+ return run_with_operation_limit(
222
+ org_id=org_id,
223
+ operation=operation,
224
+ fn=fn,
225
+ )
226
+ except TimeoutError as exc:
227
+ http_exc = limiter_http_exception(operation)
228
+ raise http_exc from exc
229
+
230
+
231
+ def configure_rate_limiter(key_func: Callable[..., str]) -> None:
232
+ """
233
+ Replace the rate limiter's key function.
234
+
235
+ This is the supported way to override the default IP-based key function
236
+ (e.g. with an org-scoped or token-scoped variant in the enterprise layer).
237
+
238
+ Args:
239
+ key_func: A callable that accepts a Request and returns a string key.
240
+ """
241
+ limiter._key_func = key_func # type: ignore[reportAttributeAccessIssue]
242
+
243
+
244
+ class BotProtectionMiddleware(BaseHTTPMiddleware):
245
+ """Middleware to detect and block suspicious bot-like requests."""
246
+
247
+ async def dispatch(
248
+ self, request: Request, call_next: RequestResponseEndpoint
249
+ ) -> Response:
250
+ """Process request and block suspicious patterns.
251
+
252
+ Args:
253
+ request (Request): The incoming request
254
+ call_next (RequestResponseEndpoint): Next middleware/handler in chain
255
+
256
+ Returns:
257
+ Response: The response from the next handler or a 403 JSON response
258
+ """
259
+ from starlette.responses import JSONResponse
260
+
261
+ user_agent = request.headers.get("user-agent", "").lower()
262
+ path = request.url.path
263
+
264
+ # Allow health check and root without user agent
265
+ if path not in ALLOWED_EMPTY_UA_PATHS:
266
+ # Block requests with no user agent
267
+ if not user_agent:
268
+ return JSONResponse(
269
+ status_code=status.HTTP_403_FORBIDDEN,
270
+ content={"detail": "Forbidden: Missing user agent"},
271
+ )
272
+
273
+ # Block requests with suspicious user agents
274
+ for suspicious in SUSPICIOUS_USER_AGENTS:
275
+ if suspicious in user_agent:
276
+ return JSONResponse(
277
+ status_code=status.HTTP_403_FORBIDDEN,
278
+ content={"detail": "Forbidden: Suspicious user agent"},
279
+ )
280
+
281
+ return await call_next(request)
282
+
283
+
284
+ class TimeoutMiddleware(BaseHTTPMiddleware):
285
+ """Middleware to enforce request timeout."""
286
+
287
+ async def dispatch(
288
+ self, request: Request, call_next: RequestResponseEndpoint
289
+ ) -> Response:
290
+ """Process request with timeout enforcement.
291
+
292
+ Args:
293
+ request (Request): The incoming request
294
+ call_next (RequestResponseEndpoint): Next middleware/handler in chain
295
+
296
+ Returns:
297
+ Response: The response from the next handler or a 504 JSON response
298
+ """
299
+ from starlette.responses import JSONResponse
300
+
301
+ # Use longer timeout for synchronous processing requests
302
+ timeout = REQUEST_TIMEOUT_SECONDS
303
+ if request.query_params.get("wait_for_response", "").lower() == "true":
304
+ timeout = SYNC_REQUEST_TIMEOUT_SECONDS
305
+
306
+ try:
307
+ return await asyncio.wait_for(call_next(request), timeout=timeout)
308
+ except TimeoutError:
309
+ return JSONResponse(
310
+ status_code=status.HTTP_504_GATEWAY_TIMEOUT,
311
+ content={"detail": "Request timeout"},
312
+ )
313
+
314
+
315
+ class CorrelationIdMiddleware(BaseHTTPMiddleware):
316
+ """Middleware that assigns a unique correlation ID to each request.
317
+
318
+ The ID is stored in a ContextVar so it propagates to log records
319
+ (via CorrelationIdFilter) and to ThreadPoolExecutor workers when
320
+ ``contextvars.copy_context()`` is used.
321
+ """
322
+
323
+ async def dispatch(
324
+ self, request: Request, call_next: RequestResponseEndpoint
325
+ ) -> Response:
326
+ cid = generate_correlation_id()
327
+ correlation_id_var.set(cid)
328
+ response = await call_next(request)
329
+ response.headers["X-Correlation-ID"] = cid
330
+ return response
331
+
332
+
333
+ core_router = APIRouter()
334
+
335
+
336
+ @core_router.get("/")
337
+ def root() -> dict[str, str]:
338
+ return {
339
+ "service": "Reflexio API",
340
+ "docs": "/docs",
341
+ "health": "/health",
342
+ }
343
+
344
+
345
+ @core_router.get("/health")
346
+ def health_check() -> dict[str, str]:
347
+ """Health check endpoint for ECS/container orchestration."""
348
+ return {"status": "healthy"}
349
+
350
+
351
+ @core_router.get(
352
+ "/api/whoami",
353
+ response_model=WhoamiResponse,
354
+ response_model_exclude_none=True,
355
+ )
356
+ def whoami_endpoint(
357
+ org_id: str = Depends(default_get_org_id),
358
+ ) -> WhoamiResponse:
359
+ """Return the caller's org and masked storage routing.
360
+
361
+ Powers ``reflexio status``. Safe to call unauthenticated in
362
+ self-host mode; the enterprise server wraps this in Bearer auth.
363
+ """
364
+ return account_api.whoami(org_id=org_id)
365
+
366
+
367
+ @core_router.get(
368
+ "/api/my_config",
369
+ response_model=MyConfigResponse,
370
+ response_model_exclude_none=True,
371
+ )
372
+ def my_config_endpoint(
373
+ request: Request,
374
+ org_id: str = Depends(default_get_org_id),
375
+ ) -> MyConfigResponse:
376
+ """Return raw storage credentials for the caller's org.
377
+
378
+ Enablement is controlled by two independent opt-ins so the endpoint
379
+ is closed by default on unauthenticated self-host deployments:
380
+
381
+ - ``request.app.state.my_config_enabled`` — set to True by
382
+ :func:`create_app` when the host wires in a Bearer-auth
383
+ ``get_org_id`` dependency, so enterprise callers are always
384
+ authenticated before they reach this route.
385
+ - ``REFLEXIO_ALLOW_MY_CONFIG=true`` — OS self-host escape hatch.
386
+
387
+ If neither is set we return a closed response instead of a 404 so
388
+ the CLI can display an actionable hint.
389
+ """
390
+ app_state_enabled = bool(getattr(request.app.state, "my_config_enabled", False))
391
+ if not (app_state_enabled or account_api.my_config_allowed()):
392
+ return MyConfigResponse(
393
+ success=False,
394
+ message=(
395
+ "GET /api/my_config is disabled. Set "
396
+ "REFLEXIO_ALLOW_MY_CONFIG=true to enable."
397
+ ),
398
+ )
399
+ return account_api.my_config(org_id=org_id)
400
+
401
+
402
+ @core_router.post(
403
+ "/api/publish_interaction",
404
+ response_model=PublishUserInteractionResponse,
405
+ response_model_exclude_none=True,
406
+ )
407
+ @limiter.limit("60/minute") # Rate limit for write operations
408
+ def publish_user_interaction(
409
+ request: Request,
410
+ payload: PublishUserInteractionRequest,
411
+ background_tasks: BackgroundTasks,
412
+ org_id: str = Depends(default_get_org_id),
413
+ wait_for_response: bool = False,
414
+ ) -> PublishUserInteractionResponse:
415
+ if wait_for_response:
416
+ # Process synchronously so the caller gets the real result
417
+ return _run_limited_api(
418
+ org_id,
419
+ "publish",
420
+ lambda: publisher_api.add_user_interaction(org_id=org_id, request=payload),
421
+ )
422
+
423
+ def _limited_publish_task() -> None:
424
+ try:
425
+ run_with_operation_limit(
426
+ org_id=org_id,
427
+ operation="publish",
428
+ fn=lambda: publisher_api.add_user_interaction(
429
+ org_id=org_id, request=payload
430
+ ),
431
+ )
432
+ except TimeoutError:
433
+ logger.warning(
434
+ "Dropped queued publish for org %s because publish limiter is saturated",
435
+ org_id,
436
+ )
437
+
438
+ # Run in background — caller gets immediate acknowledgement
439
+ background_tasks.add_task(_limited_publish_task)
440
+ return PublishUserInteractionResponse(
441
+ success=True, message="Interaction queued for processing"
442
+ )
443
+
444
+
445
+ @core_router.post(
446
+ "/api/add_user_playbook",
447
+ response_model=AddUserPlaybookResponse,
448
+ response_model_exclude_none=True,
449
+ )
450
+ @limiter.limit("60/minute") # Rate limit for write operations
451
+ def add_user_playbook_endpoint(
452
+ request: Request,
453
+ payload: AddUserPlaybookRequest,
454
+ org_id: str = Depends(default_get_org_id),
455
+ ) -> AddUserPlaybookResponse:
456
+ """Add user playbook directly to storage.
457
+
458
+ Args:
459
+ request (Request): The HTTP request object (for rate limiting)
460
+ payload (AddUserPlaybookRequest): The request containing user playbooks
461
+ org_id (str): Organization ID
462
+
463
+ Returns:
464
+ AddUserPlaybookResponse: Response containing success status, message, and added count
465
+ """
466
+ return publisher_api.add_user_playbook(org_id=org_id, request=payload)
467
+
468
+
469
+ @core_router.post(
470
+ "/api/add_agent_playbook",
471
+ response_model=AddAgentPlaybookResponse,
472
+ response_model_exclude_none=True,
473
+ )
474
+ @limiter.limit("60/minute") # Rate limit for write operations
475
+ def add_agent_playbook_endpoint(
476
+ request: Request,
477
+ payload: AddAgentPlaybookRequest,
478
+ org_id: str = Depends(default_get_org_id),
479
+ ) -> AddAgentPlaybookResponse:
480
+ """Add agent playbook directly to storage.
481
+
482
+ Args:
483
+ request (Request): The HTTP request object (for rate limiting)
484
+ payload (AddAgentPlaybookRequest): The request containing agent playbooks
485
+ org_id (str): Organization ID
486
+
487
+ Returns:
488
+ AddAgentPlaybookResponse: Response containing success status, message, and added count
489
+ """
490
+ return publisher_api.add_agent_playbook(org_id=org_id, request=payload)
491
+
492
+
493
+ @core_router.post(
494
+ "/api/add_user_profile",
495
+ response_model=AddUserProfileResponse,
496
+ response_model_exclude_none=True,
497
+ )
498
+ @limiter.limit("60/minute") # Rate limit for write operations
499
+ def add_user_profile_endpoint(
500
+ request: Request,
501
+ payload: AddUserProfileRequest,
502
+ org_id: str = Depends(default_get_org_id),
503
+ ) -> AddUserProfileResponse:
504
+ """Add user profile directly to storage, bypassing inference.
505
+
506
+ Args:
507
+ request (Request): The HTTP request object (for rate limiting)
508
+ payload (AddUserProfileRequest): The request containing user profiles
509
+ org_id (str): Organization ID
510
+
511
+ Returns:
512
+ AddUserProfileResponse: Response containing success status, message, and added count
513
+ """
514
+ return publisher_api.add_user_profile(org_id=org_id, request=payload)
515
+
516
+
517
+ @core_router.post(
518
+ "/api/search_profiles",
519
+ response_model=SearchProfilesViewResponse,
520
+ response_model_exclude_none=True,
521
+ )
522
+ @limiter.limit("120/minute") # Rate limit for read operations
523
+ def search_user_profiles(
524
+ request: Request,
525
+ payload: SearchUserProfileRequest,
526
+ org_id: str = Depends(default_get_org_id),
527
+ ) -> SearchProfilesViewResponse:
528
+ response = _run_limited_api(
529
+ org_id,
530
+ "search",
531
+ lambda: get_reflexio(org_id=org_id).search_user_profiles(payload),
532
+ )
533
+ return SearchProfilesViewResponse(
534
+ success=response.success,
535
+ user_profiles=[to_profile_view(p) for p in response.user_profiles],
536
+ msg=response.msg,
537
+ )
538
+
539
+
540
+ @core_router.post(
541
+ "/api/rerank_user_profiles",
542
+ response_model=SearchProfilesViewResponse,
543
+ response_model_exclude_none=True,
544
+ )
545
+ @limiter.limit("120/minute") # Rate limit for read operations
546
+ def rerank_user_profiles(
547
+ request: Request,
548
+ payload: RerankUserProfilesRequest,
549
+ org_id: str = Depends(default_get_org_id),
550
+ ) -> SearchProfilesViewResponse:
551
+ """Rerank a list of profile ids by query relevance using a cross-encoder.
552
+
553
+ Args:
554
+ request (Request): The HTTP request object (for rate limiting)
555
+ payload (RerankUserProfilesRequest): The rerank request
556
+ org_id (str): Organization ID
557
+
558
+ Returns:
559
+ SearchProfilesViewResponse: Reranked profiles, top_k entries.
560
+ """
561
+ response = _run_limited_api(
562
+ org_id,
563
+ "search",
564
+ lambda: get_reflexio(org_id=org_id).rerank_user_profiles(payload),
565
+ )
566
+ return SearchProfilesViewResponse(
567
+ success=response.success,
568
+ user_profiles=[to_profile_view(p) for p in response.user_profiles],
569
+ msg=response.msg,
570
+ )
571
+
572
+
573
+ @core_router.get(
574
+ "/api/storage_stats",
575
+ response_model=StorageStatsResponse,
576
+ response_model_exclude_none=True,
577
+ )
578
+ @limiter.limit("120/minute") # Rate limit for read operations
579
+ def storage_stats(
580
+ request: Request,
581
+ user_id: str,
582
+ org_id: str = Depends(default_get_org_id),
583
+ ) -> StorageStatsResponse:
584
+ """Return lightweight metadata about a user's profiles and playbooks.
585
+
586
+ Args:
587
+ request (Request): The HTTP request object (for rate limiting)
588
+ user_id (str): Target user id, passed as a query parameter so this is
589
+ a cacheable, idempotent GET.
590
+ org_id (str): Organization ID
591
+
592
+ Returns:
593
+ StorageStatsResponse: Counts and timestamp range for the user.
594
+ """
595
+ return _run_limited_api(
596
+ org_id,
597
+ "search",
598
+ lambda: get_reflexio(org_id=org_id).storage_stats(
599
+ StorageStatsRequest(user_id=user_id)
600
+ ),
601
+ )
602
+
603
+
604
+ @core_router.post(
605
+ "/api/search_interactions",
606
+ response_model=SearchInteractionsViewResponse,
607
+ response_model_exclude_none=True,
608
+ )
609
+ @limiter.limit("120/minute") # Rate limit for read operations
610
+ def search_interactions(
611
+ request: Request,
612
+ payload: SearchInteractionRequest,
613
+ org_id: str = Depends(default_get_org_id),
614
+ ) -> SearchInteractionsViewResponse:
615
+ response = _run_limited_api(
616
+ org_id,
617
+ "search",
618
+ lambda: get_reflexio(org_id=org_id).search_interactions(payload),
619
+ )
620
+ return SearchInteractionsViewResponse(
621
+ success=response.success,
622
+ interactions=[to_interaction_view(i) for i in response.interactions],
623
+ msg=response.msg,
624
+ )
625
+
626
+
627
+ @core_router.post(
628
+ "/api/search_user_playbooks",
629
+ response_model=SearchUserPlaybooksViewResponse,
630
+ response_model_exclude_none=True,
631
+ )
632
+ @limiter.limit("120/minute") # Rate limit for read operations
633
+ def search_user_playbooks_endpoint(
634
+ request: Request,
635
+ payload: SearchUserPlaybookRequest,
636
+ org_id: str = Depends(default_get_org_id),
637
+ ) -> SearchUserPlaybooksViewResponse:
638
+ """Search user playbooks with semantic search and advanced filtering.
639
+
640
+ Supports filtering by user_id (via request_id linkage), agent_version,
641
+ playbook_name, datetime range, and status.
642
+
643
+ Args:
644
+ request (Request): The HTTP request object (for rate limiting)
645
+ payload (SearchUserPlaybookRequest): The search request
646
+ org_id (str): Organization ID
647
+
648
+ Returns:
649
+ SearchUserPlaybooksViewResponse: Response containing matching user playbooks
650
+ """
651
+ response = _run_limited_api(
652
+ org_id,
653
+ "search",
654
+ lambda: get_reflexio(org_id=org_id).search_user_playbooks(payload),
655
+ )
656
+ return SearchUserPlaybooksViewResponse(
657
+ success=response.success,
658
+ user_playbooks=[to_user_playbook_view(rf) for rf in response.user_playbooks],
659
+ msg=response.msg,
660
+ )
661
+
662
+
663
+ @core_router.post(
664
+ "/api/search_agent_playbooks",
665
+ response_model=SearchAgentPlaybooksViewResponse,
666
+ response_model_exclude_none=True,
667
+ )
668
+ @limiter.limit("120/minute") # Rate limit for read operations
669
+ def search_agent_playbooks_endpoint(
670
+ request: Request,
671
+ payload: SearchAgentPlaybookRequest,
672
+ org_id: str = Depends(default_get_org_id),
673
+ ) -> SearchAgentPlaybooksViewResponse:
674
+ """Search agent playbooks with semantic search and advanced filtering.
675
+
676
+ Supports filtering by agent_version, playbook_name, datetime range,
677
+ status_filter, and playbook_status_filter.
678
+
679
+ Args:
680
+ request (Request): The HTTP request object (for rate limiting)
681
+ payload (SearchAgentPlaybookRequest): The search request
682
+ org_id (str): Organization ID
683
+
684
+ Returns:
685
+ SearchAgentPlaybooksViewResponse: Response containing matching agent playbooks
686
+ """
687
+ response = _run_limited_api(
688
+ org_id,
689
+ "search",
690
+ lambda: get_reflexio(org_id=org_id).search_agent_playbooks(payload),
691
+ )
692
+ return SearchAgentPlaybooksViewResponse(
693
+ success=response.success,
694
+ agent_playbooks=[to_agent_playbook_view(fb) for fb in response.agent_playbooks],
695
+ msg=response.msg,
696
+ )
697
+
698
+
699
+ @core_router.post(
700
+ "/api/search",
701
+ response_model=UnifiedSearchViewResponse,
702
+ response_model_exclude_none=True,
703
+ )
704
+ @limiter.limit("120/minute")
705
+ def unified_search_endpoint(
706
+ request: Request,
707
+ payload: UnifiedSearchRequest,
708
+ org_id: str = Depends(default_get_org_id),
709
+ ) -> UnifiedSearchViewResponse:
710
+ """Search across all entity types (profiles, agent playbooks, user playbooks).
711
+
712
+ Runs query rewriting and embedding generation in parallel, then searches
713
+ all entity types in parallel. Query rewriting is gated behind the
714
+ enable_reformulation request param.
715
+
716
+ Args:
717
+ request (Request): The HTTP request object (for rate limiting)
718
+ payload (UnifiedSearchRequest): The unified search request
719
+ org_id (str): Organization ID
720
+
721
+ Returns:
722
+ UnifiedSearchViewResponse: Combined search results
723
+ """
724
+ response = _run_limited_api(
725
+ org_id,
726
+ "search",
727
+ lambda: get_reflexio(org_id=org_id).unified_search(payload, org_id=org_id),
728
+ )
729
+ return UnifiedSearchViewResponse(
730
+ success=response.success,
731
+ profiles=[to_profile_view(p) for p in response.profiles],
732
+ agent_playbooks=[to_agent_playbook_view(fb) for fb in response.agent_playbooks],
733
+ user_playbooks=[to_user_playbook_view(rf) for rf in response.user_playbooks],
734
+ reformulated_query=response.reformulated_query,
735
+ msg=response.msg,
736
+ agent_trace=response.agent_trace,
737
+ rehydrated_text=response.rehydrated_text,
738
+ )
739
+
740
+
741
+ @core_router.get("/api/profile_change_log", response_model=ProfileChangeLogViewResponse)
742
+ def get_profile_change_log(
743
+ org_id: str = Depends(default_get_org_id),
744
+ ) -> ProfileChangeLogViewResponse:
745
+ response = get_reflexio(org_id=org_id).get_profile_change_logs()
746
+ return ProfileChangeLogViewResponse(
747
+ success=response.success,
748
+ profile_change_logs=[
749
+ to_profile_change_log_view(log) for log in response.profile_change_logs
750
+ ],
751
+ )
752
+
753
+
754
+ @core_router.get(
755
+ "/api/playbook_aggregation_change_logs",
756
+ response_model=PlaybookAggregationChangeLogResponse,
757
+ )
758
+ def get_playbook_aggregation_change_logs(
759
+ playbook_name: str,
760
+ agent_version: str,
761
+ org_id: str = Depends(default_get_org_id),
762
+ ) -> PlaybookAggregationChangeLogResponse:
763
+ return get_reflexio(org_id=org_id).get_playbook_aggregation_change_logs(
764
+ playbook_name=playbook_name,
765
+ agent_version=agent_version,
766
+ )
767
+
768
+
769
+ @core_router.delete(
770
+ "/api/delete_profile",
771
+ response_model=DeleteUserProfileResponse,
772
+ response_model_exclude_none=True,
773
+ )
774
+ def delete_profile(
775
+ request: DeleteUserProfileRequest,
776
+ org_id: str = Depends(default_get_org_id),
777
+ ) -> DeleteUserProfileResponse:
778
+ return publisher_api.delete_user_profile(org_id=org_id, request=request)
779
+
780
+
781
+ @core_router.delete(
782
+ "/api/delete_interaction",
783
+ response_model=DeleteUserInteractionResponse,
784
+ response_model_exclude_none=True,
785
+ )
786
+ def delete_interaction(
787
+ request: DeleteUserInteractionRequest,
788
+ org_id: str = Depends(default_get_org_id),
789
+ ) -> DeleteUserInteractionResponse:
790
+ return publisher_api.delete_user_interaction(org_id=org_id, request=request)
791
+
792
+
793
+ @core_router.delete(
794
+ "/api/delete_request",
795
+ response_model=DeleteRequestResponse,
796
+ response_model_exclude_none=True,
797
+ )
798
+ def delete_request(
799
+ request: DeleteRequestRequest,
800
+ org_id: str = Depends(default_get_org_id),
801
+ ) -> DeleteRequestResponse:
802
+ return publisher_api.delete_request(org_id=org_id, request=request)
803
+
804
+
805
+ @core_router.delete(
806
+ "/api/delete_session",
807
+ response_model=DeleteSessionResponse,
808
+ response_model_exclude_none=True,
809
+ )
810
+ def delete_session(
811
+ request: DeleteSessionRequest,
812
+ org_id: str = Depends(default_get_org_id),
813
+ ) -> DeleteSessionResponse:
814
+ return publisher_api.delete_session(org_id=org_id, request=request)
815
+
816
+
817
+ @core_router.delete(
818
+ "/api/delete_agent_playbook",
819
+ response_model=DeleteAgentPlaybookResponse,
820
+ response_model_exclude_none=True,
821
+ )
822
+ def delete_agent_playbook(
823
+ request: DeleteAgentPlaybookRequest,
824
+ org_id: str = Depends(default_get_org_id),
825
+ ) -> DeleteAgentPlaybookResponse:
826
+ return publisher_api.delete_agent_playbook(org_id=org_id, request=request)
827
+
828
+
829
+ @core_router.delete(
830
+ "/api/delete_user_playbook",
831
+ response_model=DeleteUserPlaybookResponse,
832
+ response_model_exclude_none=True,
833
+ )
834
+ def delete_user_playbook(
835
+ request: DeleteUserPlaybookRequest,
836
+ org_id: str = Depends(default_get_org_id),
837
+ ) -> DeleteUserPlaybookResponse:
838
+ return publisher_api.delete_user_playbook(org_id=org_id, request=request)
839
+
840
+
841
+ @core_router.delete(
842
+ "/api/delete_requests_by_ids",
843
+ response_model=BulkDeleteResponse,
844
+ response_model_exclude_none=True,
845
+ )
846
+ def delete_requests_by_ids(
847
+ request: DeleteRequestsByIdsRequest,
848
+ org_id: str = Depends(default_get_org_id),
849
+ ) -> BulkDeleteResponse:
850
+ """Delete multiple requests by their IDs.
851
+
852
+ Args:
853
+ request (DeleteRequestsByIdsRequest): Request containing list of request IDs to delete
854
+ org_id (str): Organization ID
855
+
856
+ Returns:
857
+ BulkDeleteResponse: Response containing success status and deleted count
858
+ """
859
+ return publisher_api.delete_requests_by_ids(org_id=org_id, request=request)
860
+
861
+
862
+ @core_router.delete(
863
+ "/api/delete_profiles_by_ids",
864
+ response_model=BulkDeleteResponse,
865
+ response_model_exclude_none=True,
866
+ )
867
+ def delete_profiles_by_ids(
868
+ request: DeleteProfilesByIdsRequest,
869
+ org_id: str = Depends(default_get_org_id),
870
+ ) -> BulkDeleteResponse:
871
+ """Delete multiple profiles by their IDs.
872
+
873
+ Args:
874
+ request (DeleteProfilesByIdsRequest): Request containing list of profile IDs to delete
875
+ org_id (str): Organization ID
876
+
877
+ Returns:
878
+ BulkDeleteResponse: Response containing success status and deleted count
879
+ """
880
+ return publisher_api.delete_profiles_by_ids(org_id=org_id, request=request)
881
+
882
+
883
+ @core_router.delete(
884
+ "/api/delete_agent_playbooks_by_ids",
885
+ response_model=BulkDeleteResponse,
886
+ response_model_exclude_none=True,
887
+ )
888
+ def delete_agent_playbooks_by_ids(
889
+ request: DeleteAgentPlaybooksByIdsRequest,
890
+ org_id: str = Depends(default_get_org_id),
891
+ ) -> BulkDeleteResponse:
892
+ """Delete multiple agent playbooks by their IDs.
893
+
894
+ Args:
895
+ request (DeleteAgentPlaybooksByIdsRequest): Request containing list of agent playbook IDs to delete
896
+ org_id (str): Organization ID
897
+
898
+ Returns:
899
+ BulkDeleteResponse: Response containing success status and deleted count
900
+ """
901
+ return publisher_api.delete_agent_playbooks_by_ids_bulk(
902
+ org_id=org_id, request=request
903
+ )
904
+
905
+
906
+ @core_router.delete(
907
+ "/api/delete_user_playbooks_by_ids",
908
+ response_model=BulkDeleteResponse,
909
+ response_model_exclude_none=True,
910
+ )
911
+ def delete_user_playbooks_by_ids(
912
+ request: DeleteUserPlaybooksByIdsRequest,
913
+ org_id: str = Depends(default_get_org_id),
914
+ ) -> BulkDeleteResponse:
915
+ """Delete multiple user playbooks by their IDs.
916
+
917
+ Args:
918
+ request (DeleteUserPlaybooksByIdsRequest): Request containing list of user playbook IDs to delete
919
+ org_id (str): Organization ID
920
+
921
+ Returns:
922
+ BulkDeleteResponse: Response containing success status and deleted count
923
+ """
924
+ return publisher_api.delete_user_playbooks_by_ids_bulk(
925
+ org_id=org_id, request=request
926
+ )
927
+
928
+
929
+ @core_router.delete(
930
+ "/api/delete_all_interactions",
931
+ response_model=BulkDeleteResponse,
932
+ response_model_exclude_none=True,
933
+ )
934
+ def delete_all_interactions(
935
+ org_id: str = Depends(default_get_org_id),
936
+ ) -> BulkDeleteResponse:
937
+ """Delete all requests and their associated interactions.
938
+
939
+ Args:
940
+ org_id (str): Organization ID
941
+
942
+ Returns:
943
+ BulkDeleteResponse: Response containing success status and deleted count
944
+ """
945
+ return publisher_api.delete_all_interactions_bulk(org_id=org_id)
946
+
947
+
948
+ @core_router.delete(
949
+ "/api/delete_all_profiles",
950
+ response_model=BulkDeleteResponse,
951
+ response_model_exclude_none=True,
952
+ )
953
+ def delete_all_profiles(
954
+ org_id: str = Depends(default_get_org_id),
955
+ ) -> BulkDeleteResponse:
956
+ """Delete all profiles.
957
+
958
+ Args:
959
+ org_id (str): Organization ID
960
+
961
+ Returns:
962
+ BulkDeleteResponse: Response containing success status and deleted count
963
+ """
964
+ return publisher_api.delete_all_profiles_bulk(org_id=org_id)
965
+
966
+
967
+ @core_router.delete(
968
+ "/api/delete_all_playbooks",
969
+ response_model=BulkDeleteResponse,
970
+ response_model_exclude_none=True,
971
+ )
972
+ def delete_all_playbooks(
973
+ org_id: str = Depends(default_get_org_id),
974
+ ) -> BulkDeleteResponse:
975
+ """Delete all playbooks (both user and agent).
976
+
977
+ Args:
978
+ org_id (str): Organization ID
979
+
980
+ Returns:
981
+ BulkDeleteResponse: Response containing success status and deleted count
982
+ """
983
+ return publisher_api.delete_all_playbooks_bulk(org_id=org_id)
984
+
985
+
986
+ @core_router.delete(
987
+ "/api/delete_all_user_playbooks",
988
+ response_model=BulkDeleteResponse,
989
+ response_model_exclude_none=True,
990
+ )
991
+ def delete_all_user_playbooks(
992
+ org_id: str = Depends(default_get_org_id),
993
+ ) -> BulkDeleteResponse:
994
+ """Delete all user playbooks (user only, not agent).
995
+
996
+ Args:
997
+ org_id (str): Organization ID
998
+
999
+ Returns:
1000
+ BulkDeleteResponse: Response containing success status and deleted count
1001
+ """
1002
+ return publisher_api.delete_all_user_playbooks_bulk(org_id=org_id)
1003
+
1004
+
1005
+ @core_router.delete(
1006
+ "/api/delete_all_agent_playbooks",
1007
+ response_model=BulkDeleteResponse,
1008
+ response_model_exclude_none=True,
1009
+ )
1010
+ def delete_all_agent_playbooks(
1011
+ org_id: str = Depends(default_get_org_id),
1012
+ ) -> BulkDeleteResponse:
1013
+ """Delete all agent playbooks (agent only, not user).
1014
+
1015
+ Args:
1016
+ org_id (str): Organization ID
1017
+
1018
+ Returns:
1019
+ BulkDeleteResponse: Response containing success status and deleted count
1020
+ """
1021
+ return publisher_api.delete_all_agent_playbooks_bulk(org_id=org_id)
1022
+
1023
+
1024
+ @core_router.post(
1025
+ "/api/clear_user_data",
1026
+ response_model=ClearUserDataResponse,
1027
+ response_model_exclude_none=True,
1028
+ )
1029
+ def clear_user_data(
1030
+ request: ClearUserDataRequest,
1031
+ org_id: str = Depends(default_get_org_id),
1032
+ ) -> ClearUserDataResponse:
1033
+ """Delete all rows scoped to a single ``user_id``.
1034
+
1035
+ Removes the user's interactions, user playbooks, profiles, and
1036
+ requests. Does NOT touch ``agent_playbooks`` — they are
1037
+ intentionally shared cross-project. Used by paired-protocol
1038
+ harnesses (e.g. SWE-bench) to isolate per-task data on a shared
1039
+ backend without one task's clear-all nuking another in-flight
1040
+ task's rows.
1041
+
1042
+ Args:
1043
+ request (ClearUserDataRequest): Request containing the target user_id
1044
+ org_id (str): Organization ID
1045
+
1046
+ Returns:
1047
+ ClearUserDataResponse: Response with per-entity deletion counts
1048
+ """
1049
+ return publisher_api.clear_user_data(org_id=org_id, request=request)
1050
+
1051
+
1052
+ @core_router.post(
1053
+ "/api/get_interactions",
1054
+ response_model=GetInteractionsViewResponse,
1055
+ response_model_exclude_none=True,
1056
+ )
1057
+ def get_interactions(
1058
+ request: GetInteractionsRequest,
1059
+ org_id: str = Depends(default_get_org_id),
1060
+ ) -> GetInteractionsViewResponse:
1061
+ response = get_reflexio(org_id=org_id).get_interactions(request)
1062
+ return GetInteractionsViewResponse(
1063
+ success=response.success,
1064
+ interactions=[to_interaction_view(i) for i in response.interactions],
1065
+ msg=response.msg,
1066
+ )
1067
+
1068
+
1069
+ @core_router.get(
1070
+ "/api/get_all_interactions",
1071
+ response_model=GetInteractionsViewResponse,
1072
+ response_model_exclude_none=True,
1073
+ )
1074
+ def get_all_interactions(
1075
+ limit: int = 100,
1076
+ org_id: str = Depends(default_get_org_id),
1077
+ ) -> GetInteractionsViewResponse:
1078
+ """Get all user interactions across all users.
1079
+
1080
+ Args:
1081
+ limit (int, optional): Maximum number of interactions to return. Defaults to 100.
1082
+ org_id (str): Organization ID
1083
+
1084
+ Returns:
1085
+ GetInteractionsViewResponse: Response containing all user interactions
1086
+ """
1087
+ reflexio = get_reflexio(org_id=org_id)
1088
+ response = reflexio.get_all_interactions(limit=limit)
1089
+ return GetInteractionsViewResponse(
1090
+ success=response.success,
1091
+ interactions=[to_interaction_view(i) for i in response.interactions],
1092
+ msg=response.msg,
1093
+ )
1094
+
1095
+
1096
+ @core_router.post(
1097
+ "/api/get_requests",
1098
+ response_model=GetRequestsViewResponse,
1099
+ response_model_exclude_none=True,
1100
+ )
1101
+ def get_requests_endpoint(
1102
+ request: GetRequestsRequest,
1103
+ org_id: str = Depends(default_get_org_id),
1104
+ ) -> GetRequestsViewResponse:
1105
+ """Get requests with their associated interactions.
1106
+
1107
+ Args:
1108
+ request (GetRequestsRequest): The get request
1109
+ org_id (str): Organization ID
1110
+
1111
+ Returns:
1112
+ GetRequestsViewResponse: Response containing requests with their interactions
1113
+ """
1114
+ internal_response = get_reflexio(org_id=org_id).get_requests(request)
1115
+ return GetRequestsViewResponse(
1116
+ success=internal_response.success,
1117
+ sessions=[
1118
+ SessionView(
1119
+ session_id=s.session_id,
1120
+ requests=[
1121
+ RequestDataView(
1122
+ request=rd.request,
1123
+ interactions=[to_interaction_view(i) for i in rd.interactions],
1124
+ )
1125
+ for rd in s.requests
1126
+ ],
1127
+ )
1128
+ for s in internal_response.sessions
1129
+ ],
1130
+ has_more=internal_response.has_more,
1131
+ msg=internal_response.msg,
1132
+ )
1133
+
1134
+
1135
+ @core_router.post(
1136
+ "/api/get_profiles",
1137
+ response_model=GetProfilesViewResponse,
1138
+ response_model_exclude_none=True,
1139
+ )
1140
+ def get_profiles(
1141
+ request: GetUserProfilesRequest,
1142
+ org_id: str = Depends(default_get_org_id),
1143
+ ) -> GetProfilesViewResponse:
1144
+ response = get_reflexio(org_id=org_id).get_profiles(request)
1145
+ return GetProfilesViewResponse(
1146
+ success=response.success,
1147
+ user_profiles=[to_profile_view(p) for p in response.user_profiles],
1148
+ msg=response.msg,
1149
+ )
1150
+
1151
+
1152
+ @core_router.get(
1153
+ "/api/get_all_profiles",
1154
+ response_model=GetProfilesViewResponse,
1155
+ response_model_exclude_none=True,
1156
+ )
1157
+ def get_all_profiles(
1158
+ limit: int = 100,
1159
+ status_filter: str | None = None,
1160
+ org_id: str = Depends(default_get_org_id),
1161
+ ) -> GetProfilesViewResponse:
1162
+ """Get all user profiles across all users.
1163
+
1164
+ Args:
1165
+ limit (int, optional): Maximum number of profiles to return. Defaults to 100.
1166
+ status_filter (str, optional): Filter by profile status. Can be "current", "pending", or "archived".
1167
+ org_id (str): Organization ID
1168
+
1169
+ Returns:
1170
+ GetProfilesViewResponse: Response containing all user profiles
1171
+ """
1172
+ reflexio = get_reflexio(org_id=org_id)
1173
+
1174
+ # Map status_filter string to Status list
1175
+ status_filter_list = None
1176
+ if status_filter == "current":
1177
+ status_filter_list = [None]
1178
+ elif status_filter == "pending":
1179
+ status_filter_list = [Status.PENDING]
1180
+ elif status_filter == "archived":
1181
+ status_filter_list = [Status.ARCHIVED]
1182
+
1183
+ response = reflexio.get_all_profiles(limit=limit, status_filter=status_filter_list) # type: ignore[reportArgumentType]
1184
+ return GetProfilesViewResponse(
1185
+ success=response.success,
1186
+ user_profiles=[to_profile_view(p) for p in response.user_profiles],
1187
+ msg=response.msg,
1188
+ )
1189
+
1190
+
1191
+ @core_router.get(
1192
+ "/api/get_profile_statistics",
1193
+ response_model=GetProfileStatisticsResponse,
1194
+ response_model_exclude_none=True,
1195
+ )
1196
+ def get_profile_statistics(
1197
+ org_id: str = Depends(default_get_org_id),
1198
+ ) -> GetProfileStatisticsResponse:
1199
+ """Get efficient profile statistics using storage layer queries.
1200
+
1201
+ Args:
1202
+ org_id (str): Organization ID
1203
+
1204
+ Returns:
1205
+ GetProfileStatisticsResponse: Response containing profile counts by status
1206
+ """
1207
+ # Create Reflexio instance
1208
+ reflexio = get_reflexio(org_id=org_id)
1209
+
1210
+ # Get profile statistics using Reflexio's method
1211
+ return reflexio.get_profile_statistics()
1212
+
1213
+
1214
+ @core_router.post(
1215
+ "/api/run_playbook_aggregation",
1216
+ response_model=RunPlaybookAggregationResponse,
1217
+ response_model_exclude_none=True,
1218
+ )
1219
+ @limiter.limit("10/minute") # Strict limit for expensive operations
1220
+ def run_playbook_aggregation(
1221
+ request: Request,
1222
+ payload: RunPlaybookAggregationRequest,
1223
+ org_id: str = Depends(default_get_org_id),
1224
+ ) -> RunPlaybookAggregationResponse:
1225
+ return _run_limited_api(
1226
+ org_id,
1227
+ "aggregation",
1228
+ lambda: publisher_api.run_playbook_aggregation(org_id=org_id, request=payload),
1229
+ )
1230
+
1231
+
1232
+ @core_router.post("/api/set_config")
1233
+ def set_config(
1234
+ config: dict[str, Any],
1235
+ org_id: str = Depends(default_get_org_id),
1236
+ ) -> SetConfigResponse:
1237
+ """Set configuration for the organization.
1238
+
1239
+ Args:
1240
+ config (Config): The configuration to set
1241
+ org_id (str): Organization ID
1242
+
1243
+ Returns:
1244
+ dict: Response containing success status and message
1245
+ """
1246
+ # Create Reflexio instance to access the configurator through request_context
1247
+ reflexio = get_reflexio(org_id=org_id)
1248
+
1249
+ # Set the config using Reflexio's set_config method
1250
+ response = reflexio.set_config(config)
1251
+
1252
+ # Invalidate cache on successful config change to ensure fresh instance next request
1253
+ if response.success:
1254
+ invalidate_reflexio_cache(org_id=org_id)
1255
+
1256
+ return response
1257
+
1258
+
1259
+ @core_router.post("/api/update_config")
1260
+ def update_config(
1261
+ partial: dict[str, Any],
1262
+ org_id: str = Depends(default_get_org_id),
1263
+ ) -> SetConfigResponse:
1264
+ """Apply a partial update to the org's config (PATCH semantics).
1265
+
1266
+ Performs a **top-level shallow merge** of *partial* over the existing
1267
+ config and round-trips through ``Config(**merged)`` so Pydantic
1268
+ validates the result and rejects bogus top-level fields.
1269
+
1270
+ .. warning::
1271
+ Nested objects (e.g. ``storage_config``, ``profile_extractor_config``,
1272
+ ``user_playbook_extractor_config``) are **replaced wholesale**.
1273
+ Deep merging is intentionally not supported -- the discriminator on
1274
+ ``storage_config`` would be lost on partial updates, and merging nested
1275
+ dicts has ambiguous semantics.
1276
+
1277
+ To update a field inside an extractor config you must resend that
1278
+ extractor object fully populated (including ``extractor_name``,
1279
+ ``extraction_definition_prompt``, etc.). For one-off mutations prefer
1280
+ ``GET /api/get_config`` followed by ``POST /api/set_config`` with the
1281
+ modified full config.
1282
+
1283
+ Unlike :func:`set_config`, callers do not need to re-send the full
1284
+ config (including required fields like ``storage_config``) just to
1285
+ flip a single top-level boolean. The merge happens server-side
1286
+ atomically within the request, eliminating the read-modify-write
1287
+ race a client would otherwise hit.
1288
+
1289
+ Args:
1290
+ partial: Top-level fields to overlay on the existing config.
1291
+ org_id: Organization ID resolved by the auth layer.
1292
+
1293
+ Returns:
1294
+ SetConfigResponse: Success status and message from
1295
+ :meth:`Reflexio.set_config`.
1296
+ """
1297
+ from pydantic import ValidationError
1298
+
1299
+ reflexio = get_reflexio(org_id=org_id)
1300
+ existing = reflexio.request_context.configurator.get_config().model_dump(
1301
+ mode="python"
1302
+ )
1303
+ merged = {**existing, **partial}
1304
+ # Pydantic validates the merged shape and rejects unknown / malformed
1305
+ # fields here, before storage validation in reflexio.set_config.
1306
+ # Convert ValidationError into 422 so callers passing a partial that
1307
+ # would replace a nested extractor object with an incomplete dict (e.g.
1308
+ # {"user_playbook_extractor_config": {"aggregation_config": {...}}})
1309
+ # get a clean client-error response instead of a 500.
1310
+ try:
1311
+ merged_config = Config(**merged)
1312
+ except ValidationError as exc:
1313
+ raise HTTPException(
1314
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
1315
+ detail={
1316
+ "error": "Invalid partial config (top-level shallow merge)",
1317
+ "hint": (
1318
+ "Nested objects (e.g. user_playbook_extractor_config) "
1319
+ "are replaced wholesale, not deep-merged. To mutate a "
1320
+ "single nested field, fetch the full config via "
1321
+ "/api/get_config, edit, and POST it back via "
1322
+ "/api/set_config."
1323
+ ),
1324
+ "validation_errors": exc.errors(),
1325
+ },
1326
+ ) from exc
1327
+ response = reflexio.set_config(merged_config)
1328
+ if response.success:
1329
+ invalidate_reflexio_cache(org_id=org_id)
1330
+ return response
1331
+
1332
+
1333
+ @core_router.post("/api/admin/cache/invalidate")
1334
+ def admin_invalidate_cache(
1335
+ payload: AdminInvalidateCacheRequest,
1336
+ org_id: str = Depends(default_get_org_id),
1337
+ ) -> AdminInvalidateCacheResponse:
1338
+ """Explicitly evict the per-org Reflexio cache entry.
1339
+
1340
+ Necessary when the running config has been mutated through a
1341
+ channel the server can't observe — e.g. another replica wrote to
1342
+ the shared DB, or an operator hand-edited a self-host config file
1343
+ on a backend that doesn't support cheap version probing. The
1344
+ file-mtime check (Phase 1) and DB version check (Phase 3) cover
1345
+ most cases automatically; this endpoint is the manual escape hatch.
1346
+
1347
+ Auth uses the same dependency as ``/api/set_config`` — callers
1348
+ can only invalidate their own org's cache. If the request body
1349
+ supplies ``org_id`` it must match the dep-resolved value;
1350
+ cross-org invalidation is intentionally NOT exposed here.
1351
+
1352
+ Args:
1353
+ payload: Optional ``org_id`` (verification only — must match
1354
+ the caller's authenticated org if provided).
1355
+ org_id: Organization ID resolved by the auth layer.
1356
+
1357
+ Returns:
1358
+ AdminInvalidateCacheResponse: ``invalidated`` is True iff an
1359
+ entry was evicted (False is a successful no-op when nothing
1360
+ was cached).
1361
+
1362
+ Raises:
1363
+ HTTPException: 403 when the body's ``org_id`` differs from the
1364
+ caller's authenticated org.
1365
+ """
1366
+ if payload.org_id is not None and payload.org_id != org_id:
1367
+ raise HTTPException(
1368
+ status_code=status.HTTP_403_FORBIDDEN,
1369
+ detail=(
1370
+ "Cross-org cache invalidation is not supported; "
1371
+ "omit org_id or pass your own."
1372
+ ),
1373
+ )
1374
+ invalidated = invalidate_reflexio_cache(org_id=org_id)
1375
+ return AdminInvalidateCacheResponse(invalidated=invalidated, org_id=org_id)
1376
+
1377
+
1378
+ @core_router.get("/api/get_config")
1379
+ def get_config(
1380
+ org_id: str = Depends(default_get_org_id),
1381
+ ) -> dict[str, Any]:
1382
+ """Get configuration for the organization.
1383
+
1384
+ Args:
1385
+ org_id (str): Organization ID
1386
+
1387
+ Returns:
1388
+ Config: The current configuration
1389
+ """
1390
+ # Create Reflexio instance to access the configurator through request_context
1391
+ reflexio = get_reflexio(org_id=org_id)
1392
+
1393
+ return reflexio.request_context.configurator.get_config_for_response()
1394
+
1395
+
1396
+ @core_router.post(
1397
+ "/api/get_user_playbooks",
1398
+ response_model=GetUserPlaybooksViewResponse,
1399
+ response_model_exclude_none=True,
1400
+ )
1401
+ def get_user_playbooks(
1402
+ request: GetUserPlaybooksRequest,
1403
+ org_id: str = Depends(default_get_org_id),
1404
+ ) -> GetUserPlaybooksViewResponse:
1405
+ """Get user playbooks with internal fields filtered out.
1406
+
1407
+ Args:
1408
+ request (GetUserPlaybooksRequest): The get request
1409
+ org_id (str): Organization ID
1410
+
1411
+ Returns:
1412
+ GetUserPlaybooksViewResponse: Response containing user playbooks without internal fields
1413
+ """
1414
+ reflexio = get_reflexio(org_id=org_id)
1415
+ response = reflexio.get_user_playbooks(request)
1416
+ return GetUserPlaybooksViewResponse(
1417
+ success=response.success,
1418
+ user_playbooks=[to_user_playbook_view(rf) for rf in response.user_playbooks],
1419
+ msg=response.msg,
1420
+ )
1421
+
1422
+
1423
+ @core_router.post(
1424
+ "/api/get_agent_playbooks",
1425
+ response_model=GetAgentPlaybooksViewResponse,
1426
+ response_model_exclude_none=True,
1427
+ )
1428
+ def get_agent_playbooks(
1429
+ request: GetAgentPlaybooksRequest,
1430
+ org_id: str = Depends(default_get_org_id),
1431
+ ) -> GetAgentPlaybooksViewResponse:
1432
+ """Get agent playbooks with internal fields filtered out.
1433
+
1434
+ Args:
1435
+ request (GetAgentPlaybooksRequest): The get request
1436
+ org_id (str): Organization ID
1437
+
1438
+ Returns:
1439
+ GetAgentPlaybooksViewResponse: Response containing agent playbooks without internal fields
1440
+ """
1441
+ reflexio = get_reflexio(org_id=org_id)
1442
+ response = reflexio.get_agent_playbooks(request)
1443
+ return GetAgentPlaybooksViewResponse(
1444
+ success=response.success,
1445
+ agent_playbooks=[to_agent_playbook_view(fb) for fb in response.agent_playbooks],
1446
+ msg=response.msg,
1447
+ )
1448
+
1449
+
1450
+ @core_router.post(
1451
+ "/api/get_agent_success_evaluation_results",
1452
+ response_model=GetEvaluationResultsViewResponse,
1453
+ response_model_exclude_none=True,
1454
+ )
1455
+ def get_agent_success_evaluation_results(
1456
+ request: GetAgentSuccessEvaluationResultsRequest,
1457
+ org_id: str = Depends(default_get_org_id),
1458
+ ) -> GetEvaluationResultsViewResponse:
1459
+ """Get agent success evaluation results.
1460
+
1461
+ Args:
1462
+ request (GetAgentSuccessEvaluationResultsRequest): The get request
1463
+ org_id (str): Organization ID
1464
+
1465
+ Returns:
1466
+ GetEvaluationResultsViewResponse: Response containing agent success evaluation results
1467
+ """
1468
+ reflexio = get_reflexio(org_id=org_id)
1469
+ response = reflexio.get_agent_success_evaluation_results(request)
1470
+ return GetEvaluationResultsViewResponse(
1471
+ success=response.success,
1472
+ agent_success_evaluation_results=[
1473
+ to_evaluation_result_view(r)
1474
+ for r in response.agent_success_evaluation_results
1475
+ ],
1476
+ msg=response.msg,
1477
+ )
1478
+
1479
+
1480
+ @core_router.put(
1481
+ "/api/update_agent_playbook_status",
1482
+ response_model=UpdatePlaybookStatusResponse,
1483
+ response_model_exclude_none=True,
1484
+ )
1485
+ def update_agent_playbook_status_endpoint(
1486
+ request: UpdatePlaybookStatusRequest,
1487
+ org_id: str = Depends(default_get_org_id),
1488
+ ) -> UpdatePlaybookStatusResponse:
1489
+ """Update the status of a specific playbook.
1490
+
1491
+ Args:
1492
+ request (UpdatePlaybookStatusRequest): The update request
1493
+ org_id (str): Organization ID
1494
+
1495
+ Returns:
1496
+ UpdatePlaybookStatusResponse: Response containing success status and message
1497
+ """
1498
+ return publisher_api.update_agent_playbook_status(org_id=org_id, request=request)
1499
+
1500
+
1501
+ @core_router.put(
1502
+ "/api/update_agent_playbook",
1503
+ response_model=UpdateAgentPlaybookResponse,
1504
+ response_model_exclude_none=True,
1505
+ )
1506
+ def update_agent_playbook_endpoint(
1507
+ request: UpdateAgentPlaybookRequest,
1508
+ org_id: str = Depends(default_get_org_id),
1509
+ ) -> UpdateAgentPlaybookResponse:
1510
+ """Update editable fields of a specific agent playbook.
1511
+
1512
+ Args:
1513
+ request (UpdateAgentPlaybookRequest): The update request
1514
+ org_id (str): Organization ID
1515
+
1516
+ Returns:
1517
+ UpdateAgentPlaybookResponse: Response containing success status and message
1518
+ """
1519
+ return publisher_api.update_agent_playbook(org_id=org_id, request=request)
1520
+
1521
+
1522
+ @core_router.put(
1523
+ "/api/update_user_playbook",
1524
+ response_model=UpdateUserPlaybookResponse,
1525
+ response_model_exclude_none=True,
1526
+ )
1527
+ def update_user_playbook_endpoint(
1528
+ request: UpdateUserPlaybookRequest,
1529
+ org_id: str = Depends(default_get_org_id),
1530
+ ) -> UpdateUserPlaybookResponse:
1531
+ """Update editable fields of a specific user playbook.
1532
+
1533
+ Args:
1534
+ request (UpdateUserPlaybookRequest): The update request
1535
+ org_id (str): Organization ID
1536
+
1537
+ Returns:
1538
+ UpdateUserPlaybookResponse: Response containing success status and message
1539
+ """
1540
+ return publisher_api.update_user_playbook(org_id=org_id, request=request)
1541
+
1542
+
1543
+ @core_router.put(
1544
+ "/api/update_user_profile",
1545
+ response_model=UpdateUserProfileResponse,
1546
+ response_model_exclude_none=True,
1547
+ )
1548
+ def update_user_profile_endpoint(
1549
+ request: UpdateUserProfileRequest,
1550
+ org_id: str = Depends(default_get_org_id),
1551
+ ) -> UpdateUserProfileResponse:
1552
+ """Apply a partial update to an existing user profile.
1553
+
1554
+ Args:
1555
+ request (UpdateUserProfileRequest): The update request
1556
+ org_id (str): Organization ID
1557
+
1558
+ Returns:
1559
+ UpdateUserProfileResponse: Response containing success status and message
1560
+ """
1561
+ return publisher_api.update_user_profile(org_id=org_id, request=request)
1562
+
1563
+
1564
+ @core_router.post(
1565
+ "/api/get_dashboard_stats",
1566
+ response_model=GetDashboardStatsResponse,
1567
+ response_model_exclude_none=True,
1568
+ )
1569
+ def get_dashboard_stats(
1570
+ request: GetDashboardStatsRequest,
1571
+ org_id: str = Depends(default_get_org_id),
1572
+ ) -> GetDashboardStatsResponse:
1573
+ """Get comprehensive dashboard statistics including counts and time-series data.
1574
+
1575
+ Args:
1576
+ request (GetDashboardStatsRequest): Request containing days_back and granularity
1577
+ org_id (str): Organization ID
1578
+
1579
+ Returns:
1580
+ GetDashboardStatsResponse: Response containing dashboard statistics
1581
+ """
1582
+ # Create Reflexio instance
1583
+ reflexio = get_reflexio(org_id=org_id)
1584
+
1585
+ # Get dashboard stats using Reflexio's method
1586
+ return reflexio.get_dashboard_stats(request)
1587
+
1588
+
1589
+ @core_router.post(
1590
+ "/api/get_playbook_application_stats",
1591
+ response_model=GetPlaybookApplicationStatsResponse,
1592
+ response_model_exclude_none=True,
1593
+ )
1594
+ def get_playbook_application_stats(
1595
+ request: GetPlaybookApplicationStatsRequest,
1596
+ org_id: str = Depends(default_get_org_id),
1597
+ ) -> GetPlaybookApplicationStatsResponse:
1598
+ """Get per-rule citation counts aggregated from interactions.
1599
+
1600
+ Returns one row per cited (kind, real_id) over the look-back window,
1601
+ sorted by applied_count descending. Lets the dashboard show users a
1602
+ per-rule "track record" — how often each playbook or profile has been
1603
+ applied and when it last fired.
1604
+
1605
+ Args:
1606
+ request (GetPlaybookApplicationStatsRequest): Request containing
1607
+ days_back.
1608
+ org_id (str): Organization ID.
1609
+
1610
+ Returns:
1611
+ GetPlaybookApplicationStatsResponse: Response containing aggregated
1612
+ stats.
1613
+ """
1614
+ reflexio = get_reflexio(org_id=org_id)
1615
+ return reflexio.get_playbook_application_stats(request)
1616
+
1617
+
1618
+ # ============================================================================
1619
+ # Braintrust connector (Plan C-backend)
1620
+ # ============================================================================
1621
+
1622
+
1623
+ @core_router.post(
1624
+ "/api/braintrust/connect",
1625
+ response_model=ConnectBraintrustResponse,
1626
+ response_model_exclude_none=True,
1627
+ )
1628
+ @limiter.limit("10/minute")
1629
+ def braintrust_connect(
1630
+ request: Request,
1631
+ payload: ConnectBraintrustRequest,
1632
+ org_id: str = Depends(default_get_org_id),
1633
+ ) -> ConnectBraintrustResponse:
1634
+ """Step 1: validate the Braintrust API key and list workspaces/projects.
1635
+
1636
+ Persists nothing — call `/api/braintrust/select_projects` to commit.
1637
+
1638
+ Args:
1639
+ request (Request): The HTTP request object for rate limiting.
1640
+ payload (ConnectBraintrustRequest): Customer's Braintrust API key.
1641
+ org_id (str): Resolved by auth dependency.
1642
+
1643
+ Returns:
1644
+ ConnectBraintrustResponse: Workspaces tree on success; `success=False`
1645
+ with a message when the key is rejected.
1646
+ """
1647
+ reflexio = get_reflexio(org_id=org_id)
1648
+ return reflexio.braintrust_connect(payload)
1649
+
1650
+
1651
+ @core_router.post(
1652
+ "/api/braintrust/select_projects",
1653
+ response_model=SelectProjectsResponse,
1654
+ response_model_exclude_none=True,
1655
+ )
1656
+ @limiter.limit("10/minute")
1657
+ def braintrust_select_projects(
1658
+ request: Request,
1659
+ payload: SelectProjectsRequest,
1660
+ org_id: str = Depends(default_get_org_id),
1661
+ ) -> SelectProjectsResponse:
1662
+ """Step 2: commit the Braintrust connection with selected projects.
1663
+
1664
+ The API key is encrypted at rest. Subsequent syncs use the persisted
1665
+ connection until the customer calls DELETE /api/braintrust/connection.
1666
+ """
1667
+ reflexio = get_reflexio(org_id=org_id)
1668
+ return reflexio.braintrust_select_projects(payload)
1669
+
1670
+
1671
+ @core_router.get(
1672
+ "/api/braintrust/status",
1673
+ response_model=BraintrustStatusResponse,
1674
+ response_model_exclude_none=True,
1675
+ )
1676
+ def braintrust_status(
1677
+ org_id: str = Depends(default_get_org_id),
1678
+ ) -> BraintrustStatusResponse:
1679
+ """Return Braintrust connection state. Never echoes the API key."""
1680
+ reflexio = get_reflexio(org_id=org_id)
1681
+ return reflexio.braintrust_status()
1682
+
1683
+
1684
+ @core_router.delete("/api/braintrust/connection")
1685
+ @limiter.limit("10/minute")
1686
+ def braintrust_disconnect(
1687
+ request: Request,
1688
+ org_id: str = Depends(default_get_org_id),
1689
+ ) -> dict:
1690
+ """Delete the persisted Braintrust connection for the org.
1691
+
1692
+ Args:
1693
+ org_id (str): Resolved by auth dependency.
1694
+
1695
+ Returns:
1696
+ dict: ``{"success": True}`` on completion.
1697
+ """
1698
+ reflexio = get_reflexio(org_id=org_id)
1699
+ reflexio.braintrust_disconnect()
1700
+ return {"success": True}
1701
+
1702
+
1703
+ @core_router.post(
1704
+ "/api/braintrust/sync",
1705
+ response_model=SyncBraintrustResponse,
1706
+ response_model_exclude_none=True,
1707
+ )
1708
+ @limiter.limit("10/minute")
1709
+ def braintrust_sync(
1710
+ request: Request,
1711
+ org_id: str = Depends(default_get_org_id),
1712
+ ) -> SyncBraintrustResponse:
1713
+ """Trigger a one-shot sync of Braintrust scorer outputs.
1714
+
1715
+ Scheduled (cron) sync is a follow-up; for now the endpoint exists so
1716
+ operators can drive a manual import.
1717
+ """
1718
+ reflexio = get_reflexio(org_id=org_id)
1719
+ return reflexio.braintrust_sync()
1720
+
1721
+
1722
+ @core_router.post(
1723
+ "/api/get_evaluation_overview",
1724
+ response_model=GetEvaluationOverviewResponse,
1725
+ response_model_exclude_none=True,
1726
+ )
1727
+ def get_evaluation_overview(
1728
+ request: GetEvaluationOverviewRequest,
1729
+ org_id: str = Depends(default_get_org_id),
1730
+ ) -> GetEvaluationOverviewResponse:
1731
+ """Return the redesigned /evaluations page payload.
1732
+
1733
+ Aggregates hero state, four context tiles with deltas, top rule
1734
+ attribution, and a corrections-per-session distribution into a single
1735
+ response shaped exactly as the frontend renders it.
1736
+
1737
+ Args:
1738
+ request (GetEvaluationOverviewRequest): Window + bucket granularity.
1739
+ org_id (str): Organization ID resolved by the auth dependency.
1740
+
1741
+ Returns:
1742
+ GetEvaluationOverviewResponse: Full overview payload.
1743
+ """
1744
+ reflexio = get_reflexio(org_id=org_id)
1745
+ return reflexio.get_evaluation_overview(request)
1746
+
1747
+
1748
+ # ---------------------------------------------------------------------------
1749
+ # /api/evaluations/regenerate — replay the LLM judge over a window
1750
+ # ---------------------------------------------------------------------------
1751
+
1752
+
1753
+ @core_router.post(
1754
+ "/api/evaluations/regenerate",
1755
+ response_model=RegenerateStartResponse,
1756
+ response_model_exclude_none=True,
1757
+ )
1758
+ def start_regenerate(
1759
+ payload: RegenerateRequest,
1760
+ org_id: str = Depends(default_get_org_id),
1761
+ ) -> RegenerateStartResponse:
1762
+ """Start a singleton regenerate job over a time window.
1763
+
1764
+ Args:
1765
+ payload (RegenerateRequest): Window bounds plus optional legacy evaluator name.
1766
+ org_id (str): Organization ID resolved by the auth dependency.
1767
+
1768
+ Returns:
1769
+ RegenerateStartResponse: ``job_id`` to poll/cancel and ``total``
1770
+ tuples queued.
1771
+
1772
+ Raises:
1773
+ HTTPException: 409 when a regenerate for the same org is already
1774
+ running. 503 when storage is not configured.
1775
+ """
1776
+ reflexio = get_reflexio(org_id=org_id)
1777
+ storage = reflexio.request_context.storage
1778
+ if storage is None:
1779
+ raise HTTPException(status_code=503, detail="Storage not configured")
1780
+ descriptors = storage.get_session_ids_in_window(
1781
+ from_ts=payload.from_ts, to_ts=payload.to_ts
1782
+ )
1783
+ try:
1784
+ job = REGEN_JOBS.create(
1785
+ org_id=org_id,
1786
+ from_ts=payload.from_ts,
1787
+ to_ts=payload.to_ts,
1788
+ total=len(descriptors),
1789
+ )
1790
+ except RuntimeError as e:
1791
+ raise HTTPException(status_code=409, detail=str(e)) from e
1792
+ threading.Thread(
1793
+ target=run_regen,
1794
+ kwargs={
1795
+ "job": job,
1796
+ "request_context": reflexio.request_context,
1797
+ "llm_client": reflexio.llm_client,
1798
+ },
1799
+ daemon=True,
1800
+ ).start()
1801
+ return RegenerateStartResponse(job_id=job.job_id, total=job.total)
1802
+
1803
+
1804
+ @core_router.get(
1805
+ "/api/evaluations/regenerate/{job_id}",
1806
+ response_model=RegenerateStatusResponse,
1807
+ response_model_exclude_none=True,
1808
+ )
1809
+ def get_regenerate_status(
1810
+ job_id: str,
1811
+ org_id: str = Depends(default_get_org_id),
1812
+ ) -> RegenerateStatusResponse:
1813
+ """Poll the status of a regenerate job.
1814
+
1815
+ Args:
1816
+ job_id (str): Opaque handle returned by POST /api/evaluations/regenerate.
1817
+ org_id (str): Organization ID resolved by the auth dependency.
1818
+
1819
+ Returns:
1820
+ RegenerateStatusResponse: Counters, status, and failure list.
1821
+
1822
+ Raises:
1823
+ HTTPException: 404 when ``job_id`` is unknown or belongs to a
1824
+ different org.
1825
+ """
1826
+ job = REGEN_JOBS.get(job_id)
1827
+ if job is None or job.org_id != org_id:
1828
+ raise HTTPException(status_code=404, detail="Unknown job_id")
1829
+ return RegenerateStatusResponse(
1830
+ job_id=job.job_id,
1831
+ status=job.status,
1832
+ total=job.total,
1833
+ completed=job.completed,
1834
+ failed=job.failed,
1835
+ failures=[
1836
+ RegenerateFailure(session_id=f.session_id, reason=f.reason)
1837
+ for f in job.failures
1838
+ ],
1839
+ started_at=job.started_at,
1840
+ finished_at=job.finished_at,
1841
+ # F3 informational counters — surface sampler + concurrency facts
1842
+ # so the dashboard can render "n_sampled / total_candidates" and
1843
+ # the configured worker cap without a second round-trip.
1844
+ total_candidates=job.total_candidates,
1845
+ sampled_count=job.sampled_count,
1846
+ concurrency_limit=job.concurrency_limit,
1847
+ )
1848
+
1849
+
1850
+ @core_router.delete("/api/evaluations/regenerate/{job_id}")
1851
+ def cancel_regenerate(
1852
+ job_id: str,
1853
+ org_id: str = Depends(default_get_org_id),
1854
+ ) -> dict[str, str]:
1855
+ """Request cancellation of a running regenerate job.
1856
+
1857
+ Sets the worker's cancel event; the worker checks the flag between
1858
+ sessions and transitions to ``"cancelled"`` on its next iteration.
1859
+
1860
+ Args:
1861
+ job_id (str): Opaque handle returned by POST /api/evaluations/regenerate.
1862
+ org_id (str): Organization ID resolved by the auth dependency.
1863
+
1864
+ Returns:
1865
+ dict[str, str]: ``{"status": "cancelled"}`` on successful flag set.
1866
+
1867
+ Raises:
1868
+ HTTPException: 404 when ``job_id`` is unknown or belongs to a
1869
+ different org.
1870
+ """
1871
+ job = REGEN_JOBS.get(job_id)
1872
+ if job is None or job.org_id != org_id:
1873
+ raise HTTPException(status_code=404, detail="Unknown job_id")
1874
+ REGEN_JOBS.cancel(job_id)
1875
+ return {"status": "cancelled"}
1876
+
1877
+
1878
+ # ---------------------------------------------------------------------------
1879
+ # /api/evaluations/grade_on_demand — single-session click-through grading
1880
+ # ---------------------------------------------------------------------------
1881
+ #
1882
+ # F3 sampling means most sessions in a regen window are NEVER graded —
1883
+ # the sampler keeps cost predictable by capping each (day, group) stratum
1884
+ # at ``Config.eval_sample_n_per_stratum``. Plan 3 (F1) surfaces a
1885
+ # bounded list of sessions in the UI; when a customer clicks one that
1886
+ # wasn't in the sampled subset, the frontend hits this endpoint to grade
1887
+ # it on demand. The 24h cache prevents repeated clicks from triggering
1888
+ # redundant LLM calls.
1889
+
1890
+ _GRADE_ON_DEMAND_CACHE_TTL_SECONDS = 24 * 60 * 60
1891
+ _GRADE_ON_DEMAND_CACHE_KEY_PREFIX = "grade_on_demand"
1892
+
1893
+
1894
+ def _grade_on_demand_cache_key(
1895
+ org_id: str, session_id: str, agent_version: str, evaluation_name: str
1896
+ ) -> str:
1897
+ """Build the operation_state key for the on-demand grading cache.
1898
+
1899
+ The key embeds every active singleton dimension that could change the
1900
+ verdict: org_id (multi-tenant scope), session_id (the unit of work),
1901
+ agent_version (eval results are versioned), and evaluation_name (kept as a
1902
+ compatibility/readback discriminator for historical multi-evaluator rows).
1903
+
1904
+ Args:
1905
+ org_id (str): Tenant identifier from the auth context.
1906
+ session_id (str): Target session.
1907
+ agent_version (str): Agent version filter.
1908
+ evaluation_name (str): Evaluator/result namespace to isolate cache rows.
1909
+ Returns:
1910
+ str: A namespaced key suitable for ``storage.upsert_operation_state``.
1911
+ """
1912
+ return (
1913
+ f"{_GRADE_ON_DEMAND_CACHE_KEY_PREFIX}::{org_id}::{session_id}"
1914
+ f"::{agent_version}::{evaluation_name}"
1915
+ )
1916
+
1917
+
1918
+ def _read_grade_on_demand_cache(
1919
+ storage: Any, cache_key: str, *, now: int
1920
+ ) -> int | None:
1921
+ """Return the cached ``result_id`` if a valid entry exists, else None.
1922
+
1923
+ Returns None on three conditions: no entry, malformed entry, or entry
1924
+ whose ``last_graded_at`` is older than the 24h TTL. Keeps the handler
1925
+ body focused on the happy path.
1926
+
1927
+ Args:
1928
+ storage: The request's storage backend.
1929
+ cache_key (str): Key produced by ``_grade_on_demand_cache_key``.
1930
+ now (int): Current Unix-seconds wall-clock timestamp.
1931
+
1932
+ Returns:
1933
+ int | None: Cached result_id when fresh, else None.
1934
+ """
1935
+ cached_state = storage.get_operation_state(cache_key)
1936
+ if not cached_state:
1937
+ return None
1938
+ state = cached_state.get("operation_state")
1939
+ if not isinstance(state, dict):
1940
+ return None
1941
+ last_graded_at = state.get("last_graded_at")
1942
+ if not isinstance(last_graded_at, int):
1943
+ return None
1944
+ if (now - last_graded_at) >= _GRADE_ON_DEMAND_CACHE_TTL_SECONDS:
1945
+ return None
1946
+ cached_result_id = state.get("result_id")
1947
+ return cached_result_id if isinstance(cached_result_id, int) else None
1948
+
1949
+
1950
+ def _resolve_session_user_id(storage: Any, session_id: str) -> str | None:
1951
+ """Look up the user_id that owns a session_id without requiring it as input.
1952
+
1953
+ Uses ``get_sessions(session_id=...)`` because it's the only storage
1954
+ method on ``BaseStorage`` that accepts session_id alone — every other
1955
+ request-fetcher requires a user_id paired with it. Returns None when
1956
+ the session has no requests, signalling NO_REQUESTS to the caller.
1957
+
1958
+ Args:
1959
+ storage: The request's storage backend.
1960
+ session_id (str): The target session whose owner to resolve.
1961
+
1962
+ Returns:
1963
+ str | None: The user_id of the earliest request in the session,
1964
+ or None when no requests exist.
1965
+ """
1966
+ sessions = storage.get_sessions(session_id=session_id, top_k=100)
1967
+ rows = sessions.get(session_id) or []
1968
+ if not rows:
1969
+ return None
1970
+ earliest = min(rows, key=lambda r: r.request.created_at)
1971
+ return earliest.request.user_id
1972
+
1973
+
1974
+ def _find_fresh_result_id(
1975
+ storage: Any,
1976
+ *,
1977
+ session_id: str,
1978
+ agent_version: str,
1979
+ evaluation_name: str,
1980
+ previous_result_ids: set[int],
1981
+ ) -> int | None:
1982
+ """Locate the result_id written by the most-recent grade for this session.
1983
+
1984
+ The runner writes rows but doesn't return the id. Reading back through
1985
+ ``get_agent_success_evaluation_results`` matches the pattern used by
1986
+ the regen worker's prior-row capture (group_evaluation_runner step 5).
1987
+
1988
+ Args:
1989
+ storage: The request's storage backend.
1990
+ session_id (str): The graded session.
1991
+ agent_version (str): The version dimension.
1992
+ evaluation_name (str): Evaluator/result namespace to isolate readback.
1993
+ previous_result_ids (set[int]): Matching rows observed before grading.
1994
+
1995
+ Returns:
1996
+ int | None: result_id of the latest matching row, or None if the
1997
+ runner wrote nothing.
1998
+ """
1999
+ rows = storage.get_agent_success_evaluation_results(
2000
+ limit=1000, agent_version=agent_version
2001
+ )
2002
+ matched = [
2003
+ r
2004
+ for r in rows
2005
+ if r.session_id == session_id
2006
+ and r.evaluation_name == evaluation_name
2007
+ and r.result_id not in previous_result_ids
2008
+ ]
2009
+ if not matched:
2010
+ return None
2011
+ latest = max(matched, key=lambda r: r.created_at)
2012
+ return latest.result_id
2013
+
2014
+
2015
+ @core_router.post(
2016
+ "/api/evaluations/grade_on_demand",
2017
+ response_model=GradeOnDemandResponse,
2018
+ response_model_exclude_none=False,
2019
+ )
2020
+ def grade_on_demand(
2021
+ payload: GradeOnDemandRequest,
2022
+ org_id: str = Depends(default_get_org_id),
2023
+ ) -> GradeOnDemandResponse:
2024
+ """Grade a single session synchronously; serve cached results within 24h.
2025
+
2026
+ Flow:
2027
+ 1. Read the operation_state cache; if a fresh entry exists, return it
2028
+ with ``cached=True``.
2029
+ 2. Resolve the session's user_id from storage (skip with ``NO_REQUESTS``
2030
+ when the session is unknown — surfaced as 200 + ``skipped_reason``
2031
+ so the frontend's bounded-list click-through can handle stale ids
2032
+ locally without polluting 5xx telemetry).
2033
+ 3. Invoke ``run_group_evaluation(force_regenerate=True)`` so the
2034
+ "already evaluated" short-circuit doesn't suppress a customer's
2035
+ explicit click.
2036
+ 4. Find the freshly-written result_id and persist it in the cache
2037
+ with ``last_graded_at`` so future calls within 24h short-circuit.
2038
+
2039
+ Args:
2040
+ payload (GradeOnDemandRequest): Session + version plus optional legacy evaluator name.
2041
+ org_id (str): Tenant identifier resolved by the auth dependency.
2042
+
2043
+ Returns:
2044
+ GradeOnDemandResponse: Echoes ``session_id`` and carries either
2045
+ a fresh ``result_id`` (``cached=False``), a cached one
2046
+ (``cached=True``), or a ``skipped_reason`` (NO_REQUESTS).
2047
+
2048
+ Raises:
2049
+ HTTPException: 503 when storage is not configured.
2050
+ """
2051
+ reflexio = get_reflexio(org_id=org_id)
2052
+ storage = reflexio.request_context.storage
2053
+ if storage is None:
2054
+ raise HTTPException(status_code=503, detail="Storage not configured")
2055
+
2056
+ evaluation_name = payload.evaluation_name or SINGLETON_AGENT_SUCCESS_EVALUATION_NAME
2057
+ cache_key = _grade_on_demand_cache_key(
2058
+ org_id,
2059
+ payload.session_id,
2060
+ payload.agent_version,
2061
+ evaluation_name,
2062
+ )
2063
+ now = int(datetime.now(UTC).timestamp())
2064
+
2065
+ cached_result_id = _read_grade_on_demand_cache(storage, cache_key, now=now)
2066
+ if cached_result_id is not None:
2067
+ return GradeOnDemandResponse(
2068
+ session_id=payload.session_id,
2069
+ result_id=cached_result_id,
2070
+ cached=True,
2071
+ skipped_reason=None,
2072
+ )
2073
+
2074
+ user_id = _resolve_session_user_id(storage, payload.session_id)
2075
+ if user_id is None:
2076
+ return GradeOnDemandResponse(
2077
+ session_id=payload.session_id,
2078
+ result_id=None,
2079
+ cached=False,
2080
+ skipped_reason="NO_REQUESTS",
2081
+ )
2082
+
2083
+ previous_result_ids = {
2084
+ r.result_id
2085
+ for r in storage.get_agent_success_evaluation_results(
2086
+ limit=1000, agent_version=payload.agent_version
2087
+ )
2088
+ if r.session_id == payload.session_id and r.evaluation_name == evaluation_name
2089
+ }
2090
+
2091
+ # Two operation_state rows are intentionally written for this session:
2092
+ # 1) `grade_on_demand::{org_id}::{session_id}::{agent_version}::{evaluation_name}`
2093
+ # — our 24h cache, set below after the result_id is resolved.
2094
+ # 2) `agent_success_group_eval::{org_id}::{user_id}::{session_id}`
2095
+ # — the runner's own "evaluated" marker, written by
2096
+ # run_group_evaluation. Future background runs without
2097
+ # force_regenerate will skip this session as a result.
2098
+ # The cache key namespaces are distinct so the two markers do not
2099
+ # interfere; the explicit force_regenerate=True here is what makes
2100
+ # an on-demand grade always do real work on a cache miss.
2101
+ run_group_evaluation(
2102
+ org_id=org_id,
2103
+ user_id=user_id,
2104
+ session_id=payload.session_id,
2105
+ agent_version=payload.agent_version,
2106
+ source=None,
2107
+ request_context=reflexio.request_context,
2108
+ llm_client=reflexio.llm_client,
2109
+ force_regenerate=True,
2110
+ )
2111
+
2112
+ result_id = _find_fresh_result_id(
2113
+ storage,
2114
+ session_id=payload.session_id,
2115
+ agent_version=payload.agent_version,
2116
+ evaluation_name=evaluation_name,
2117
+ previous_result_ids=previous_result_ids,
2118
+ )
2119
+
2120
+ storage.upsert_operation_state(
2121
+ cache_key,
2122
+ {"last_graded_at": now, "result_id": result_id},
2123
+ )
2124
+
2125
+ return GradeOnDemandResponse(
2126
+ session_id=payload.session_id,
2127
+ result_id=result_id,
2128
+ cached=False,
2129
+ skipped_reason=None,
2130
+ )
2131
+
2132
+
2133
+ # ---------------------------------------------------------------------------
2134
+ # /api/evaluations/shadow_comparisons/recent — F1 drawer + Top 10 widget
2135
+ # ---------------------------------------------------------------------------
2136
+ #
2137
+ # Powers two surfaces on /evaluations:
2138
+ # 1. The drawer triggered from the per-turn comparison tile — shows the
2139
+ # N most recent verdicts so customers can spot-check the judge.
2140
+ # 2. The "Top 10 disagreements" widget — fetches a wider pool and the
2141
+ # frontend filters to ``is_significantly_better=True`` losses to surface
2142
+ # actionable rule-correction candidates.
2143
+ #
2144
+ # Filtering is restricted to the org's currently pinned
2145
+ # ``shadow_comparison_judge_prompt_version`` so verdicts from an older rubric
2146
+ # never mix into the drawer. The 30-day lookback is a defensive cap that lets
2147
+ # the storage layer use an index range scan instead of a full table read.
2148
+
2149
+ _RECENT_SHADOW_COMPARISONS_LOOKBACK_SECONDS = 30 * 24 * 60 * 60
2150
+ _RECENT_SHADOW_COMPARISONS_MAX_LIMIT = 100
2151
+
2152
+
2153
+ @core_router.get(
2154
+ "/api/evaluations/shadow_comparisons/recent",
2155
+ response_model=GetRecentShadowComparisonsResponse,
2156
+ )
2157
+ def get_recent_shadow_comparisons(
2158
+ limit: int = 10,
2159
+ org_id: str = Depends(default_get_org_id),
2160
+ ) -> GetRecentShadowComparisonsResponse:
2161
+ """Return the N most recent shadow comparison verdicts for the pinned rubric.
2162
+
2163
+ Filters to the org's currently pinned
2164
+ ``Config.shadow_comparison_judge_prompt_version`` so verdicts produced
2165
+ under an older rubric do not mix into the drawer or the Top 10
2166
+ disagreements widget. Storage returns verdicts in ascending ``created_at``
2167
+ order; we reverse to "newest first" and cap at ``limit``.
2168
+
2169
+ Args:
2170
+ limit (int): Max verdicts to return. Clamped to ``[1, 100]``.
2171
+ Default 10 — matches the size of the drawer and Top 10 widget.
2172
+ org_id (str): Tenant identifier resolved by the auth dependency.
2173
+
2174
+ Returns:
2175
+ GetRecentShadowComparisonsResponse: Verdicts in newest-first order.
2176
+ Empty list when the backend does not support the
2177
+ ``shadow_comparison_verdicts`` storage feature, when no verdicts
2178
+ exist in the 30-day window, or when no verdicts match the pinned
2179
+ prompt version.
2180
+
2181
+ Raises:
2182
+ HTTPException: 503 when storage is not configured.
2183
+ """
2184
+ clamped_limit = max(1, min(int(limit), _RECENT_SHADOW_COMPARISONS_MAX_LIMIT))
2185
+ reflexio = get_reflexio(org_id=org_id)
2186
+ storage = reflexio.request_context.storage
2187
+ if storage is None:
2188
+ raise HTTPException(status_code=503, detail="Storage not configured")
2189
+
2190
+ config = reflexio.request_context.configurator.get_config()
2191
+ pinned_version = (
2192
+ config.shadow_comparison_judge_prompt_version
2193
+ if config is not None
2194
+ else "v1.0.0"
2195
+ )
2196
+
2197
+ now = int(datetime.now(UTC).timestamp())
2198
+ try:
2199
+ verdicts = storage.get_shadow_comparison_verdicts(
2200
+ from_ts=now - _RECENT_SHADOW_COMPARISONS_LOOKBACK_SECONDS,
2201
+ to_ts=now,
2202
+ judge_prompt_version=pinned_version,
2203
+ )
2204
+ except NotImplementedError:
2205
+ # Backends that don't support shadow verdicts (e.g., Disk) should
2206
+ # quietly return empty rather than 5xx — the surface degrades to
2207
+ # "no data yet" in the UI.
2208
+ return GetRecentShadowComparisonsResponse(verdicts=[])
2209
+
2210
+ # Storage contract returns ascending — flip to "newest first" and cap.
2211
+ newest_first = list(reversed(verdicts))[:clamped_limit]
2212
+ return GetRecentShadowComparisonsResponse(verdicts=newest_first)
2213
+
2214
+
2215
+ @core_router.post(
2216
+ "/api/rerun_profile_generation",
2217
+ response_model=RerunProfileGenerationResponse,
2218
+ response_model_exclude_none=True,
2219
+ )
2220
+ @limiter.limit("5/minute") # Strict limit for expensive operations
2221
+ def rerun_profile_generation_endpoint(
2222
+ request: Request,
2223
+ payload: RerunProfileGenerationRequest,
2224
+ background_tasks: BackgroundTasks,
2225
+ org_id: str = Depends(default_get_org_id),
2226
+ ) -> RerunProfileGenerationResponse:
2227
+ """Rerun profile generation for a user with filtered interactions.
2228
+
2229
+ Args:
2230
+ request (Request): The HTTP request object (for rate limiting)
2231
+ payload (RerunProfileGenerationRequest): Request containing user_id, time filters, and source
2232
+ background_tasks (BackgroundTasks): Background task runner
2233
+ org_id (str): Organization ID
2234
+
2235
+ Returns:
2236
+ RerunProfileGenerationResponse: Response containing success status and profiles generated count
2237
+ """
2238
+ # Create Reflexio instance
2239
+ reflexio = get_reflexio(org_id=org_id)
2240
+
2241
+ # Run the long-running task in the background to avoid proxy timeout
2242
+ # Client polls get_operation_status for progress
2243
+ background_tasks.add_task(reflexio.rerun_profile_generation, payload)
2244
+
2245
+ return RerunProfileGenerationResponse(
2246
+ success=True, msg="Profile generation started"
2247
+ )
2248
+
2249
+
2250
+ @core_router.post(
2251
+ "/api/manual_profile_generation",
2252
+ response_model=ManualProfileGenerationResponse,
2253
+ response_model_exclude_none=True,
2254
+ )
2255
+ @limiter.limit("5/minute") # Strict limit for expensive operations
2256
+ def manual_profile_generation_endpoint(
2257
+ request: Request,
2258
+ payload: ManualProfileGenerationRequest,
2259
+ org_id: str = Depends(default_get_org_id),
2260
+ ) -> ManualProfileGenerationResponse:
2261
+ """Manually trigger profile generation with window-sized interactions and CURRENT output.
2262
+
2263
+ Runs with auto_run=False, which bypasses the regular stride/should_run
2264
+ gates. Only profile extraction is triggered. Each extractor uses its own
2265
+ window_size_override when present, falling back to the global window_size.
2266
+ Output is CURRENT profiles only.
2267
+
2268
+ Args:
2269
+ request (Request): The HTTP request object (for rate limiting)
2270
+ payload (ManualProfileGenerationRequest): Request containing user_id, source, and extractor_names
2271
+ org_id (str): Organization ID
2272
+
2273
+ Returns:
2274
+ ManualProfileGenerationResponse: Response containing success status and profiles generated count
2275
+ """
2276
+ # Create Reflexio instance
2277
+ reflexio = get_reflexio(org_id=org_id)
2278
+
2279
+ # Call manual_profile_generation
2280
+ return reflexio.manual_profile_generation(payload)
2281
+
2282
+
2283
+ @core_router.post(
2284
+ "/api/rerun_playbook_generation",
2285
+ response_model=RerunPlaybookGenerationResponse,
2286
+ response_model_exclude_none=True,
2287
+ )
2288
+ @limiter.limit("5/minute") # Strict limit for expensive operations
2289
+ def rerun_playbook_generation_endpoint(
2290
+ request: Request,
2291
+ payload: RerunPlaybookGenerationRequest,
2292
+ background_tasks: BackgroundTasks,
2293
+ org_id: str = Depends(default_get_org_id),
2294
+ ) -> RerunPlaybookGenerationResponse:
2295
+ """Rerun playbook generation with filtered interactions.
2296
+
2297
+ Args:
2298
+ request (Request): The HTTP request object (for rate limiting)
2299
+ payload (RerunPlaybookGenerationRequest): Request containing agent_version, time filters, and optional playbook_name
2300
+ background_tasks (BackgroundTasks): Background task runner
2301
+ org_id (str): Organization ID
2302
+
2303
+ Returns:
2304
+ RerunPlaybookGenerationResponse: Response containing success status and playbooks generated count
2305
+ """
2306
+ # Create Reflexio instance
2307
+ reflexio = get_reflexio(org_id=org_id)
2308
+
2309
+ # Run the long-running task in the background to avoid proxy timeout
2310
+ # Client polls get_operation_status for progress
2311
+ background_tasks.add_task(reflexio.rerun_playbook_generation, payload)
2312
+
2313
+ return RerunPlaybookGenerationResponse(
2314
+ success=True, msg="Playbook generation started"
2315
+ )
2316
+
2317
+
2318
+ @core_router.post(
2319
+ "/api/manual_playbook_generation",
2320
+ response_model=ManualPlaybookGenerationResponse,
2321
+ response_model_exclude_none=True,
2322
+ )
2323
+ @limiter.limit("5/minute") # Strict limit for expensive operations
2324
+ def manual_playbook_generation_endpoint(
2325
+ request: Request,
2326
+ payload: ManualPlaybookGenerationRequest,
2327
+ org_id: str = Depends(default_get_org_id),
2328
+ ) -> ManualPlaybookGenerationResponse:
2329
+ """Manually trigger playbook generation with window-sized interactions and CURRENT output.
2330
+
2331
+ Runs with auto_run=False, which bypasses the regular stride/should_run
2332
+ gates. Only playbook extraction is triggered. Each extractor uses its own
2333
+ window_size_override when present, falling back to the global window_size.
2334
+ Output is CURRENT playbooks only.
2335
+
2336
+ Args:
2337
+ request (Request): The HTTP request object (for rate limiting)
2338
+ payload (ManualPlaybookGenerationRequest): Request containing agent_version, source, and playbook_name
2339
+ org_id (str): Organization ID
2340
+
2341
+ Returns:
2342
+ ManualPlaybookGenerationResponse: Response containing success status and playbooks generated count
2343
+ """
2344
+ # Create Reflexio instance
2345
+ reflexio = get_reflexio(org_id=org_id)
2346
+
2347
+ # Call manual_playbook_generation
2348
+ return reflexio.manual_playbook_generation(payload)
2349
+
2350
+
2351
+ @core_router.post(
2352
+ "/api/upgrade_all_profiles",
2353
+ response_model=UpgradeProfilesResponse,
2354
+ response_model_exclude_none=True,
2355
+ )
2356
+ def upgrade_all_profiles_endpoint(
2357
+ request: UpgradeProfilesRequest,
2358
+ org_id: str = Depends(default_get_org_id),
2359
+ ) -> UpgradeProfilesResponse:
2360
+ """Upgrade all profiles by deleting old ARCHIVED, archiving CURRENT, and promoting PENDING.
2361
+
2362
+ This operation performs three atomic steps:
2363
+ 1. Delete all ARCHIVED profiles (old archived profiles from previous upgrades)
2364
+ 2. Archive all CURRENT profiles → ARCHIVED (save current state for potential rollback)
2365
+ 3. Promote all PENDING profiles → CURRENT (activate new profiles)
2366
+
2367
+ Args:
2368
+ request (UpgradeProfilesRequest): The upgrade request with only_affected_users parameter
2369
+ org_id (str): Organization ID
2370
+
2371
+ Returns:
2372
+ UpgradeProfilesResponse: Response containing success status and counts
2373
+ """
2374
+ # Create Reflexio instance
2375
+ reflexio = get_reflexio(org_id=org_id)
2376
+
2377
+ # Call upgrade_all_profiles with request
2378
+ return reflexio.upgrade_all_profiles(request=request)
2379
+
2380
+
2381
+ @core_router.post(
2382
+ "/api/downgrade_all_profiles",
2383
+ response_model=DowngradeProfilesResponse,
2384
+ response_model_exclude_none=True,
2385
+ )
2386
+ def downgrade_all_profiles_endpoint(
2387
+ request: DowngradeProfilesRequest,
2388
+ org_id: str = Depends(default_get_org_id),
2389
+ ) -> DowngradeProfilesResponse:
2390
+ """Downgrade all profiles by demoting CURRENT to PENDING and restoring ARCHIVED.
2391
+
2392
+ This operation performs two atomic steps:
2393
+ 1. Demote all CURRENT profiles → PENDING
2394
+ 2. Restore all ARCHIVED profiles → CURRENT
2395
+
2396
+ Args:
2397
+ request (DowngradeProfilesRequest): The downgrade request with only_affected_users parameter
2398
+ org_id (str): Organization ID
2399
+
2400
+ Returns:
2401
+ DowngradeProfilesResponse: Response containing success status and counts
2402
+ """
2403
+ # Create Reflexio instance
2404
+ reflexio = get_reflexio(org_id=org_id)
2405
+
2406
+ # Call downgrade_all_profiles with request
2407
+ return reflexio.downgrade_all_profiles(request=request)
2408
+
2409
+
2410
+ @core_router.post(
2411
+ "/api/upgrade_all_user_playbooks",
2412
+ response_model=UpgradeUserPlaybooksResponse,
2413
+ response_model_exclude_none=True,
2414
+ )
2415
+ def upgrade_all_user_playbooks_endpoint(
2416
+ request: UpgradeUserPlaybooksRequest,
2417
+ org_id: str = Depends(default_get_org_id),
2418
+ ) -> UpgradeUserPlaybooksResponse:
2419
+ """Upgrade all user playbooks by deleting old ARCHIVED, archiving CURRENT, and promoting PENDING.
2420
+
2421
+ This operation performs three atomic steps:
2422
+ 1. Delete all ARCHIVED user playbooks (old archived from previous upgrades)
2423
+ 2. Archive all CURRENT user playbooks → ARCHIVED (save current state for potential rollback)
2424
+ 3. Promote all PENDING user playbooks → CURRENT (activate new user playbooks)
2425
+
2426
+ Args:
2427
+ request (UpgradeUserPlaybooksRequest): The upgrade request with optional agent_version and playbook_name filters
2428
+ org_id (str): Organization ID
2429
+
2430
+ Returns:
2431
+ UpgradeUserPlaybooksResponse: Response containing success status and counts
2432
+ """
2433
+ # Create Reflexio instance
2434
+ reflexio = get_reflexio(org_id=org_id)
2435
+
2436
+ # Call upgrade_all_user_playbooks with request
2437
+ return reflexio.upgrade_all_user_playbooks(request=request)
2438
+
2439
+
2440
+ @core_router.post(
2441
+ "/api/downgrade_all_user_playbooks",
2442
+ response_model=DowngradeUserPlaybooksResponse,
2443
+ response_model_exclude_none=True,
2444
+ )
2445
+ def downgrade_all_user_playbooks_endpoint(
2446
+ request: DowngradeUserPlaybooksRequest,
2447
+ org_id: str = Depends(default_get_org_id),
2448
+ ) -> DowngradeUserPlaybooksResponse:
2449
+ """Downgrade all user playbooks by archiving CURRENT and restoring ARCHIVED.
2450
+
2451
+ This operation performs three atomic steps:
2452
+ 1. Mark all CURRENT user playbooks → ARCHIVE_IN_PROGRESS (temporary status)
2453
+ 2. Restore all ARCHIVED user playbooks → CURRENT
2454
+ 3. Move all ARCHIVE_IN_PROGRESS user playbooks → ARCHIVED
2455
+
2456
+ Args:
2457
+ request (DowngradeUserPlaybooksRequest): The downgrade request with optional agent_version and playbook_name filters
2458
+ org_id (str): Organization ID
2459
+
2460
+ Returns:
2461
+ DowngradeUserPlaybooksResponse: Response containing success status and counts
2462
+ """
2463
+ # Create Reflexio instance
2464
+ reflexio = get_reflexio(org_id=org_id)
2465
+
2466
+ # Call downgrade_all_user_playbooks with request
2467
+ return reflexio.downgrade_all_user_playbooks(request=request)
2468
+
2469
+
2470
+ @core_router.get(
2471
+ "/api/get_operation_status",
2472
+ response_model=GetOperationStatusResponse,
2473
+ response_model_exclude_none=True,
2474
+ )
2475
+ def get_operation_status_endpoint(
2476
+ service_name: str = "profile_generation",
2477
+ org_id: str = Depends(default_get_org_id),
2478
+ ) -> GetOperationStatusResponse:
2479
+ """Get the status of an operation (e.g., profile generation rerun or manual).
2480
+
2481
+ Args:
2482
+ service_name (str): The service name to query. Defaults to "profile_generation"
2483
+ org_id (str): Organization ID
2484
+
2485
+ Returns:
2486
+ GetOperationStatusResponse: Response containing operation status info
2487
+ """
2488
+ # Create Reflexio instance
2489
+ reflexio = get_reflexio(org_id=org_id)
2490
+
2491
+ # Get operation status
2492
+ request = GetOperationStatusRequest(service_name=service_name)
2493
+ return reflexio.get_operation_status(request)
2494
+
2495
+
2496
+ @core_router.post(
2497
+ "/api/cancel_operation",
2498
+ response_model=CancelOperationResponse,
2499
+ response_model_exclude_none=True,
2500
+ )
2501
+ @limiter.limit("10/minute")
2502
+ def cancel_operation_endpoint(
2503
+ request: Request,
2504
+ payload: CancelOperationRequest,
2505
+ org_id: str = Depends(default_get_org_id),
2506
+ ) -> CancelOperationResponse:
2507
+ """Cancel an in-progress operation (rerun or manual generation).
2508
+
2509
+ Args:
2510
+ request (Request): The HTTP request object (for rate limiting)
2511
+ payload (CancelOperationRequest): Request containing optional service_name
2512
+ org_id (str): Organization ID
2513
+
2514
+ Returns:
2515
+ CancelOperationResponse: Response with list of services that were cancelled
2516
+ """
2517
+ reflexio = get_reflexio(org_id=org_id)
2518
+ return reflexio.cancel_operation(payload)
2519
+
2520
+
2521
+ # Paths that should remain publicly accessible (no lock icon in Swagger)
2522
+ _PUBLIC_PATHS = frozenset(
2523
+ {"/", "/health", "/meta/version", "/token", "/docs", "/openapi.json"}
2524
+ )
2525
+ _PUBLIC_PATH_PREFIXES = ("/api/register", "/api/registration-config", "/api/auth/")
2526
+
2527
+
2528
+ def _add_openapi_security(app: FastAPI) -> None:
2529
+ """Inject Bearer auth security scheme into the OpenAPI spec.
2530
+
2531
+ Overrides the default openapi() method to add a global HTTPBearer security
2532
+ requirement while exempting public endpoints (login, register, health, etc.).
2533
+ """
2534
+ original_openapi = app.openapi
2535
+
2536
+ def custom_openapi() -> dict: # type: ignore[type-arg]
2537
+ if app.openapi_schema:
2538
+ return app.openapi_schema
2539
+
2540
+ schema = original_openapi()
2541
+
2542
+ # Add security scheme
2543
+ schema.setdefault("components", {}).setdefault("securitySchemes", {})
2544
+ schema["components"]["securitySchemes"]["BearerAuth"] = {
2545
+ "type": "http",
2546
+ "scheme": "bearer",
2547
+ "description": "API key or JWT token. Pass as: Authorization: Bearer <token>",
2548
+ }
2549
+
2550
+ # Apply security globally, then remove from public endpoints
2551
+ for path, methods in schema.get("paths", {}).items():
2552
+ is_public = path in _PUBLIC_PATHS or any(
2553
+ path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES
2554
+ )
2555
+ for method_detail in methods.values():
2556
+ if isinstance(method_detail, dict):
2557
+ if is_public:
2558
+ method_detail["security"] = []
2559
+ else:
2560
+ method_detail.setdefault("security", [{"BearerAuth": []}])
2561
+
2562
+ app.openapi_schema = schema
2563
+ return schema
2564
+
2565
+ app.openapi = custom_openapi # type: ignore[method-assign]
2566
+
2567
+
2568
+ def create_app(
2569
+ get_org_id: Callable[..., str] | None = None,
2570
+ additional_routers: list[APIRouter] | None = None,
2571
+ middleware_config: dict | None = None,
2572
+ require_auth: bool = False,
2573
+ ) -> FastAPI:
2574
+ """Factory to create a FastAPI app.
2575
+
2576
+ Args:
2577
+ get_org_id: Custom dependency for resolving org_id (e.g., from JWT auth).
2578
+ When provided, overrides the default_get_org_id dependency globally.
2579
+ additional_routers: Extra APIRouter instances (e.g., enterprise login/oauth).
2580
+ middleware_config: Optional middleware overrides (not used yet, reserved for future).
2581
+ require_auth: When True, declares a Bearer security scheme in the OpenAPI spec
2582
+ so Swagger UI shows lock icons and the Authorize button works.
2583
+
2584
+ Returns:
2585
+ Configured FastAPI application.
2586
+ """
2587
+ from collections.abc import AsyncIterator
2588
+ from contextlib import asynccontextmanager
2589
+
2590
+ from reflexio.server._auth import default_get_org_id
2591
+ from reflexio.server.api_endpoints.request_context import RequestContext
2592
+ from reflexio.server.llm.model_defaults import validate_llm_availability
2593
+ from reflexio.server.services.extraction.resume_scheduler import (
2594
+ maybe_start_resume_scheduler,
2595
+ )
2596
+
2597
+ def _lifespan_org_id() -> str:
2598
+ if get_org_id is None:
2599
+ return default_get_org_id()
2600
+ try:
2601
+ signature = inspect.signature(get_org_id)
2602
+ except (TypeError, ValueError):
2603
+ return default_get_org_id()
2604
+ if signature.parameters:
2605
+ return default_get_org_id()
2606
+ try:
2607
+ return str(get_org_id())
2608
+ except Exception:
2609
+ logger.exception("Failed to resolve lifespan org_id; using default org")
2610
+ return default_get_org_id()
2611
+
2612
+ @asynccontextmanager
2613
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001
2614
+ validate_llm_availability()
2615
+ bootstrap_org_id = _lifespan_org_id()
2616
+ bootstrap_context = RequestContext(org_id=bootstrap_org_id)
2617
+ retrieval_floor = bootstrap_context.configurator.get_config().retrieval_floor
2618
+ if retrieval_floor.enabled:
2619
+ from reflexio.server.llm.rerank import prewarm as _prewarm_cross_encoder
2620
+
2621
+ _prewarm_cross_encoder()
2622
+ else:
2623
+ logger.info(
2624
+ "Cross-encoder pre-warm skipped because retrieval_floor.enabled=false"
2625
+ )
2626
+ # The scheduler discovers every org with resumable work each tick and
2627
+ # drives a per-org worker with org-scoped claims, so it is not limited
2628
+ # to the bootstrap org. The bootstrap org is only used to read config
2629
+ # and to seed cross-org discovery.
2630
+ scheduler = maybe_start_resume_scheduler(
2631
+ lambda org_id: RequestContext(org_id=org_id),
2632
+ bootstrap_org_id=bootstrap_org_id,
2633
+ )
2634
+ try:
2635
+ yield
2636
+ finally:
2637
+ if scheduler is not None:
2638
+ scheduler.stop()
2639
+
2640
+ app = FastAPI(docs_url="/docs", lifespan=lifespan)
2641
+
2642
+ if require_auth:
2643
+ _add_openapi_security(app)
2644
+
2645
+ @app.get("/meta/version")
2646
+ def get_version_info() -> dict[str, str]:
2647
+ from importlib.metadata import PackageNotFoundError, version
2648
+
2649
+ try:
2650
+ server_version = version("reflexio")
2651
+ except PackageNotFoundError:
2652
+ server_version = "0.0.0-dev"
2653
+ return {
2654
+ "server_version": server_version,
2655
+ "api_version": "v1",
2656
+ "min_client_version": "0.1.0",
2657
+ }
2658
+
2659
+ # Configure rate limiter
2660
+ app.state.limiter = limiter
2661
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[reportArgumentType]
2662
+
2663
+ # CORS
2664
+ origins = ["*"]
2665
+ app.add_middleware(
2666
+ CORSMiddleware,
2667
+ allow_origins=origins,
2668
+ allow_credentials=True,
2669
+ allow_methods=["*"],
2670
+ allow_headers=["*"],
2671
+ )
2672
+
2673
+ # Timeout middleware
2674
+ app.add_middleware(TimeoutMiddleware)
2675
+
2676
+ # Bot protection
2677
+ app.add_middleware(BotProtectionMiddleware)
2678
+
2679
+ # Correlation ID — added last so it runs outermost (Starlette reverses order)
2680
+ app.add_middleware(CorrelationIdMiddleware)
2681
+
2682
+ # Override get_org_id dependency if custom one provided
2683
+ if get_org_id is not None:
2684
+ app.dependency_overrides[default_get_org_id] = get_org_id
2685
+
2686
+ # When a custom get_org_id is provided together with require_auth,
2687
+ # auth is enforced on every route — mark this app instance so the
2688
+ # token-gated my_config endpoint becomes reachable. Using
2689
+ # ``app.state`` instead of a module-level global keeps the gate
2690
+ # scoped to this FastAPI instance, so multiple apps (e.g. tests,
2691
+ # multi-tenant embeddings) can coexist without leaking state.
2692
+ app.state.my_config_enabled = bool(get_org_id is not None and require_auth)
2693
+
2694
+ # Include core routes
2695
+ app.include_router(core_router)
2696
+
2697
+ # Include stall_state routes
2698
+ app.include_router(stall_state_api.router)
2699
+
2700
+ # Include pending tool call routes
2701
+ app.include_router(pending_tool_call_api.router)
2702
+
2703
+ # Include additional routers
2704
+ for router in additional_routers or []:
2705
+ app.include_router(router)
2706
+
2707
+ # Health/observability endpoint (per-worker metrics for recycling)
2708
+ health_api.install(app)
2709
+
2710
+ return app
2711
+
2712
+
2713
+ # Default standalone app (no auth)
2714
+ app = create_app()