claude-smart 0.2.46 → 0.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -11
  3. package/bin/claude-smart.js +290 -68
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +11 -10
  8. package/plugin/dashboard/app/layout.tsx +20 -0
  9. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  10. package/plugin/dashboard/package-lock.json +61 -390
  11. package/plugin/dashboard/package.json +2 -2
  12. package/plugin/opencode/dist/server.mjs +76 -2
  13. package/plugin/opencode/server.mts +79 -2
  14. package/plugin/pyproject.toml +6 -2
  15. package/plugin/scripts/smart-install.sh +7 -1
  16. package/plugin/src/claude_smart/cli.py +210 -22
  17. package/plugin/src/claude_smart/context_format.py +9 -9
  18. package/plugin/src/claude_smart/cs_cite.py +66 -19
  19. package/plugin/uv.lock +5 -5
  20. package/plugin/vendor/reflexio/.env.example +7 -0
  21. package/plugin/vendor/reflexio/README.md +3 -3
  22. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  23. package/plugin/vendor/reflexio/reflexio/README.md +11 -6
  24. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  25. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  26. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  27. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  28. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  29. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  30. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  31. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  32. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  33. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  34. package/plugin/vendor/reflexio/reflexio/lib/_search.py +27 -5
  35. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  36. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  37. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  38. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  39. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  40. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +37 -0
  41. package/plugin/vendor/reflexio/reflexio/server/README.md +38 -9
  42. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  43. package/plugin/vendor/reflexio/reflexio/server/api.py +274 -3267
  44. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  45. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  46. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  47. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  48. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  49. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  50. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  51. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  52. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  53. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  54. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  55. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  56. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  57. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  58. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  59. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +57 -5
  60. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  61. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  62. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  63. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  64. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  67. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  68. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  69. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  70. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  71. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  72. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  73. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  74. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  75. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  76. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  77. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/README.md +5 -2
  79. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  87. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  88. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  89. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  91. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +385 -78
  95. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  96. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +121 -525
  98. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  102. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  103. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +43 -6
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +58 -351
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +452 -0
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -4
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1126
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +955 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +844 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -9
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +57 -86
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  169. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  170. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
@@ -1,3258 +1,100 @@
1
- import asyncio
2
- import inspect
3
- import logging
4
- import os
5
- import time
6
- from collections.abc import Callable
7
- from concurrent.futures import ThreadPoolExecutor
8
- from datetime import UTC, datetime
9
- from typing import Any
10
-
11
- from anyio.to_thread import current_default_thread_limiter
12
- from fastapi import (
13
- APIRouter,
14
- BackgroundTasks,
15
- Depends,
16
- FastAPI,
17
- HTTPException,
18
- Request,
19
- status,
20
- )
21
- from fastapi.middleware.cors import CORSMiddleware
22
- from slowapi import Limiter, _rate_limit_exceeded_handler
23
- from slowapi.errors import RateLimitExceeded
24
- from slowapi.util import get_remote_address
25
- from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
26
- from starlette.responses import Response
27
- from starlette.types import ASGIApp, Message, Receive, Scope, Send
28
-
29
- from reflexio.models.api_schema.braintrust_schema import (
30
- BraintrustStatusResponse,
31
- ConnectBraintrustRequest,
32
- ConnectBraintrustResponse,
33
- SelectProjectsRequest,
34
- SelectProjectsResponse,
35
- SyncBraintrustResponse,
36
- )
37
- from reflexio.models.api_schema.eval_overview_schema import (
38
- GetEvaluationOverviewRequest,
39
- GetEvaluationOverviewResponse,
40
- GetRecentShadowComparisonsResponse,
41
- GradeOnDemandRequest,
42
- GradeOnDemandResponse,
43
- RegenerateFailure,
44
- RegenerateRequest,
45
- RegenerateStartResponse,
46
- RegenerateStatusResponse,
47
- )
48
- from reflexio.models.api_schema.retriever_schema import (
49
- GetAgentPlaybooksRequest,
50
- GetAgentPlaybooksViewResponse,
51
- GetAgentSuccessEvaluationResultsRequest,
52
- GetDashboardStatsRequest,
53
- GetDashboardStatsResponse,
54
- GetEvaluationResultsViewResponse,
55
- GetInteractionsRequest,
56
- GetInteractionsViewResponse,
57
- GetLearningProvenanceRequest,
58
- GetPlaybookApplicationStatsRequest,
59
- GetPlaybookApplicationStatsResponse,
60
- GetProfileStatisticsResponse,
61
- GetProfilesViewResponse,
62
- GetRequestsRequest,
63
- GetRequestsViewResponse,
64
- GetUserPlaybooksRequest,
65
- GetUserPlaybooksViewResponse,
66
- GetUserProfilesRequest,
67
- LearningProvenanceViewResponse,
68
- ProfileChangeLogViewResponse,
69
- RequestDataView,
70
- RerankUserProfilesRequest,
71
- SearchAgentPlaybookRequest,
72
- SearchAgentPlaybooksViewResponse,
73
- SearchInteractionRequest,
74
- SearchInteractionsViewResponse,
75
- SearchProfilesViewResponse,
76
- SearchUserPlaybookRequest,
77
- SearchUserPlaybooksViewResponse,
78
- SearchUserProfileRequest,
79
- SessionView,
80
- SetConfigResponse,
81
- SourceUserPlaybookProvenanceView,
82
- StorageStatsRequest,
83
- StorageStatsResponse,
84
- UnifiedSearchRequest,
85
- UnifiedSearchViewResponse,
86
- UpdateAgentPlaybookRequest,
87
- UpdateAgentPlaybookResponse,
88
- UpdatePlaybookStatusRequest,
89
- UpdatePlaybookStatusResponse,
90
- UpdateUserPlaybookRequest,
91
- UpdateUserPlaybookResponse,
92
- UpdateUserProfileRequest,
93
- UpdateUserProfileResponse,
94
- )
95
- from reflexio.models.api_schema.service_schemas import (
96
- AddAgentPlaybookRequest,
97
- AddAgentPlaybookResponse,
98
- AddUserPlaybookRequest,
99
- AddUserPlaybookResponse,
100
- AddUserProfileRequest,
101
- AddUserProfileResponse,
102
- AdminInvalidateCacheRequest,
103
- AdminInvalidateCacheResponse,
104
- BulkDeleteResponse,
105
- CancelOperationRequest,
106
- CancelOperationResponse,
107
- ClearUserDataRequest,
108
- ClearUserDataResponse,
109
- DeleteAgentPlaybookRequest,
110
- DeleteAgentPlaybookResponse,
111
- DeleteAgentPlaybooksByIdsRequest,
112
- DeleteProfilesByIdsRequest,
113
- DeleteRequestRequest,
114
- DeleteRequestResponse,
115
- DeleteRequestsByIdsRequest,
116
- DeleteSessionRequest,
117
- DeleteSessionResponse,
118
- DeleteUserInteractionRequest,
119
- DeleteUserInteractionResponse,
120
- DeleteUserPlaybookRequest,
121
- DeleteUserPlaybookResponse,
122
- DeleteUserPlaybooksByIdsRequest,
123
- DeleteUserProfileRequest,
124
- DeleteUserProfileResponse,
125
- DowngradeProfilesRequest,
126
- DowngradeProfilesResponse,
127
- DowngradeUserPlaybooksRequest,
128
- DowngradeUserPlaybooksResponse,
129
- GetOperationStatusRequest,
130
- GetOperationStatusResponse,
131
- ManualPlaybookGenerationRequest,
132
- ManualPlaybookGenerationResponse,
133
- ManualProfileGenerationRequest,
134
- ManualProfileGenerationResponse,
135
- MyConfigResponse,
136
- PlaybookAggregationChangeLogResponse,
137
- PublishUserInteractionRequest,
138
- PublishUserInteractionResponse,
139
- RerunPlaybookGenerationRequest,
140
- RerunPlaybookGenerationResponse,
141
- RerunProfileGenerationRequest,
142
- RerunProfileGenerationResponse,
143
- RunPlaybookAggregationRequest,
144
- RunPlaybookAggregationResponse,
145
- Status,
146
- UpgradeProfilesRequest,
147
- UpgradeProfilesResponse,
148
- UpgradeUserPlaybooksRequest,
149
- UpgradeUserPlaybooksResponse,
150
- WhoamiResponse,
151
- )
152
- from reflexio.models.api_schema.ui.converters import (
153
- to_agent_playbook_view,
154
- to_evaluation_result_view,
155
- to_interaction_view,
156
- to_profile_change_log_view,
157
- to_profile_view,
158
- to_user_playbook_view,
159
- )
160
- from reflexio.models.config_schema import (
161
- DEFAULT_WINDOW_SIZE,
162
- SINGLETON_AGENT_SUCCESS_EVALUATION_NAME,
163
- Config,
164
- )
165
- from reflexio.server._auth import (
166
- DEFAULT_ORG_ID,
167
- default_billing_gate,
168
- default_get_caller_type,
169
- default_get_org_id,
170
- )
171
- from reflexio.server.api_endpoints import (
172
- account_api,
173
- health_api,
174
- pending_tool_call_api,
175
- publisher_api,
176
- stall_state_api,
177
- )
178
- from reflexio.server.cache.reflexio_cache import (
179
- get_reflexio,
180
- invalidate_reflexio_cache,
181
- )
182
- from reflexio.server.correlation import correlation_id_var, generate_correlation_id
183
- from reflexio.server.operation_limiter import (
184
- OperationName,
185
- limiter_http_exception,
186
- log_publish_hardware_capacity,
187
- run_with_operation_limit,
188
- )
189
- from reflexio.server.services.agent_success_evaluation.regen_jobs import (
190
- REGEN_JOBS,
191
- run_regen,
192
- )
193
- from reflexio.server.services.agent_success_evaluation.runner import (
194
- run_group_evaluation,
195
- )
196
- from reflexio.server.services.extractor_interaction_utils import (
197
- get_effective_source_filter,
198
- get_extractor_window_params,
199
- )
200
- from reflexio.server.tracing import profile_step
201
-
202
- logger = logging.getLogger(__name__)
203
-
204
- # Re-exported for backwards compatibility — callers that did
205
- # ``from reflexio.server.api import default_get_org_id`` or ``DEFAULT_ORG_ID``
206
- # continue to work.
207
- __all__ = [
208
- "DEFAULT_ORG_ID",
209
- "create_app",
210
- "default_billing_gate",
211
- "default_get_caller_type",
212
- "default_get_org_id",
213
- ]
214
-
215
- # Bot protection configuration
216
- REQUEST_TIMEOUT_SECONDS = 60
217
- SYNC_REQUEST_TIMEOUT_SECONDS = (
218
- 600 # Longer timeout for synchronous processing (wait_for_response=true)
219
- )
220
- SUSPICIOUS_USER_AGENTS = ["bot", "crawler", "spider", "scraper", "curl", "wget"]
221
- ALLOWED_EMPTY_UA_PATHS = ["/health", "/"] # Paths that allow empty user agents
222
- DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
223
- REGENERATE_MAX_WORKERS = 2
224
- _regen_executor = ThreadPoolExecutor(
225
- max_workers=REGENERATE_MAX_WORKERS,
226
- thread_name_prefix="reflexio-regen",
227
- )
228
-
229
-
230
- def _resolve_cors_origins() -> list[str]:
231
- """Resolve browser origins allowed to make credentialed CORS requests."""
232
- configured_origins = os.getenv("REFLEXIO_ALLOWED_ORIGINS", "").strip()
233
- if configured_origins:
234
- origins = [
235
- origin.strip().rstrip("/")
236
- for origin in configured_origins.split(",")
237
- if origin.strip()
238
- ]
239
- return origins or ["http://localhost:8080"]
240
-
241
- frontend_url = os.getenv("FRONTEND_URL", "").strip()
242
- if frontend_url:
243
- return [frontend_url.rstrip("/")]
244
-
245
- return ["http://localhost:8080"]
246
-
247
-
248
- def _max_body_bytes_from_env() -> int:
249
- raw_value = os.getenv("REFLEXIO_MAX_BODY_BYTES", str(DEFAULT_MAX_BODY_BYTES))
250
- try:
251
- max_bytes = int(raw_value)
252
- except ValueError:
253
- logger.warning(
254
- "Ignoring invalid REFLEXIO_MAX_BODY_BYTES=%r; using %s",
255
- raw_value,
256
- DEFAULT_MAX_BODY_BYTES,
257
- )
258
- return DEFAULT_MAX_BODY_BYTES
259
- if max_bytes <= 0:
260
- logger.warning(
261
- "Ignoring non-positive REFLEXIO_MAX_BODY_BYTES=%r; using %s",
262
- raw_value,
263
- DEFAULT_MAX_BODY_BYTES,
264
- )
265
- return DEFAULT_MAX_BODY_BYTES
266
- return max_bytes
267
-
268
-
269
- def get_rate_limit_key(request: Request) -> str:
270
- """Get rate limit key based on IP address.
271
-
272
- Args:
273
- request (Request): The incoming request
274
-
275
- Returns:
276
- str: Rate limit key (IP address)
277
- """
278
- return get_remote_address(request)
279
-
280
-
281
- def _storage_backend_name(limiter_obj: Limiter) -> str:
282
- storage = getattr(limiter_obj, "_storage", None)
283
- if storage is None:
284
- return "unknown"
285
- return storage.__class__.__name__
286
-
287
-
288
- def _trace_external_rate_limit_backend(limiter_obj: Limiter) -> None:
289
- """Trace rate-limit storage hits when the backend is an external service."""
290
- backend = _storage_backend_name(limiter_obj)
291
- if backend == "MemoryStorage":
292
- return
293
-
294
- strategy = getattr(limiter_obj, "limiter", None)
295
- if strategy is None or getattr(strategy, "_reflexio_traced", False):
296
- return
297
-
298
- original_hit = strategy.hit
299
-
300
- def traced_hit(item: Any, *identifiers: str, cost: int = 1) -> bool:
301
- with profile_step(
302
- "rate_limit.backend_hit",
303
- storage_backend=backend,
304
- strategy=strategy.__class__.__name__,
305
- cost=cost,
306
- ):
307
- return original_hit(item, *identifiers, cost=cost)
308
-
309
- strategy.hit = traced_hit
310
- strategy._reflexio_traced = True
311
-
312
-
313
- # Initialize rate limiter
314
- limiter = Limiter(key_func=get_rate_limit_key)
315
- _trace_external_rate_limit_backend(limiter)
316
-
317
-
318
- def _run_limited_api[T](
319
- org_id: str,
320
- operation: OperationName,
321
- fn: Callable[[], T],
322
- ) -> T:
323
- try:
324
- return run_with_operation_limit(
325
- org_id=org_id,
326
- operation=operation,
327
- fn=fn,
328
- )
329
- except TimeoutError as exc:
330
- http_exc = limiter_http_exception(operation)
331
- raise http_exc from exc
332
-
333
-
334
- def configure_rate_limiter(key_func: Callable[..., str]) -> None:
335
- """
336
- Replace the rate limiter's key function.
337
-
338
- This is the supported way to override the default IP-based key function
339
- (e.g. with an org-scoped or token-scoped variant in the enterprise layer).
340
-
341
- Args:
342
- key_func: A callable that accepts a Request and returns a string key.
343
- """
344
- limiter._key_func = key_func # type: ignore[reportAttributeAccessIssue]
345
-
346
-
347
- class BotProtectionMiddleware(BaseHTTPMiddleware):
348
- """Middleware to detect and block suspicious bot-like requests."""
349
-
350
- async def dispatch(
351
- self, request: Request, call_next: RequestResponseEndpoint
352
- ) -> Response:
353
- """Process request and block suspicious patterns.
354
-
355
- Args:
356
- request (Request): The incoming request
357
- call_next (RequestResponseEndpoint): Next middleware/handler in chain
358
-
359
- Returns:
360
- Response: The response from the next handler or a 403 JSON response
361
- """
362
- from starlette.responses import JSONResponse
363
-
364
- user_agent = request.headers.get("user-agent", "").lower()
365
- path = request.url.path
366
-
367
- # Allow health check and root without user agent
368
- if path not in ALLOWED_EMPTY_UA_PATHS:
369
- # Block requests with no user agent
370
- if not user_agent:
371
- return JSONResponse(
372
- status_code=status.HTTP_403_FORBIDDEN,
373
- content={"detail": "Forbidden: Missing user agent"},
374
- )
375
-
376
- # Block requests with suspicious user agents
377
- for suspicious in SUSPICIOUS_USER_AGENTS:
378
- if suspicious in user_agent:
379
- return JSONResponse(
380
- status_code=status.HTTP_403_FORBIDDEN,
381
- content={"detail": "Forbidden: Suspicious user agent"},
382
- )
383
-
384
- return await call_next(request)
385
-
386
-
387
- class TimeoutMiddleware(BaseHTTPMiddleware):
388
- """Middleware to enforce request timeout."""
389
-
390
- async def dispatch(
391
- self, request: Request, call_next: RequestResponseEndpoint
392
- ) -> Response:
393
- """Process request with timeout enforcement.
394
-
395
- Args:
396
- request (Request): The incoming request
397
- call_next (RequestResponseEndpoint): Next middleware/handler in chain
398
-
399
- Returns:
400
- Response: The response from the next handler or a 504 JSON response
401
- """
402
- from starlette.responses import JSONResponse
403
-
404
- # Use longer timeout for synchronous processing requests
405
- timeout = REQUEST_TIMEOUT_SECONDS
406
- if request.query_params.get("wait_for_response", "").lower() == "true":
407
- timeout = SYNC_REQUEST_TIMEOUT_SECONDS
408
-
409
- try:
410
- return await asyncio.wait_for(call_next(request), timeout=timeout)
411
- except TimeoutError:
412
- return JSONResponse(
413
- status_code=status.HTTP_504_GATEWAY_TIMEOUT,
414
- content={"detail": "Request timeout"},
415
- )
416
-
417
-
418
- class _RequestBodyTooLargeError(Exception):
419
- """Raised when the streamed request body exceeds the configured limit."""
420
-
421
-
422
- class BodySizeLimitMiddleware:
423
- """Reject requests whose declared or streamed body size exceeds the limit."""
424
-
425
- def __init__(self, app: ASGIApp) -> None:
426
- self.app = app
427
-
428
- async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
429
- from starlette.responses import JSONResponse
430
-
431
- if scope["type"] != "http":
432
- await self.app(scope, receive, send)
433
- return
434
-
435
- max_body_bytes = _max_body_bytes_from_env()
436
- content_length = None
437
- for name, value in scope.get("headers", []):
438
- if name.lower() == b"content-length":
439
- content_length = value.decode("latin-1")
440
- break
441
-
442
- if content_length is not None:
443
- try:
444
- body_bytes = int(content_length)
445
- except ValueError:
446
- body_bytes = 0
447
- if body_bytes > max_body_bytes:
448
- await JSONResponse(
449
- status_code=status.HTTP_413_CONTENT_TOO_LARGE,
450
- content={"detail": "Request body too large"},
451
- )(scope, receive, send)
452
- return
453
-
454
- consumed_bytes = 0
455
-
456
- async def limited_receive() -> Message:
457
- nonlocal consumed_bytes
458
- message = await receive()
459
- if message["type"] == "http.request":
460
- consumed_bytes += len(message.get("body", b""))
461
- if consumed_bytes > max_body_bytes:
462
- raise _RequestBodyTooLargeError
463
- return message
464
-
465
- try:
466
- await self.app(scope, limited_receive, send)
467
- except _RequestBodyTooLargeError:
468
- await JSONResponse(
469
- status_code=status.HTTP_413_CONTENT_TOO_LARGE,
470
- content={"detail": "Request body too large"},
471
- )(scope, receive, send)
472
-
473
-
474
- class SecurityHeadersMiddleware(BaseHTTPMiddleware):
475
- """Attach conservative browser security headers to every response."""
476
-
477
- async def dispatch(
478
- self, request: Request, call_next: RequestResponseEndpoint
479
- ) -> Response:
480
- response = await call_next(request)
481
- response.headers["X-Content-Type-Options"] = "nosniff"
482
- response.headers["X-Frame-Options"] = "DENY"
483
- response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
484
- if (
485
- request.url.scheme == "https"
486
- or request.headers.get("x-forwarded-proto", "").lower() == "https"
487
- ):
488
- response.headers["Strict-Transport-Security"] = (
489
- "max-age=31536000; includeSubDomains"
490
- )
491
- return response
492
-
493
-
494
- class CorrelationIdMiddleware(BaseHTTPMiddleware):
495
- """Middleware that assigns a unique correlation ID to each request.
496
-
497
- The ID is stored in a ContextVar so it propagates to log records
498
- (via CorrelationIdFilter) and to ThreadPoolExecutor workers when
499
- ``contextvars.copy_context()`` is used.
500
- """
501
-
502
- async def dispatch(
503
- self, request: Request, call_next: RequestResponseEndpoint
504
- ) -> Response:
505
- cid = generate_correlation_id()
506
- correlation_id_var.set(cid)
507
- try:
508
- stats = current_default_thread_limiter().statistics()
509
- request.state.tp_borrowed = stats.borrowed_tokens
510
- request.state.tp_total = stats.total_tokens
511
- request.state.tp_waiting = stats.tasks_waiting
512
- except Exception as exc: # noqa: BLE001
513
- logger.debug("Failed to snapshot threadpool limiter stats: %s", exc)
514
- request.state.tp_borrowed = None
515
- request.state.tp_total = None
516
- request.state.tp_waiting = None
517
- response = await call_next(request)
518
- response.headers["X-Correlation-ID"] = cid
519
- return response
520
-
521
-
522
- core_router = APIRouter()
523
-
524
-
525
- def _stamp_search_dependencies_done(request: Request) -> None:
526
- """Stamp when dependency resolution reaches its final search dependency."""
527
- request.state.search_deps_done_monotonic = time.monotonic()
528
-
529
-
530
- def _meter_applied_learnings(
531
- *,
532
- org_id: str,
533
- caller_type: str,
534
- surfaced_count: int,
535
- request_id: str | None = None,
536
- session_id: str | None = None,
537
- ) -> None:
538
- """Emit the ③ Application event via the OSS emission helper.
539
-
540
- No-op unless a production-agent caller surfaced >= 1 result. The cheap
541
- caller-type and count guard runs first so the get_reflexio / config lookup
542
- is skipped on the free paths (dashboard / empty result).
543
-
544
- Args:
545
- org_id: Organization ID for the requesting caller.
546
- caller_type: Resolved caller classification (e.g. ``"production_agent"``).
547
- surfaced_count: Total number of learnings returned to the caller.
548
- request_id: Optional request correlation ID from the payload.
549
- session_id: Optional session ID from the payload.
550
- """
551
- if caller_type != "production_agent" or surfaced_count <= 0:
552
- return
553
- try:
554
- from reflexio.server.billing_meter import record_applied_learnings
555
- from reflexio.server.billing_signals import platform_llm_from_config
556
-
557
- config = get_reflexio(org_id=org_id).request_context.configurator.get_config()
558
- record_applied_learnings(
559
- org_id=org_id,
560
- surfaced_count=surfaced_count,
561
- caller_type=caller_type,
562
- platform_llm=platform_llm_from_config(config),
563
- platform_storage=None, # resolved enterprise-side at rollup (Phase 1)
564
- request_id=request_id,
565
- session_id=session_id,
566
- )
567
- except Exception:
568
- logger.warning(
569
- "applied-learnings metering failed for org %s", org_id, exc_info=True
570
- )
571
-
572
-
573
- def _meter_search_request(
574
- *,
575
- org_id: str,
576
- caller_type: str,
577
- request_id: str | None = None,
578
- session_id: str | None = None,
579
- ) -> None:
580
- """Emit one production-agent search request metric.
581
-
582
- Args:
583
- org_id: Organization ID for the requesting caller.
584
- caller_type: Resolved caller classification.
585
- request_id: Optional request correlation ID from the payload.
586
- session_id: Optional session ID from the payload.
587
- """
588
- if caller_type != "production_agent":
589
- return
590
- try:
591
- from reflexio.server.billing_meter import record_search_request
592
-
593
- record_search_request(
594
- org_id=org_id,
595
- caller_type=caller_type,
596
- request_id=request_id,
597
- session_id=session_id,
598
- )
599
- except Exception:
600
- logger.warning(
601
- "search-request metering failed for org %s", org_id, exc_info=True
602
- )
603
-
604
-
605
- @core_router.get("/")
606
- def root() -> dict[str, str]:
607
- return {
608
- "service": "Reflexio API",
609
- "docs": "/docs",
610
- "health": "/health",
611
- }
612
-
613
-
614
- @core_router.get("/health")
615
- async def health_check() -> dict[str, str]:
616
- """Health check endpoint for ECS/container orchestration."""
617
- return {"status": "healthy"}
618
-
619
-
620
- @core_router.get(
621
- "/api/whoami",
622
- response_model=WhoamiResponse,
623
- response_model_exclude_none=True,
624
- )
625
- def whoami_endpoint(
626
- org_id: str = Depends(default_get_org_id),
627
- ) -> WhoamiResponse:
628
- """Return the caller's org and masked storage routing.
629
-
630
- Powers ``reflexio status``. Safe to call unauthenticated in
631
- self-host mode; the enterprise server wraps this in Bearer auth.
632
- """
633
- return account_api.whoami(org_id=org_id)
634
-
635
-
636
- @core_router.get(
637
- "/api/my_config",
638
- response_model=MyConfigResponse,
639
- response_model_exclude_none=True,
640
- )
641
- def my_config_endpoint(
642
- request: Request,
643
- org_id: str = Depends(default_get_org_id),
644
- ) -> MyConfigResponse:
645
- """Return raw storage credentials for the caller's org.
646
-
647
- Enablement is controlled by two independent opt-ins so the endpoint
648
- is closed by default on unauthenticated self-host deployments:
649
-
650
- - ``request.app.state.my_config_enabled`` — set to True by
651
- :func:`create_app` when the host wires in a Bearer-auth
652
- ``get_org_id`` dependency, so enterprise callers are always
653
- authenticated before they reach this route.
654
- - ``REFLEXIO_ALLOW_MY_CONFIG=true`` — OS self-host escape hatch.
655
-
656
- If neither is set we return a closed response instead of a 404 so
657
- the CLI can display an actionable hint.
658
- """
659
- app_state_enabled = bool(getattr(request.app.state, "my_config_enabled", False))
660
- if not (app_state_enabled or account_api.my_config_allowed()):
661
- return MyConfigResponse(
662
- success=False,
663
- message=(
664
- "GET /api/my_config is disabled. Set "
665
- "REFLEXIO_ALLOW_MY_CONFIG=true to enable."
666
- ),
667
- )
668
- return account_api.my_config(org_id=org_id)
669
-
670
-
671
- @core_router.post(
672
- "/api/publish_interaction",
673
- response_model=PublishUserInteractionResponse,
674
- response_model_exclude_none=True,
675
- )
676
- @limiter.limit("60/minute") # Rate limit for write operations
677
- def publish_user_interaction(
678
- request: Request,
679
- payload: PublishUserInteractionRequest,
680
- background_tasks: BackgroundTasks,
681
- org_id: str = Depends(default_get_org_id),
682
- wait_for_response: bool = False,
683
- _gate: None = Depends(default_billing_gate("learnings_generated")), # noqa: B008
684
- ) -> PublishUserInteractionResponse:
685
- if wait_for_response:
686
- # Process synchronously so the caller gets the real result
687
- return _run_limited_api(
688
- org_id,
689
- "publish",
690
- lambda: publisher_api.add_user_interaction(org_id=org_id, request=payload),
691
- )
692
-
693
- def _limited_publish_task() -> None:
694
- try:
695
- run_with_operation_limit(
696
- org_id=org_id,
697
- operation="publish",
698
- fn=lambda: publisher_api.add_user_interaction(
699
- org_id=org_id, request=payload
700
- ),
701
- wait_forever=False,
702
- )
703
- except TimeoutError:
704
- logger.warning(
705
- "Dropped queued publish for org %s because publish limiter is saturated",
706
- org_id,
707
- )
708
-
709
- # Run in background — caller gets immediate acknowledgement
710
- background_tasks.add_task(_limited_publish_task)
711
- return PublishUserInteractionResponse(
712
- success=True, message="Interaction queued for processing"
713
- )
714
-
715
-
716
- @core_router.post(
717
- "/api/add_user_playbook",
718
- response_model=AddUserPlaybookResponse,
719
- response_model_exclude_none=True,
720
- )
721
- @limiter.limit("60/minute") # Rate limit for write operations
722
- def add_user_playbook_endpoint(
723
- request: Request,
724
- payload: AddUserPlaybookRequest,
725
- org_id: str = Depends(default_get_org_id),
726
- ) -> AddUserPlaybookResponse:
727
- """Add user playbook directly to storage.
728
-
729
- Args:
730
- request (Request): The HTTP request object (for rate limiting)
731
- payload (AddUserPlaybookRequest): The request containing user playbooks
732
- org_id (str): Organization ID
733
-
734
- Returns:
735
- AddUserPlaybookResponse: Response containing success status, message, and added count
736
- """
737
- return publisher_api.add_user_playbook(org_id=org_id, request=payload)
738
-
739
-
740
- @core_router.post(
741
- "/api/add_agent_playbook",
742
- response_model=AddAgentPlaybookResponse,
743
- response_model_exclude_none=True,
744
- )
745
- @limiter.limit("60/minute") # Rate limit for write operations
746
- def add_agent_playbook_endpoint(
747
- request: Request,
748
- payload: AddAgentPlaybookRequest,
749
- org_id: str = Depends(default_get_org_id),
750
- ) -> AddAgentPlaybookResponse:
751
- """Add agent playbook directly to storage.
752
-
753
- Args:
754
- request (Request): The HTTP request object (for rate limiting)
755
- payload (AddAgentPlaybookRequest): The request containing agent playbooks
756
- org_id (str): Organization ID
757
-
758
- Returns:
759
- AddAgentPlaybookResponse: Response containing success status, message, and added count
760
- """
761
- return publisher_api.add_agent_playbook(org_id=org_id, request=payload)
762
-
763
-
764
- @core_router.post(
765
- "/api/add_user_profile",
766
- response_model=AddUserProfileResponse,
767
- response_model_exclude_none=True,
768
- )
769
- @limiter.limit("60/minute") # Rate limit for write operations
770
- def add_user_profile_endpoint(
771
- request: Request,
772
- payload: AddUserProfileRequest,
773
- org_id: str = Depends(default_get_org_id),
774
- ) -> AddUserProfileResponse:
775
- """Add user profile directly to storage, bypassing inference.
776
-
777
- Args:
778
- request (Request): The HTTP request object (for rate limiting)
779
- payload (AddUserProfileRequest): The request containing user profiles
780
- org_id (str): Organization ID
781
-
782
- Returns:
783
- AddUserProfileResponse: Response containing success status, message, and added count
784
- """
785
- return publisher_api.add_user_profile(org_id=org_id, request=payload)
786
-
787
-
788
- @core_router.post(
789
- "/api/search_profiles",
790
- response_model=SearchProfilesViewResponse,
791
- response_model_exclude_none=True,
792
- )
793
- @limiter.limit("120/minute") # Rate limit for read operations
794
- def search_user_profiles(
795
- request: Request,
796
- payload: SearchUserProfileRequest,
797
- org_id: str = Depends(default_get_org_id),
798
- caller_type: str = Depends(default_get_caller_type),
799
- _gate: None = Depends(default_billing_gate("application")), # noqa: B008
800
- ) -> SearchProfilesViewResponse:
801
- response = _run_limited_api(
802
- org_id,
803
- "search",
804
- lambda: get_reflexio(org_id=org_id).search_user_profiles(payload),
805
- )
806
- resp = SearchProfilesViewResponse(
807
- success=response.success,
808
- user_profiles=[to_profile_view(p) for p in response.user_profiles],
809
- msg=response.msg,
810
- )
811
- _meter_search_request(
812
- org_id=org_id,
813
- caller_type=caller_type,
814
- request_id=getattr(payload, "request_id", None),
815
- session_id=getattr(payload, "session_id", None),
816
- )
817
- _meter_applied_learnings(
818
- org_id=org_id,
819
- caller_type=caller_type,
820
- surfaced_count=len(resp.user_profiles),
821
- request_id=getattr(payload, "request_id", None),
822
- session_id=getattr(payload, "session_id", None),
823
- )
824
- return resp
825
-
826
-
827
- @core_router.post(
828
- "/api/rerank_user_profiles",
829
- response_model=SearchProfilesViewResponse,
830
- response_model_exclude_none=True,
831
- )
832
- @limiter.limit("120/minute") # Rate limit for read operations
833
- def rerank_user_profiles(
834
- request: Request,
835
- payload: RerankUserProfilesRequest,
836
- org_id: str = Depends(default_get_org_id),
837
- ) -> SearchProfilesViewResponse:
838
- """Rerank a list of profile ids by query relevance using a cross-encoder.
839
-
840
- Args:
841
- request (Request): The HTTP request object (for rate limiting)
842
- payload (RerankUserProfilesRequest): The rerank request
843
- org_id (str): Organization ID
844
-
845
- Returns:
846
- SearchProfilesViewResponse: Reranked profiles, top_k entries.
847
- """
848
- response = _run_limited_api(
849
- org_id,
850
- "search",
851
- lambda: get_reflexio(org_id=org_id).rerank_user_profiles(payload),
852
- )
853
- return SearchProfilesViewResponse(
854
- success=response.success,
855
- user_profiles=[to_profile_view(p) for p in response.user_profiles],
856
- msg=response.msg,
857
- )
858
-
859
-
860
- @core_router.get(
861
- "/api/storage_stats",
862
- response_model=StorageStatsResponse,
863
- response_model_exclude_none=True,
864
- )
865
- @limiter.limit("120/minute") # Rate limit for read operations
866
- def storage_stats(
867
- request: Request,
868
- user_id: str,
869
- org_id: str = Depends(default_get_org_id),
870
- ) -> StorageStatsResponse:
871
- """Return lightweight metadata about a user's profiles and playbooks.
872
-
873
- Args:
874
- request (Request): The HTTP request object (for rate limiting)
875
- user_id (str): Target user id, passed as a query parameter so this is
876
- a cacheable, idempotent GET.
877
- org_id (str): Organization ID
878
-
879
- Returns:
880
- StorageStatsResponse: Counts and timestamp range for the user.
881
- """
882
- return _run_limited_api(
883
- org_id,
884
- "search",
885
- lambda: get_reflexio(org_id=org_id).storage_stats(
886
- StorageStatsRequest(user_id=user_id)
887
- ),
888
- )
889
-
890
-
891
- @core_router.post(
892
- "/api/search_interactions",
893
- response_model=SearchInteractionsViewResponse,
894
- response_model_exclude_none=True,
895
- )
896
- @limiter.limit("120/minute") # Rate limit for read operations
897
- def search_interactions(
898
- request: Request,
899
- payload: SearchInteractionRequest,
900
- org_id: str = Depends(default_get_org_id),
901
- ) -> SearchInteractionsViewResponse:
902
- response = _run_limited_api(
903
- org_id,
904
- "search",
905
- lambda: get_reflexio(org_id=org_id).search_interactions(payload),
906
- )
907
- return SearchInteractionsViewResponse(
908
- success=response.success,
909
- interactions=[to_interaction_view(i) for i in response.interactions],
910
- msg=response.msg,
911
- )
912
-
913
-
914
- @core_router.post(
915
- "/api/search_user_playbooks",
916
- response_model=SearchUserPlaybooksViewResponse,
917
- response_model_exclude_none=True,
918
- )
919
- @limiter.limit("120/minute") # Rate limit for read operations
920
- def search_user_playbooks_endpoint(
921
- request: Request,
922
- payload: SearchUserPlaybookRequest,
923
- org_id: str = Depends(default_get_org_id),
924
- caller_type: str = Depends(default_get_caller_type),
925
- _gate: None = Depends(default_billing_gate("application")), # noqa: B008
926
- ) -> SearchUserPlaybooksViewResponse:
927
- """Search user playbooks with semantic search and advanced filtering.
928
-
929
- Supports filtering by user_id (via request_id linkage), agent_version,
930
- playbook_name, datetime range, and status.
931
-
932
- Args:
933
- request (Request): The HTTP request object (for rate limiting)
934
- payload (SearchUserPlaybookRequest): The search request
935
- org_id (str): Organization ID
936
- caller_type (str): Billing caller classification (injected via dependency).
937
-
938
- Returns:
939
- SearchUserPlaybooksViewResponse: Response containing matching user playbooks
940
- """
941
- response = _run_limited_api(
942
- org_id,
943
- "search",
944
- lambda: get_reflexio(org_id=org_id).search_user_playbooks(payload),
945
- )
946
- resp = SearchUserPlaybooksViewResponse(
947
- success=response.success,
948
- user_playbooks=[to_user_playbook_view(rf) for rf in response.user_playbooks],
949
- msg=response.msg,
950
- )
951
- _meter_search_request(
952
- org_id=org_id,
953
- caller_type=caller_type,
954
- request_id=getattr(payload, "request_id", None),
955
- session_id=getattr(payload, "session_id", None),
956
- )
957
- _meter_applied_learnings(
958
- org_id=org_id,
959
- caller_type=caller_type,
960
- surfaced_count=len(resp.user_playbooks),
961
- request_id=getattr(payload, "request_id", None),
962
- session_id=getattr(payload, "session_id", None),
963
- )
964
- return resp
965
-
966
-
967
- @core_router.post(
968
- "/api/search_agent_playbooks",
969
- response_model=SearchAgentPlaybooksViewResponse,
970
- response_model_exclude_none=True,
971
- )
972
- @limiter.limit("120/minute") # Rate limit for read operations
973
- def search_agent_playbooks_endpoint(
974
- request: Request,
975
- payload: SearchAgentPlaybookRequest,
976
- org_id: str = Depends(default_get_org_id),
977
- caller_type: str = Depends(default_get_caller_type),
978
- _gate: None = Depends(default_billing_gate("application")), # noqa: B008
979
- ) -> SearchAgentPlaybooksViewResponse:
980
- """Search agent playbooks with semantic search and advanced filtering.
981
-
982
- Supports filtering by agent_version, playbook_name, datetime range,
983
- status_filter, and playbook_status_filter.
984
-
985
- Args:
986
- request (Request): The HTTP request object (for rate limiting)
987
- payload (SearchAgentPlaybookRequest): The search request
988
- org_id (str): Organization ID
989
- caller_type (str): Billing caller classification (injected via dependency).
990
-
991
- Returns:
992
- SearchAgentPlaybooksViewResponse: Response containing matching agent playbooks
993
- """
994
- response = _run_limited_api(
995
- org_id,
996
- "search",
997
- lambda: get_reflexio(org_id=org_id).search_agent_playbooks(payload),
998
- )
999
- resp = SearchAgentPlaybooksViewResponse(
1000
- success=response.success,
1001
- agent_playbooks=[to_agent_playbook_view(fb) for fb in response.agent_playbooks],
1002
- msg=response.msg,
1003
- )
1004
- _meter_search_request(
1005
- org_id=org_id,
1006
- caller_type=caller_type,
1007
- request_id=getattr(payload, "request_id", None),
1008
- session_id=getattr(payload, "session_id", None),
1009
- )
1010
- _meter_applied_learnings(
1011
- org_id=org_id,
1012
- caller_type=caller_type,
1013
- surfaced_count=len(resp.agent_playbooks),
1014
- request_id=getattr(payload, "request_id", None),
1015
- session_id=getattr(payload, "session_id", None),
1016
- )
1017
- return resp
1018
-
1019
-
1020
- @core_router.post(
1021
- "/api/search",
1022
- response_model=UnifiedSearchViewResponse,
1023
- response_model_exclude_none=True,
1024
- )
1025
- @limiter.limit("120/minute")
1026
- def unified_search_endpoint(
1027
- request: Request,
1028
- payload: UnifiedSearchRequest,
1029
- background_tasks: BackgroundTasks,
1030
- org_id: str = Depends(default_get_org_id),
1031
- caller_type: str = Depends(default_get_caller_type),
1032
- _gate: None = Depends(default_billing_gate("application")), # noqa: B008
1033
- _deps_done: None = Depends(_stamp_search_dependencies_done),
1034
- ) -> UnifiedSearchViewResponse:
1035
- """Search across all entity types (profiles, agent playbooks, user playbooks).
1036
-
1037
- Runs query rewriting and embedding generation in parallel, then searches
1038
- all entity types in parallel. Query rewriting is gated behind the
1039
- enable_reformulation request param.
1040
-
1041
- Args:
1042
- request (Request): The HTTP request object (for rate limiting)
1043
- payload (UnifiedSearchRequest): The unified search request
1044
- org_id (str): Organization ID
1045
- caller_type (str): Billing caller classification (injected via dependency).
1046
-
1047
- Returns:
1048
- UnifiedSearchViewResponse: Combined search results
1049
- """
1050
- deps_done = getattr(request.state, "search_deps_done_monotonic", None)
1051
- deps_to_body_ms = (
1052
- int((time.monotonic() - deps_done) * 1000) if deps_done is not None else None
1053
- )
1054
- with profile_step(
1055
- "search.endpoint",
1056
- enabled=bool(payload.enable_reformulation),
1057
- has_conversation_history=bool(payload.conversation_history),
1058
- search_mode=payload.search_mode,
1059
- ) as endpoint_span:
1060
- endpoint_span.set_data("deps_to_body_ms", deps_to_body_ms)
1061
- endpoint_span.set_data(
1062
- "tp_borrowed", getattr(request.state, "tp_borrowed", None)
1063
- )
1064
- endpoint_span.set_data("tp_total", getattr(request.state, "tp_total", None))
1065
- endpoint_span.set_data("tp_waiting", getattr(request.state, "tp_waiting", None))
1066
-
1067
- def run_search() -> Any:
1068
- with profile_step("search.reflexio_cache"):
1069
- reflexio = get_reflexio(org_id=org_id)
1070
- return reflexio.unified_search(payload, org_id=org_id)
1071
-
1072
- response = _run_limited_api(org_id, "search", run_search)
1073
- with profile_step("search.response_view"):
1074
- resp = UnifiedSearchViewResponse(
1075
- success=response.success,
1076
- profiles=[to_profile_view(p) for p in response.profiles],
1077
- agent_playbooks=[
1078
- to_agent_playbook_view(fb) for fb in response.agent_playbooks
1079
- ],
1080
- user_playbooks=[
1081
- to_user_playbook_view(rf) for rf in response.user_playbooks
1082
- ],
1083
- reformulated_query=response.reformulated_query,
1084
- msg=response.msg,
1085
- agent_trace=response.agent_trace,
1086
- rehydrated_text=response.rehydrated_text,
1087
- )
1088
- background_tasks.add_task(
1089
- _meter_search_request,
1090
- org_id=org_id,
1091
- caller_type=caller_type,
1092
- request_id=getattr(payload, "request_id", None),
1093
- session_id=getattr(payload, "session_id", None),
1094
- )
1095
- background_tasks.add_task(
1096
- _meter_applied_learnings,
1097
- org_id=org_id,
1098
- caller_type=caller_type,
1099
- surfaced_count=len(resp.profiles)
1100
- + len(resp.agent_playbooks)
1101
- + len(resp.user_playbooks),
1102
- request_id=getattr(payload, "request_id", None),
1103
- session_id=getattr(payload, "session_id", None),
1104
- )
1105
- return resp
1106
-
1107
-
1108
- @core_router.get("/api/profile_change_log", response_model=ProfileChangeLogViewResponse)
1109
- def get_profile_change_log(
1110
- org_id: str = Depends(default_get_org_id),
1111
- ) -> ProfileChangeLogViewResponse:
1112
- # Serves the reconstructed profile change log (rebuilt from lineage events). The
1113
- # legacy `profile_change_logs` table is no longer written; see
1114
- # reconstruct_profile_change_log in lib/_profiles.py.
1115
- response = get_reflexio(org_id=org_id).get_profile_change_logs()
1116
- return ProfileChangeLogViewResponse(
1117
- success=response.success,
1118
- profile_change_logs=[
1119
- to_profile_change_log_view(log) for log in response.profile_change_logs
1120
- ],
1121
- )
1122
-
1123
-
1124
- @core_router.get(
1125
- "/api/playbook_aggregation_change_logs",
1126
- response_model=PlaybookAggregationChangeLogResponse,
1127
- )
1128
- def get_playbook_aggregation_change_logs(
1129
- playbook_name: str,
1130
- agent_version: str,
1131
- org_id: str = Depends(default_get_org_id),
1132
- ) -> PlaybookAggregationChangeLogResponse:
1133
- return get_reflexio(org_id=org_id).get_playbook_aggregation_change_logs(
1134
- playbook_name=playbook_name,
1135
- agent_version=agent_version,
1136
- )
1137
-
1138
-
1139
- @core_router.delete(
1140
- "/api/delete_profile",
1141
- response_model=DeleteUserProfileResponse,
1142
- response_model_exclude_none=True,
1143
- )
1144
- def delete_profile(
1145
- request: DeleteUserProfileRequest,
1146
- org_id: str = Depends(default_get_org_id),
1147
- ) -> DeleteUserProfileResponse:
1148
- return publisher_api.delete_user_profile(org_id=org_id, request=request)
1149
-
1150
-
1151
- @core_router.delete(
1152
- "/api/delete_interaction",
1153
- response_model=DeleteUserInteractionResponse,
1154
- response_model_exclude_none=True,
1155
- )
1156
- def delete_interaction(
1157
- request: DeleteUserInteractionRequest,
1158
- org_id: str = Depends(default_get_org_id),
1159
- ) -> DeleteUserInteractionResponse:
1160
- return publisher_api.delete_user_interaction(org_id=org_id, request=request)
1161
-
1162
-
1163
- @core_router.delete(
1164
- "/api/delete_request",
1165
- response_model=DeleteRequestResponse,
1166
- response_model_exclude_none=True,
1167
- )
1168
- def delete_request(
1169
- request: DeleteRequestRequest,
1170
- org_id: str = Depends(default_get_org_id),
1171
- ) -> DeleteRequestResponse:
1172
- return publisher_api.delete_request(org_id=org_id, request=request)
1173
-
1174
-
1175
- @core_router.delete(
1176
- "/api/delete_session",
1177
- response_model=DeleteSessionResponse,
1178
- response_model_exclude_none=True,
1179
- )
1180
- def delete_session(
1181
- request: DeleteSessionRequest,
1182
- org_id: str = Depends(default_get_org_id),
1183
- ) -> DeleteSessionResponse:
1184
- return publisher_api.delete_session(org_id=org_id, request=request)
1185
-
1186
-
1187
- @core_router.delete(
1188
- "/api/delete_agent_playbook",
1189
- response_model=DeleteAgentPlaybookResponse,
1190
- response_model_exclude_none=True,
1191
- )
1192
- def delete_agent_playbook(
1193
- request: DeleteAgentPlaybookRequest,
1194
- org_id: str = Depends(default_get_org_id),
1195
- ) -> DeleteAgentPlaybookResponse:
1196
- return publisher_api.delete_agent_playbook(org_id=org_id, request=request)
1197
-
1198
-
1199
- @core_router.delete(
1200
- "/api/delete_user_playbook",
1201
- response_model=DeleteUserPlaybookResponse,
1202
- response_model_exclude_none=True,
1203
- )
1204
- def delete_user_playbook(
1205
- request: DeleteUserPlaybookRequest,
1206
- org_id: str = Depends(default_get_org_id),
1207
- ) -> DeleteUserPlaybookResponse:
1208
- return publisher_api.delete_user_playbook(org_id=org_id, request=request)
1209
-
1210
-
1211
- @core_router.delete(
1212
- "/api/delete_requests_by_ids",
1213
- response_model=BulkDeleteResponse,
1214
- response_model_exclude_none=True,
1215
- )
1216
- def delete_requests_by_ids(
1217
- request: DeleteRequestsByIdsRequest,
1218
- org_id: str = Depends(default_get_org_id),
1219
- ) -> BulkDeleteResponse:
1220
- """Delete multiple requests by their IDs.
1221
-
1222
- Args:
1223
- request (DeleteRequestsByIdsRequest): Request containing list of request IDs to delete
1224
- org_id (str): Organization ID
1225
-
1226
- Returns:
1227
- BulkDeleteResponse: Response containing success status and deleted count
1228
- """
1229
- return publisher_api.delete_requests_by_ids(org_id=org_id, request=request)
1230
-
1231
-
1232
- @core_router.delete(
1233
- "/api/delete_profiles_by_ids",
1234
- response_model=BulkDeleteResponse,
1235
- response_model_exclude_none=True,
1236
- )
1237
- def delete_profiles_by_ids(
1238
- request: DeleteProfilesByIdsRequest,
1239
- org_id: str = Depends(default_get_org_id),
1240
- ) -> BulkDeleteResponse:
1241
- """Delete multiple profiles by their IDs.
1242
-
1243
- Args:
1244
- request (DeleteProfilesByIdsRequest): Request containing list of profile IDs to delete
1245
- org_id (str): Organization ID
1246
-
1247
- Returns:
1248
- BulkDeleteResponse: Response containing success status and deleted count
1249
- """
1250
- return publisher_api.delete_profiles_by_ids(org_id=org_id, request=request)
1251
-
1252
-
1253
- @core_router.delete(
1254
- "/api/delete_agent_playbooks_by_ids",
1255
- response_model=BulkDeleteResponse,
1256
- response_model_exclude_none=True,
1257
- )
1258
- def delete_agent_playbooks_by_ids(
1259
- request: DeleteAgentPlaybooksByIdsRequest,
1260
- org_id: str = Depends(default_get_org_id),
1261
- ) -> BulkDeleteResponse:
1262
- """Delete multiple agent playbooks by their IDs.
1263
-
1264
- Args:
1265
- request (DeleteAgentPlaybooksByIdsRequest): Request containing list of agent playbook IDs to delete
1266
- org_id (str): Organization ID
1267
-
1268
- Returns:
1269
- BulkDeleteResponse: Response containing success status and deleted count
1270
- """
1271
- return publisher_api.delete_agent_playbooks_by_ids_bulk(
1272
- org_id=org_id, request=request
1273
- )
1274
-
1275
-
1276
- @core_router.delete(
1277
- "/api/delete_user_playbooks_by_ids",
1278
- response_model=BulkDeleteResponse,
1279
- response_model_exclude_none=True,
1280
- )
1281
- def delete_user_playbooks_by_ids(
1282
- request: DeleteUserPlaybooksByIdsRequest,
1283
- org_id: str = Depends(default_get_org_id),
1284
- ) -> BulkDeleteResponse:
1285
- """Delete multiple user playbooks by their IDs.
1286
-
1287
- Args:
1288
- request (DeleteUserPlaybooksByIdsRequest): Request containing list of user playbook IDs to delete
1289
- org_id (str): Organization ID
1290
-
1291
- Returns:
1292
- BulkDeleteResponse: Response containing success status and deleted count
1293
- """
1294
- return publisher_api.delete_user_playbooks_by_ids_bulk(
1295
- org_id=org_id, request=request
1296
- )
1297
-
1298
-
1299
- @core_router.delete(
1300
- "/api/delete_all_interactions",
1301
- response_model=BulkDeleteResponse,
1302
- response_model_exclude_none=True,
1303
- )
1304
- @limiter.limit("10/minute")
1305
- def delete_all_interactions(
1306
- request: Request,
1307
- org_id: str = Depends(default_get_org_id),
1308
- ) -> BulkDeleteResponse:
1309
- """Delete all requests and their associated interactions.
1310
-
1311
- Args:
1312
- org_id (str): Organization ID
1313
-
1314
- Returns:
1315
- BulkDeleteResponse: Response containing success status and deleted count
1316
- """
1317
- return publisher_api.delete_all_interactions_bulk(org_id=org_id)
1318
-
1319
-
1320
- @core_router.delete(
1321
- "/api/delete_all_profiles",
1322
- response_model=BulkDeleteResponse,
1323
- response_model_exclude_none=True,
1324
- )
1325
- @limiter.limit("10/minute")
1326
- def delete_all_profiles(
1327
- request: Request,
1328
- org_id: str = Depends(default_get_org_id),
1329
- ) -> BulkDeleteResponse:
1330
- """Delete all profiles.
1331
-
1332
- Args:
1333
- org_id (str): Organization ID
1334
-
1335
- Returns:
1336
- BulkDeleteResponse: Response containing success status and deleted count
1337
- """
1338
- return publisher_api.delete_all_profiles_bulk(org_id=org_id)
1339
-
1340
-
1341
- @core_router.delete(
1342
- "/api/delete_all_playbooks",
1343
- response_model=BulkDeleteResponse,
1344
- response_model_exclude_none=True,
1345
- )
1346
- @limiter.limit("10/minute")
1347
- def delete_all_playbooks(
1348
- request: Request,
1349
- org_id: str = Depends(default_get_org_id),
1350
- ) -> BulkDeleteResponse:
1351
- """Delete all playbooks (both user and agent).
1352
-
1353
- Args:
1354
- org_id (str): Organization ID
1355
-
1356
- Returns:
1357
- BulkDeleteResponse: Response containing success status and deleted count
1358
- """
1359
- return publisher_api.delete_all_playbooks_bulk(org_id=org_id)
1360
-
1361
-
1362
- @core_router.delete(
1363
- "/api/delete_all_user_playbooks",
1364
- response_model=BulkDeleteResponse,
1365
- response_model_exclude_none=True,
1366
- )
1367
- @limiter.limit("10/minute")
1368
- def delete_all_user_playbooks(
1369
- request: Request,
1370
- org_id: str = Depends(default_get_org_id),
1371
- ) -> BulkDeleteResponse:
1372
- """Delete all user playbooks (user only, not agent).
1373
-
1374
- Args:
1375
- org_id (str): Organization ID
1376
-
1377
- Returns:
1378
- BulkDeleteResponse: Response containing success status and deleted count
1379
- """
1380
- return publisher_api.delete_all_user_playbooks_bulk(org_id=org_id)
1381
-
1382
-
1383
- @core_router.delete(
1384
- "/api/delete_all_agent_playbooks",
1385
- response_model=BulkDeleteResponse,
1386
- response_model_exclude_none=True,
1387
- )
1388
- @limiter.limit("10/minute")
1389
- def delete_all_agent_playbooks(
1390
- request: Request,
1391
- org_id: str = Depends(default_get_org_id),
1392
- ) -> BulkDeleteResponse:
1393
- """Delete all agent playbooks (agent only, not user).
1394
-
1395
- Args:
1396
- org_id (str): Organization ID
1397
-
1398
- Returns:
1399
- BulkDeleteResponse: Response containing success status and deleted count
1400
- """
1401
- return publisher_api.delete_all_agent_playbooks_bulk(org_id=org_id)
1402
-
1403
-
1404
- @core_router.post(
1405
- "/api/clear_user_data",
1406
- response_model=ClearUserDataResponse,
1407
- response_model_exclude_none=True,
1408
- )
1409
- @limiter.limit("10/minute")
1410
- def clear_user_data(
1411
- request: Request,
1412
- payload: ClearUserDataRequest,
1413
- org_id: str = Depends(default_get_org_id),
1414
- ) -> ClearUserDataResponse:
1415
- """Delete all rows scoped to a single ``user_id``.
1416
-
1417
- Removes the user's interactions, user playbooks, profiles, and
1418
- requests. Does NOT touch ``agent_playbooks`` — they are
1419
- intentionally shared cross-project. Used by paired-protocol
1420
- harnesses (e.g. SWE-bench) to isolate per-task data on a shared
1421
- backend without one task's clear-all nuking another in-flight
1422
- task's rows.
1423
-
1424
- Args:
1425
- request (ClearUserDataRequest): Request containing the target user_id
1426
- org_id (str): Organization ID
1427
-
1428
- Returns:
1429
- ClearUserDataResponse: Response with per-entity deletion counts
1430
- """
1431
- return publisher_api.clear_user_data(org_id=org_id, request=payload)
1432
-
1433
-
1434
- @core_router.post(
1435
- "/api/get_interactions",
1436
- response_model=GetInteractionsViewResponse,
1437
- response_model_exclude_none=True,
1438
- )
1439
- def get_interactions(
1440
- request: GetInteractionsRequest,
1441
- org_id: str = Depends(default_get_org_id),
1442
- ) -> GetInteractionsViewResponse:
1443
- response = get_reflexio(org_id=org_id).get_interactions(request)
1444
- return GetInteractionsViewResponse(
1445
- success=response.success,
1446
- interactions=[to_interaction_view(i) for i in response.interactions],
1447
- msg=response.msg,
1448
- )
1449
-
1450
-
1451
- @core_router.get(
1452
- "/api/get_all_interactions",
1453
- response_model=GetInteractionsViewResponse,
1454
- response_model_exclude_none=True,
1455
- )
1456
- @limiter.limit("30/minute")
1457
- def get_all_interactions(
1458
- request: Request,
1459
- limit: int = 100,
1460
- org_id: str = Depends(default_get_org_id),
1461
- ) -> GetInteractionsViewResponse:
1462
- """Get all user interactions across all users.
1463
-
1464
- Args:
1465
- limit (int, optional): Maximum number of interactions to return. Defaults to 100.
1466
- org_id (str): Organization ID
1467
-
1468
- Returns:
1469
- GetInteractionsViewResponse: Response containing all user interactions
1470
- """
1471
- reflexio = get_reflexio(org_id=org_id)
1472
- response = reflexio.get_all_interactions(limit=limit)
1473
- return GetInteractionsViewResponse(
1474
- success=response.success,
1475
- interactions=[to_interaction_view(i) for i in response.interactions],
1476
- msg=response.msg,
1477
- )
1478
-
1479
-
1480
- @core_router.post(
1481
- "/api/get_requests",
1482
- response_model=GetRequestsViewResponse,
1483
- response_model_exclude_none=True,
1484
- )
1485
- def get_requests_endpoint(
1486
- request: GetRequestsRequest,
1487
- org_id: str = Depends(default_get_org_id),
1488
- ) -> GetRequestsViewResponse:
1489
- """Get requests with their associated interactions.
1490
-
1491
- Args:
1492
- request (GetRequestsRequest): The get request
1493
- org_id (str): Organization ID
1494
-
1495
- Returns:
1496
- GetRequestsViewResponse: Response containing requests with their interactions
1497
- """
1498
- internal_response = get_reflexio(org_id=org_id).get_requests(request)
1499
- return GetRequestsViewResponse(
1500
- success=internal_response.success,
1501
- sessions=[
1502
- SessionView(
1503
- session_id=s.session_id,
1504
- requests=[
1505
- RequestDataView(
1506
- request=rd.request,
1507
- interactions=[to_interaction_view(i) for i in rd.interactions],
1508
- )
1509
- for rd in s.requests
1510
- ],
1511
- )
1512
- for s in internal_response.sessions
1513
- ],
1514
- has_more=internal_response.has_more,
1515
- msg=internal_response.msg,
1516
- )
1517
-
1518
-
1519
- def _learning_provenance_error(
1520
- payload: GetLearningProvenanceRequest,
1521
- msg: str,
1522
- ) -> LearningProvenanceViewResponse:
1523
- return LearningProvenanceViewResponse(
1524
- success=False,
1525
- target_kind=payload.kind,
1526
- target_id=payload.id,
1527
- provenance_status="unavailable",
1528
- msg=msg,
1529
- )
1530
-
1531
-
1532
- def _parse_learning_target_int(
1533
- payload: GetLearningProvenanceRequest,
1534
- ) -> int | LearningProvenanceViewResponse:
1535
- try:
1536
- return int(payload.id)
1537
- except ValueError:
1538
- return _learning_provenance_error(
1539
- payload,
1540
- f"{payload.kind} id must be an integer",
1541
- )
1542
-
1543
-
1544
- def _sort_interactions_by_time(interactions: list[Any]) -> list[Any]:
1545
- return sorted(
1546
- interactions,
1547
- key=lambda i: (
1548
- getattr(i, "created_at", 0) or 0,
1549
- getattr(i, "interaction_id", 0),
1550
- ),
1551
- )
1552
-
1553
-
1554
- def _interaction_views(interactions: list[Any]) -> list[Any]:
1555
- return [to_interaction_view(i) for i in _sort_interactions_by_time(interactions)]
1556
-
1557
-
1558
- def _effective_profile_provenance_window(
1559
- reflexio: Any, trigger_source: str | None
1560
- ) -> tuple[int, list[str] | None]:
1561
- config = reflexio.request_context.configurator.get_config()
1562
- profile_config = getattr(config, "profile_extractor_config", None)
1563
- if profile_config is None:
1564
- return getattr(config, "window_size", DEFAULT_WINDOW_SIZE), None
1565
-
1566
- window_size, _ = get_extractor_window_params(
1567
- profile_config,
1568
- getattr(config, "window_size", None),
1569
- getattr(config, "stride_size", None),
1570
- )
1571
- should_skip, source_filter = get_effective_source_filter(
1572
- profile_config,
1573
- trigger_source,
1574
- )
1575
- if should_skip:
1576
- return window_size, None
1577
- return window_size, source_filter
1578
-
1579
-
1580
- def _get_profile_learning_provenance(
1581
- payload: GetLearningProvenanceRequest,
1582
- reflexio: Any,
1583
- ) -> LearningProvenanceViewResponse:
1584
- storage = reflexio.request_context.storage
1585
- profile = storage.get_profile_by_id(payload.id)
1586
- if profile is None:
1587
- return _learning_provenance_error(payload, "Profile not found")
1588
-
1589
- if profile.source_interaction_ids:
1590
- interactions = storage.get_interactions_by_ids(profile.source_interaction_ids)
1591
- return LearningProvenanceViewResponse(
1592
- success=True,
1593
- target_kind=payload.kind,
1594
- target_id=payload.id,
1595
- provenance_status="exact",
1596
- trigger_request_id=profile.generated_from_request_id,
1597
- interactions=_interaction_views(interactions),
1598
- )
1599
-
1600
- if not profile.generated_from_request_id:
1601
- return _learning_provenance_error(
1602
- payload,
1603
- "Profile has no generation request for provenance reconstruction",
1604
- )
1605
-
1606
- trigger_request = storage.get_request(profile.generated_from_request_id)
1607
- if trigger_request is None:
1608
- return _learning_provenance_error(
1609
- payload,
1610
- "Profile generation request was not found",
1611
- )
1612
-
1613
- trigger_interactions = storage.get_interactions_by_request_ids(
1614
- [profile.generated_from_request_id]
1615
- )
1616
- anchor_time = (
1617
- max(i.created_at for i in trigger_interactions)
1618
- if trigger_interactions
1619
- else trigger_request.created_at
1620
- )
1621
- window_size, source_filter = _effective_profile_provenance_window(
1622
- reflexio,
1623
- trigger_request.source,
1624
- )
1625
- _, interactions = storage.get_last_k_interactions_grouped(
1626
- user_id=profile.user_id,
1627
- k=window_size,
1628
- sources=source_filter,
1629
- end_time=anchor_time,
1630
- )
1631
- return LearningProvenanceViewResponse(
1632
- success=True,
1633
- target_kind=payload.kind,
1634
- target_id=payload.id,
1635
- provenance_status="best_effort" if interactions else "unavailable",
1636
- trigger_request_id=profile.generated_from_request_id,
1637
- interactions=_interaction_views(interactions),
1638
- msg=None if interactions else "No interactions found for reconstructed window",
1639
- )
1640
-
1641
-
1642
- def _get_user_playbook_learning_provenance(
1643
- payload: GetLearningProvenanceRequest,
1644
- reflexio: Any,
1645
- ) -> LearningProvenanceViewResponse:
1646
- parsed_id = _parse_learning_target_int(payload)
1647
- if isinstance(parsed_id, LearningProvenanceViewResponse):
1648
- return parsed_id
1649
-
1650
- storage = reflexio.request_context.storage
1651
- playbook = storage.get_user_playbook_by_id(parsed_id)
1652
- if playbook is None:
1653
- return _learning_provenance_error(payload, "User playbook not found")
1654
-
1655
- status = "exact"
1656
- if playbook.source_interaction_ids:
1657
- interactions = storage.get_interactions_by_ids(playbook.source_interaction_ids)
1658
- elif playbook.request_id:
1659
- status = "best_effort"
1660
- interactions = storage.get_interactions_by_request_ids([playbook.request_id])
1661
- else:
1662
- status = "unavailable"
1663
- interactions = []
1664
-
1665
- return LearningProvenanceViewResponse(
1666
- success=True,
1667
- target_kind=payload.kind,
1668
- target_id=payload.id,
1669
- provenance_status=status if interactions else "unavailable",
1670
- trigger_request_id=playbook.request_id,
1671
- interactions=_interaction_views(interactions),
1672
- msg=None if interactions else "No interactions found for this user playbook",
1673
- )
1674
-
1675
-
1676
- def _get_agent_playbook_learning_provenance(
1677
- payload: GetLearningProvenanceRequest,
1678
- reflexio: Any,
1679
- ) -> LearningProvenanceViewResponse:
1680
- parsed_id = _parse_learning_target_int(payload)
1681
- if isinstance(parsed_id, LearningProvenanceViewResponse):
1682
- return parsed_id
1683
-
1684
- storage = reflexio.request_context.storage
1685
- agent_playbook = storage.get_agent_playbook_by_id(parsed_id)
1686
- if agent_playbook is None:
1687
- return _learning_provenance_error(payload, "Agent playbook not found")
1688
-
1689
- windows = storage.get_source_windows_for_agent_playbook(parsed_id)
1690
- if not windows:
1691
- return LearningProvenanceViewResponse(
1692
- success=True,
1693
- target_kind=payload.kind,
1694
- target_id=payload.id,
1695
- provenance_status="unavailable",
1696
- msg="No source user playbooks are recorded for this agent playbook",
1697
- )
1698
-
1699
- user_playbook_ids = [window.user_playbook_id for window in windows]
1700
- user_playbooks = storage.get_user_playbooks_by_ids_any_user(
1701
- user_playbook_ids,
1702
- status_filter=None,
1703
- )
1704
- user_playbooks_by_id = {
1705
- playbook.user_playbook_id: playbook for playbook in user_playbooks
1706
- }
1707
-
1708
- interaction_ids_to_fetch: set[int] = set()
1709
- request_ids_to_fetch: set[str] = set()
1710
- group_sources: dict[int, tuple[list[int], str | None, bool]] = {}
1711
- for window in windows:
1712
- source_user_playbook = user_playbooks_by_id.get(window.user_playbook_id)
1713
- if source_user_playbook is None:
1714
- continue
1715
-
1716
- source_ids = list(window.source_interaction_ids)
1717
- request_id: str | None = None
1718
- uses_fallback = False
1719
- if not source_ids and source_user_playbook.source_interaction_ids:
1720
- source_ids = list(source_user_playbook.source_interaction_ids)
1721
- elif not source_ids and source_user_playbook.request_id:
1722
- request_id = source_user_playbook.request_id
1723
- uses_fallback = True
1724
- elif not source_ids:
1725
- uses_fallback = True
1726
-
1727
- interaction_ids_to_fetch.update(source_ids)
1728
- if request_id:
1729
- request_ids_to_fetch.add(request_id)
1730
- group_sources[window.user_playbook_id] = (source_ids, request_id, uses_fallback)
1731
-
1732
- interactions_by_id = (
1733
- {
1734
- interaction.interaction_id: interaction
1735
- for interaction in storage.get_interactions_by_ids(
1736
- sorted(interaction_ids_to_fetch)
1737
- )
1738
- }
1739
- if interaction_ids_to_fetch
1740
- else {}
1741
- )
1742
- request_interactions: dict[str, list[Any]] = {}
1743
- if request_ids_to_fetch:
1744
- for interaction in storage.get_interactions_by_request_ids(
1745
- sorted(request_ids_to_fetch)
1746
- ):
1747
- request_interactions.setdefault(interaction.request_id, []).append(
1748
- interaction
1749
- )
1750
-
1751
- groups: list[SourceUserPlaybookProvenanceView] = []
1752
- used_fallback = False
1753
- found_interactions = False
1754
- for window in windows:
1755
- source_user_playbook = user_playbooks_by_id.get(window.user_playbook_id)
1756
- if source_user_playbook is None:
1757
- used_fallback = True
1758
- continue
1759
-
1760
- source_ids, request_id, uses_fallback = group_sources.get(
1761
- window.user_playbook_id,
1762
- ([], None, True),
1763
- )
1764
- used_fallback = used_fallback or uses_fallback
1765
- interactions = (
1766
- [interactions_by_id[i] for i in source_ids if i in interactions_by_id]
1767
- if source_ids
1768
- else request_interactions.get(request_id or "", [])
1769
- )
1770
-
1771
- found_interactions = found_interactions or bool(interactions)
1772
- groups.append(
1773
- SourceUserPlaybookProvenanceView(
1774
- user_playbook=to_user_playbook_view(source_user_playbook),
1775
- interactions=_interaction_views(interactions),
1776
- source_interaction_ids=source_ids,
1777
- )
1778
- )
1779
-
1780
- if not groups:
1781
- return _learning_provenance_error(
1782
- payload,
1783
- "Recorded source user playbooks were not found",
1784
- )
1785
-
1786
- return LearningProvenanceViewResponse(
1787
- success=True,
1788
- target_kind=payload.kind,
1789
- target_id=payload.id,
1790
- provenance_status=(
1791
- "exact"
1792
- if found_interactions and not used_fallback
1793
- else "best_effort"
1794
- if found_interactions
1795
- else "unavailable"
1796
- ),
1797
- source_user_playbooks=groups,
1798
- msg=None if found_interactions else "No source interactions were found",
1799
- )
1800
-
1801
-
1802
- @core_router.post(
1803
- "/api/get_learning_provenance",
1804
- response_model=LearningProvenanceViewResponse,
1805
- response_model_exclude_none=True,
1806
- )
1807
- def get_learning_provenance(
1808
- payload: GetLearningProvenanceRequest,
1809
- org_id: str = Depends(default_get_org_id),
1810
- ) -> LearningProvenanceViewResponse:
1811
- """Return read-only learning provenance for a generated learning row."""
1812
- reflexio = get_reflexio(org_id=org_id)
1813
- if payload.kind == "profile":
1814
- return _get_profile_learning_provenance(payload, reflexio)
1815
- if payload.kind == "user_playbook":
1816
- return _get_user_playbook_learning_provenance(payload, reflexio)
1817
- return _get_agent_playbook_learning_provenance(payload, reflexio)
1818
-
1819
-
1820
- @core_router.post(
1821
- "/api/get_profiles",
1822
- response_model=GetProfilesViewResponse,
1823
- response_model_exclude_none=True,
1824
- )
1825
- def get_profiles(
1826
- request: GetUserProfilesRequest,
1827
- org_id: str = Depends(default_get_org_id),
1828
- ) -> GetProfilesViewResponse:
1829
- response = get_reflexio(org_id=org_id).get_profiles(request)
1830
- return GetProfilesViewResponse(
1831
- success=response.success,
1832
- user_profiles=[to_profile_view(p) for p in response.user_profiles],
1833
- msg=response.msg,
1834
- )
1835
-
1836
-
1837
- @core_router.get(
1838
- "/api/get_all_profiles",
1839
- response_model=GetProfilesViewResponse,
1840
- response_model_exclude_none=True,
1841
- )
1842
- @limiter.limit("30/minute")
1843
- def get_all_profiles(
1844
- request: Request,
1845
- limit: int = 100,
1846
- status_filter: str | None = None,
1847
- profile_id: str | None = None,
1848
- include_tombstones: bool = False,
1849
- org_id: str = Depends(default_get_org_id),
1850
- ) -> GetProfilesViewResponse:
1851
- """Get all user profiles across all users.
1852
-
1853
- Args:
1854
- limit (int, optional): Maximum number of profiles to return. Defaults to 100.
1855
- status_filter (str, optional): Filter by profile status. Can be "current", "pending", or "archived".
1856
- profile_id (str, optional): Exact profile ID to retrieve.
1857
- include_tombstones (bool, optional): Include merged/superseded rows when
1858
- looking up a specific profile_id.
1859
- org_id (str): Organization ID
1860
-
1861
- Returns:
1862
- GetProfilesViewResponse: Response containing all user profiles
1863
- """
1864
- reflexio = get_reflexio(org_id=org_id)
1865
-
1866
- if profile_id:
1867
- storage = reflexio.request_context.storage
1868
- if storage is None:
1869
- return GetProfilesViewResponse(
1870
- success=True,
1871
- user_profiles=[],
1872
- msg="Storage is not configured",
1873
- )
1874
- profile = storage.get_profile_by_id(
1875
- profile_id, include_tombstones=include_tombstones
1876
- )
1877
- profiles = [profile] if profile else []
1878
- return GetProfilesViewResponse(
1879
- success=True,
1880
- user_profiles=[to_profile_view(p) for p in profiles],
1881
- msg=f"Found {len(profiles)} profile(s)",
1882
- )
1883
-
1884
- # Map status_filter string to Status list
1885
- status_filter_list = None
1886
- if status_filter == "current":
1887
- status_filter_list = [None]
1888
- elif status_filter == "pending":
1889
- status_filter_list = [Status.PENDING]
1890
- elif status_filter == "archived":
1891
- status_filter_list = [Status.ARCHIVED]
1892
-
1893
- response = reflexio.get_all_profiles(limit=limit, status_filter=status_filter_list) # type: ignore[reportArgumentType]
1894
- return GetProfilesViewResponse(
1895
- success=response.success,
1896
- user_profiles=[to_profile_view(p) for p in response.user_profiles],
1897
- msg=response.msg,
1898
- )
1899
-
1900
-
1901
- @core_router.get(
1902
- "/api/get_profile_statistics",
1903
- response_model=GetProfileStatisticsResponse,
1904
- response_model_exclude_none=True,
1905
- )
1906
- def get_profile_statistics(
1907
- org_id: str = Depends(default_get_org_id),
1908
- ) -> GetProfileStatisticsResponse:
1909
- """Get efficient profile statistics using storage layer queries.
1910
-
1911
- Args:
1912
- org_id (str): Organization ID
1913
-
1914
- Returns:
1915
- GetProfileStatisticsResponse: Response containing profile counts by status
1916
- """
1917
- # Create Reflexio instance
1918
- reflexio = get_reflexio(org_id=org_id)
1919
-
1920
- # Get profile statistics using Reflexio's method
1921
- return reflexio.get_profile_statistics()
1922
-
1923
-
1924
- @core_router.post(
1925
- "/api/run_playbook_aggregation",
1926
- response_model=RunPlaybookAggregationResponse,
1927
- response_model_exclude_none=True,
1928
- )
1929
- @limiter.limit("10/minute") # Strict limit for expensive operations
1930
- def run_playbook_aggregation(
1931
- request: Request,
1932
- payload: RunPlaybookAggregationRequest,
1933
- org_id: str = Depends(default_get_org_id),
1934
- ) -> RunPlaybookAggregationResponse:
1935
- return _run_limited_api(
1936
- org_id,
1937
- "aggregation",
1938
- lambda: publisher_api.run_playbook_aggregation(org_id=org_id, request=payload),
1939
- )
1940
-
1941
-
1942
- @core_router.post("/api/set_config")
1943
- @limiter.limit("10/minute")
1944
- def set_config(
1945
- request: Request,
1946
- config: dict[str, Any],
1947
- org_id: str = Depends(default_get_org_id),
1948
- ) -> SetConfigResponse:
1949
- """Set configuration for the organization.
1950
-
1951
- Args:
1952
- config (dict[str, Any]): The configuration payload to set
1953
- org_id (str): Organization ID
1954
-
1955
- Returns:
1956
- dict: Response containing success status and message
1957
- """
1958
- from pydantic import ValidationError
1959
-
1960
- # Create Reflexio instance to access the configurator through request_context
1961
- reflexio = get_reflexio(org_id=org_id)
1962
- configurator = reflexio.request_context.configurator
1963
- normalized_config = configurator.normalize_config_payload(config)
1964
- if not isinstance(normalized_config, dict):
1965
- normalized_config = config
1966
- try:
1967
- Config.model_validate(normalized_config)
1968
- except ValidationError as exc:
1969
- raise HTTPException(
1970
- status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
1971
- detail=exc.errors(),
1972
- ) from exc
1973
-
1974
- # Set the config using Reflexio's set_config method
1975
- response = reflexio.set_config(normalized_config)
1976
-
1977
- # Invalidate cache on successful config change to ensure fresh instance next request
1978
- if response.success:
1979
- invalidate_reflexio_cache(org_id=org_id)
1980
-
1981
- return response
1982
-
1983
-
1984
- @core_router.post("/api/update_config")
1985
- @limiter.limit("10/minute")
1986
- def update_config(
1987
- request: Request,
1988
- partial: dict[str, Any],
1989
- org_id: str = Depends(default_get_org_id),
1990
- ) -> SetConfigResponse:
1991
- """Apply a partial update to the org's config (PATCH semantics).
1992
-
1993
- Performs a **top-level shallow merge** of *partial* over the existing
1994
- config and round-trips through ``Config(**merged)`` so Pydantic
1995
- validates the result and rejects bogus top-level fields.
1996
-
1997
- .. warning::
1998
- Nested objects (e.g. ``storage_config``, ``profile_extractor_config``,
1999
- ``user_playbook_extractor_config``) are **replaced wholesale**.
2000
- Deep merging is intentionally not supported -- the discriminator on
2001
- ``storage_config`` would be lost on partial updates, and merging nested
2002
- dicts has ambiguous semantics.
2003
-
2004
- To update a field inside an extractor config you must resend that
2005
- extractor object fully populated (including ``extractor_name``,
2006
- ``extraction_definition_prompt``, etc.). For one-off mutations prefer
2007
- ``GET /api/get_config`` followed by ``POST /api/set_config`` with the
2008
- modified full config.
2009
-
2010
- Unlike :func:`set_config`, callers do not need to re-send the full
2011
- config (including required fields like ``storage_config``) just to
2012
- flip a single top-level boolean. The merge happens server-side
2013
- atomically within the request, eliminating the read-modify-write
2014
- race a client would otherwise hit.
2015
-
2016
- Args:
2017
- partial: Top-level fields to overlay on the existing config.
2018
- org_id: Organization ID resolved by the auth layer.
2019
-
2020
- Returns:
2021
- SetConfigResponse: Success status and message from
2022
- :meth:`Reflexio.set_config`.
2023
- """
2024
- from pydantic import ValidationError
2025
-
2026
- reflexio = get_reflexio(org_id=org_id)
2027
- existing = reflexio.request_context.configurator.get_config().model_dump(
2028
- mode="python"
2029
- )
2030
- merged = {**existing, **partial}
2031
- # Pydantic validates the merged shape and rejects unknown / malformed
2032
- # fields here, before storage validation in reflexio.set_config.
2033
- # Convert ValidationError into 422 so callers passing a partial that
2034
- # would replace a nested extractor object with an incomplete dict (e.g.
2035
- # {"user_playbook_extractor_config": {"aggregation_config": {...}}})
2036
- # get a clean client-error response instead of a 500.
2037
- try:
2038
- merged_config = Config(**merged)
2039
- except ValidationError as exc:
2040
- raise HTTPException(
2041
- status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
2042
- detail={
2043
- "error": "Invalid partial config (top-level shallow merge)",
2044
- "hint": (
2045
- "Nested objects (e.g. user_playbook_extractor_config) "
2046
- "are replaced wholesale, not deep-merged. To mutate a "
2047
- "single nested field, fetch the full config via "
2048
- "/api/get_config, edit, and POST it back via "
2049
- "/api/set_config."
2050
- ),
2051
- "validation_errors": exc.errors(),
2052
- },
2053
- ) from exc
2054
- response = reflexio.set_config(merged_config)
2055
- if response.success:
2056
- invalidate_reflexio_cache(org_id=org_id)
2057
- return response
2058
-
2059
-
2060
- @core_router.post("/api/admin/cache/invalidate")
2061
- def admin_invalidate_cache(
2062
- payload: AdminInvalidateCacheRequest,
2063
- org_id: str = Depends(default_get_org_id),
2064
- ) -> AdminInvalidateCacheResponse:
2065
- """Explicitly evict the per-org Reflexio cache entry.
2066
-
2067
- Necessary when the running config has been mutated through a
2068
- channel the server can't observe — e.g. another replica wrote to
2069
- the shared DB, or an operator hand-edited a self-host config file
2070
- on a backend that doesn't support cheap version probing. The
2071
- file-mtime check (Phase 1) and DB version check (Phase 3) cover
2072
- most cases automatically; this endpoint is the manual escape hatch.
2073
-
2074
- Auth uses the same dependency as ``/api/set_config`` — callers
2075
- can only invalidate their own org's cache. If the request body
2076
- supplies ``org_id`` it must match the dep-resolved value;
2077
- cross-org invalidation is intentionally NOT exposed here.
2078
-
2079
- Args:
2080
- payload: Optional ``org_id`` (verification only — must match
2081
- the caller's authenticated org if provided).
2082
- org_id: Organization ID resolved by the auth layer.
2083
-
2084
- Returns:
2085
- AdminInvalidateCacheResponse: ``invalidated`` is True iff an
2086
- entry was evicted (False is a successful no-op when nothing
2087
- was cached).
2088
-
2089
- Raises:
2090
- HTTPException: 403 when the body's ``org_id`` differs from the
2091
- caller's authenticated org.
2092
- """
2093
- if payload.org_id is not None and payload.org_id != org_id:
2094
- raise HTTPException(
2095
- status_code=status.HTTP_403_FORBIDDEN,
2096
- detail=(
2097
- "Cross-org cache invalidation is not supported; "
2098
- "omit org_id or pass your own."
2099
- ),
2100
- )
2101
- invalidated = invalidate_reflexio_cache(org_id=org_id)
2102
- return AdminInvalidateCacheResponse(invalidated=invalidated, org_id=org_id)
2103
-
2104
-
2105
- @core_router.get("/api/get_config")
2106
- def get_config(
2107
- org_id: str = Depends(default_get_org_id),
2108
- ) -> dict[str, Any]:
2109
- """Get configuration for the organization.
2110
-
2111
- Args:
2112
- org_id (str): Organization ID
2113
-
2114
- Returns:
2115
- Config: The current configuration
2116
- """
2117
- # Create Reflexio instance to access the configurator through request_context
2118
- reflexio = get_reflexio(org_id=org_id)
2119
-
2120
- return reflexio.request_context.configurator.get_config_for_response()
2121
-
2122
-
2123
- @core_router.post(
2124
- "/api/get_user_playbooks",
2125
- response_model=GetUserPlaybooksViewResponse,
2126
- response_model_exclude_none=True,
2127
- )
2128
- def get_user_playbooks(
2129
- request: GetUserPlaybooksRequest,
2130
- org_id: str = Depends(default_get_org_id),
2131
- ) -> GetUserPlaybooksViewResponse:
2132
- """Get user playbooks with internal fields filtered out.
2133
-
2134
- Args:
2135
- request (GetUserPlaybooksRequest): The get request
2136
- org_id (str): Organization ID
2137
-
2138
- Returns:
2139
- GetUserPlaybooksViewResponse: Response containing user playbooks without internal fields
2140
- """
2141
- reflexio = get_reflexio(org_id=org_id)
2142
- response = reflexio.get_user_playbooks(request)
2143
- return GetUserPlaybooksViewResponse(
2144
- success=response.success,
2145
- user_playbooks=[to_user_playbook_view(rf) for rf in response.user_playbooks],
2146
- msg=response.msg,
2147
- )
2148
-
2149
-
2150
- @core_router.post(
2151
- "/api/get_agent_playbooks",
2152
- response_model=GetAgentPlaybooksViewResponse,
2153
- response_model_exclude_none=True,
2154
- )
2155
- @limiter.limit("120/minute") # Rate limit for read operations
2156
- def get_agent_playbooks(
2157
- request: Request,
2158
- payload: GetAgentPlaybooksRequest,
2159
- org_id: str = Depends(default_get_org_id),
2160
- caller_type: str = Depends(default_get_caller_type),
2161
- _gate: None = Depends(default_billing_gate("application")), # noqa: B008
2162
- ) -> GetAgentPlaybooksViewResponse:
2163
- """Get agent playbooks with internal fields filtered out.
2164
-
2165
- Args:
2166
- request (Request): The HTTP request object (for rate limiting)
2167
- payload (GetAgentPlaybooksRequest): The get request
2168
- org_id (str): Organization ID
2169
- caller_type (str): Billing caller classification (injected via dependency).
2170
-
2171
- Returns:
2172
- GetAgentPlaybooksViewResponse: Response containing agent playbooks without internal fields
2173
- """
2174
- reflexio = get_reflexio(org_id=org_id)
2175
- response = reflexio.get_agent_playbooks(payload)
2176
- resp = GetAgentPlaybooksViewResponse(
2177
- success=response.success,
2178
- agent_playbooks=[to_agent_playbook_view(fb) for fb in response.agent_playbooks],
2179
- msg=response.msg,
2180
- )
2181
- _meter_applied_learnings(
2182
- org_id=org_id,
2183
- caller_type=caller_type,
2184
- surfaced_count=len(resp.agent_playbooks),
2185
- request_id=getattr(payload, "request_id", None),
2186
- session_id=getattr(payload, "session_id", None),
2187
- )
2188
- return resp
2189
-
2190
-
2191
- @core_router.post(
2192
- "/api/get_agent_success_evaluation_results",
2193
- response_model=GetEvaluationResultsViewResponse,
2194
- response_model_exclude_none=True,
2195
- )
2196
- def get_agent_success_evaluation_results(
2197
- request: GetAgentSuccessEvaluationResultsRequest,
2198
- org_id: str = Depends(default_get_org_id),
2199
- ) -> GetEvaluationResultsViewResponse:
2200
- """Get agent success evaluation results.
2201
-
2202
- Args:
2203
- request (GetAgentSuccessEvaluationResultsRequest): The get request
2204
- org_id (str): Organization ID
2205
-
2206
- Returns:
2207
- GetEvaluationResultsViewResponse: Response containing agent success evaluation results
2208
- """
2209
- reflexio = get_reflexio(org_id=org_id)
2210
- response = reflexio.get_agent_success_evaluation_results(request)
2211
- return GetEvaluationResultsViewResponse(
2212
- success=response.success,
2213
- agent_success_evaluation_results=[
2214
- to_evaluation_result_view(r)
2215
- for r in response.agent_success_evaluation_results
2216
- ],
2217
- msg=response.msg,
2218
- )
2219
-
2220
-
2221
- @core_router.put(
2222
- "/api/update_agent_playbook_status",
2223
- response_model=UpdatePlaybookStatusResponse,
2224
- response_model_exclude_none=True,
2225
- )
2226
- def update_agent_playbook_status_endpoint(
2227
- request: UpdatePlaybookStatusRequest,
2228
- org_id: str = Depends(default_get_org_id),
2229
- ) -> UpdatePlaybookStatusResponse:
2230
- """Update the status of a specific playbook.
2231
-
2232
- Args:
2233
- request (UpdatePlaybookStatusRequest): The update request
2234
- org_id (str): Organization ID
2235
-
2236
- Returns:
2237
- UpdatePlaybookStatusResponse: Response containing success status and message
2238
- """
2239
- return publisher_api.update_agent_playbook_status(org_id=org_id, request=request)
2240
-
2241
-
2242
- @core_router.put(
2243
- "/api/update_agent_playbook",
2244
- response_model=UpdateAgentPlaybookResponse,
2245
- response_model_exclude_none=True,
2246
- )
2247
- def update_agent_playbook_endpoint(
2248
- request: UpdateAgentPlaybookRequest,
2249
- org_id: str = Depends(default_get_org_id),
2250
- ) -> UpdateAgentPlaybookResponse:
2251
- """Update editable fields of a specific agent playbook.
2252
-
2253
- Args:
2254
- request (UpdateAgentPlaybookRequest): The update request
2255
- org_id (str): Organization ID
2256
-
2257
- Returns:
2258
- UpdateAgentPlaybookResponse: Response containing success status and message
2259
- """
2260
- return publisher_api.update_agent_playbook(org_id=org_id, request=request)
2261
-
2262
-
2263
- @core_router.put(
2264
- "/api/update_user_playbook",
2265
- response_model=UpdateUserPlaybookResponse,
2266
- response_model_exclude_none=True,
2267
- )
2268
- def update_user_playbook_endpoint(
2269
- request: UpdateUserPlaybookRequest,
2270
- org_id: str = Depends(default_get_org_id),
2271
- ) -> UpdateUserPlaybookResponse:
2272
- """Update editable fields of a specific user playbook.
2273
-
2274
- Args:
2275
- request (UpdateUserPlaybookRequest): The update request
2276
- org_id (str): Organization ID
2277
-
2278
- Returns:
2279
- UpdateUserPlaybookResponse: Response containing success status and message
2280
- """
2281
- return publisher_api.update_user_playbook(org_id=org_id, request=request)
2282
-
2283
-
2284
- @core_router.put(
2285
- "/api/update_user_profile",
2286
- response_model=UpdateUserProfileResponse,
2287
- response_model_exclude_none=True,
2288
- )
2289
- def update_user_profile_endpoint(
2290
- request: UpdateUserProfileRequest,
2291
- org_id: str = Depends(default_get_org_id),
2292
- ) -> UpdateUserProfileResponse:
2293
- """Apply a partial update to an existing user profile.
2294
-
2295
- Args:
2296
- request (UpdateUserProfileRequest): The update request
2297
- org_id (str): Organization ID
2298
-
2299
- Returns:
2300
- UpdateUserProfileResponse: Response containing success status and message
2301
- """
2302
- return publisher_api.update_user_profile(org_id=org_id, request=request)
2303
-
2304
-
2305
- @core_router.post(
2306
- "/api/get_dashboard_stats",
2307
- response_model=GetDashboardStatsResponse,
2308
- response_model_exclude_none=True,
2309
- )
2310
- @limiter.limit("30/minute")
2311
- def get_dashboard_stats(
2312
- request: Request,
2313
- payload: GetDashboardStatsRequest,
2314
- org_id: str = Depends(default_get_org_id),
2315
- ) -> GetDashboardStatsResponse:
2316
- """Get comprehensive dashboard statistics including counts and time-series data.
2317
-
2318
- Args:
2319
- request (GetDashboardStatsRequest): Request containing days_back and granularity
2320
- org_id (str): Organization ID
2321
-
2322
- Returns:
2323
- GetDashboardStatsResponse: Response containing dashboard statistics
2324
- """
2325
- # Create Reflexio instance
2326
- reflexio = get_reflexio(org_id=org_id)
2327
-
2328
- # Get dashboard stats using Reflexio's method
2329
- return reflexio.get_dashboard_stats(payload)
2330
-
2331
-
2332
- @core_router.post(
2333
- "/api/get_playbook_application_stats",
2334
- response_model=GetPlaybookApplicationStatsResponse,
2335
- response_model_exclude_none=True,
2336
- )
2337
- def get_playbook_application_stats(
2338
- request: GetPlaybookApplicationStatsRequest,
2339
- org_id: str = Depends(default_get_org_id),
2340
- ) -> GetPlaybookApplicationStatsResponse:
2341
- """Get per-rule citation counts aggregated from interactions.
2342
-
2343
- Returns one row per cited (kind, real_id) over the look-back window,
2344
- sorted by applied_count descending. Lets the dashboard show users a
2345
- per-rule "track record" — how often each playbook or profile has been
2346
- applied and when it last fired.
2347
-
2348
- Args:
2349
- request (GetPlaybookApplicationStatsRequest): Request containing
2350
- days_back.
2351
- org_id (str): Organization ID.
2352
-
2353
- Returns:
2354
- GetPlaybookApplicationStatsResponse: Response containing aggregated
2355
- stats.
2356
- """
2357
- reflexio = get_reflexio(org_id=org_id)
2358
- return reflexio.get_playbook_application_stats(request)
2359
-
2360
-
2361
- # ============================================================================
2362
- # Braintrust connector (Plan C-backend)
2363
- # ============================================================================
2364
-
2365
-
2366
- @core_router.post(
2367
- "/api/braintrust/connect",
2368
- response_model=ConnectBraintrustResponse,
2369
- response_model_exclude_none=True,
2370
- )
2371
- @limiter.limit("10/minute")
2372
- def braintrust_connect(
2373
- request: Request,
2374
- payload: ConnectBraintrustRequest,
2375
- org_id: str = Depends(default_get_org_id),
2376
- ) -> ConnectBraintrustResponse:
2377
- """Step 1: validate the Braintrust API key and list workspaces/projects.
2378
-
2379
- Persists nothing — call `/api/braintrust/select_projects` to commit.
2380
-
2381
- Args:
2382
- request (Request): The HTTP request object for rate limiting.
2383
- payload (ConnectBraintrustRequest): Customer's Braintrust API key.
2384
- org_id (str): Resolved by auth dependency.
2385
-
2386
- Returns:
2387
- ConnectBraintrustResponse: Workspaces tree on success; `success=False`
2388
- with a message when the key is rejected.
2389
- """
2390
- reflexio = get_reflexio(org_id=org_id)
2391
- return reflexio.braintrust_connect(payload)
2392
-
2393
-
2394
- @core_router.post(
2395
- "/api/braintrust/select_projects",
2396
- response_model=SelectProjectsResponse,
2397
- response_model_exclude_none=True,
2398
- )
2399
- @limiter.limit("10/minute")
2400
- def braintrust_select_projects(
2401
- request: Request,
2402
- payload: SelectProjectsRequest,
2403
- org_id: str = Depends(default_get_org_id),
2404
- ) -> SelectProjectsResponse:
2405
- """Step 2: commit the Braintrust connection with selected projects.
2406
-
2407
- The API key is encrypted at rest. Subsequent syncs use the persisted
2408
- connection until the customer calls DELETE /api/braintrust/connection.
2409
- """
2410
- reflexio = get_reflexio(org_id=org_id)
2411
- return reflexio.braintrust_select_projects(payload)
2412
-
2413
-
2414
- @core_router.get(
2415
- "/api/braintrust/status",
2416
- response_model=BraintrustStatusResponse,
2417
- response_model_exclude_none=True,
2418
- )
2419
- def braintrust_status(
2420
- org_id: str = Depends(default_get_org_id),
2421
- ) -> BraintrustStatusResponse:
2422
- """Return Braintrust connection state. Never echoes the API key."""
2423
- reflexio = get_reflexio(org_id=org_id)
2424
- return reflexio.braintrust_status()
2425
-
2426
-
2427
- @core_router.delete("/api/braintrust/connection")
2428
- @limiter.limit("10/minute")
2429
- def braintrust_disconnect(
2430
- request: Request,
2431
- org_id: str = Depends(default_get_org_id),
2432
- ) -> dict:
2433
- """Delete the persisted Braintrust connection for the org.
2434
-
2435
- Args:
2436
- org_id (str): Resolved by auth dependency.
2437
-
2438
- Returns:
2439
- dict: ``{"success": True}`` on completion.
2440
- """
2441
- reflexio = get_reflexio(org_id=org_id)
2442
- reflexio.braintrust_disconnect()
2443
- return {"success": True}
2444
-
2445
-
2446
- @core_router.post(
2447
- "/api/braintrust/sync",
2448
- response_model=SyncBraintrustResponse,
2449
- response_model_exclude_none=True,
2450
- )
2451
- @limiter.limit("10/minute")
2452
- def braintrust_sync(
2453
- request: Request,
2454
- org_id: str = Depends(default_get_org_id),
2455
- ) -> SyncBraintrustResponse:
2456
- """Trigger a one-shot sync of Braintrust scorer outputs.
2457
-
2458
- Scheduled (cron) sync is a follow-up; for now the endpoint exists so
2459
- operators can drive a manual import.
2460
- """
2461
- reflexio = get_reflexio(org_id=org_id)
2462
- return reflexio.braintrust_sync()
2463
-
2464
-
2465
- @core_router.post(
2466
- "/api/get_evaluation_overview",
2467
- response_model=GetEvaluationOverviewResponse,
2468
- response_model_exclude_none=True,
2469
- )
2470
- def get_evaluation_overview(
2471
- request: GetEvaluationOverviewRequest,
2472
- org_id: str = Depends(default_get_org_id),
2473
- ) -> GetEvaluationOverviewResponse:
2474
- """Return the redesigned /evaluations page payload.
2475
-
2476
- Aggregates hero state, four context tiles with deltas, top rule
2477
- attribution, and a corrections-per-session distribution into a single
2478
- response shaped exactly as the frontend renders it.
2479
-
2480
- Args:
2481
- request (GetEvaluationOverviewRequest): Window + bucket granularity.
2482
- org_id (str): Organization ID resolved by the auth dependency.
2483
-
2484
- Returns:
2485
- GetEvaluationOverviewResponse: Full overview payload.
2486
- """
2487
- reflexio = get_reflexio(org_id=org_id)
2488
- return reflexio.get_evaluation_overview(request)
2489
-
2490
-
2491
- # ---------------------------------------------------------------------------
2492
- # /api/evaluations/regenerate — replay the LLM judge over a window
2493
- # ---------------------------------------------------------------------------
2494
-
2495
-
2496
- @core_router.post(
2497
- "/api/evaluations/regenerate",
2498
- response_model=RegenerateStartResponse,
2499
- response_model_exclude_none=True,
2500
- )
2501
- @limiter.limit("5/minute")
2502
- def start_regenerate(
2503
- request: Request,
2504
- payload: RegenerateRequest,
2505
- org_id: str = Depends(default_get_org_id),
2506
- ) -> RegenerateStartResponse:
2507
- """Start a singleton regenerate job over a time window.
2508
-
2509
- Args:
2510
- payload (RegenerateRequest): Window bounds plus optional legacy evaluator name.
2511
- org_id (str): Organization ID resolved by the auth dependency.
2512
-
2513
- Returns:
2514
- RegenerateStartResponse: ``job_id`` to poll/cancel and ``total``
2515
- tuples queued.
1
+ """OSS FastAPI app composer (Tier3 A2).
2516
2
 
2517
- Raises:
2518
- HTTPException: 409 when a regenerate for the same org is already
2519
- running. 503 when storage is not configured.
2520
- """
2521
- reflexio = get_reflexio(org_id=org_id)
2522
- storage = reflexio.request_context.storage
2523
- if storage is None:
2524
- raise HTTPException(status_code=503, detail="Storage not configured")
2525
- descriptors = storage.get_session_ids_in_window(
2526
- from_ts=payload.from_ts, to_ts=payload.to_ts
2527
- )
2528
- try:
2529
- job = REGEN_JOBS.create(
2530
- org_id=org_id,
2531
- from_ts=payload.from_ts,
2532
- to_ts=payload.to_ts,
2533
- total=len(descriptors),
2534
- )
2535
- except RuntimeError as e:
2536
- raise HTTPException(status_code=409, detail=str(e)) from e
2537
- _regen_executor.submit(
2538
- run_regen,
2539
- job=job,
2540
- request_context=reflexio.request_context,
2541
- llm_client=reflexio.llm_client,
2542
- )
2543
- return RegenerateStartResponse(job_id=job.job_id, total=job.total)
2544
-
2545
-
2546
- @core_router.get(
2547
- "/api/evaluations/regenerate/{job_id}",
2548
- response_model=RegenerateStatusResponse,
2549
- response_model_exclude_none=True,
2550
- )
2551
- def get_regenerate_status(
2552
- job_id: str,
2553
- org_id: str = Depends(default_get_org_id),
2554
- ) -> RegenerateStatusResponse:
2555
- """Poll the status of a regenerate job.
2556
-
2557
- Args:
2558
- job_id (str): Opaque handle returned by POST /api/evaluations/regenerate.
2559
- org_id (str): Organization ID resolved by the auth dependency.
2560
-
2561
- Returns:
2562
- RegenerateStatusResponse: Counters, status, and failure list.
2563
-
2564
- Raises:
2565
- HTTPException: 404 when ``job_id`` is unknown or belongs to a
2566
- different org.
2567
- """
2568
- job = REGEN_JOBS.get(job_id)
2569
- if job is None or job.org_id != org_id:
2570
- raise HTTPException(status_code=404, detail="Unknown job_id")
2571
- return RegenerateStatusResponse(
2572
- job_id=job.job_id,
2573
- status=job.status,
2574
- total=job.total,
2575
- completed=job.completed,
2576
- failed=job.failed,
2577
- failures=[
2578
- RegenerateFailure(session_id=f.session_id, reason=f.reason)
2579
- for f in job.failures
2580
- ],
2581
- started_at=job.started_at,
2582
- finished_at=job.finished_at,
2583
- # F3 informational counters — surface sampler + concurrency facts
2584
- # so the dashboard can render "n_sampled / total_candidates" and
2585
- # the configured worker cap without a second round-trip.
2586
- total_candidates=job.total_candidates,
2587
- sampled_count=job.sampled_count,
2588
- concurrency_limit=job.concurrency_limit,
2589
- )
2590
-
2591
-
2592
- @core_router.delete("/api/evaluations/regenerate/{job_id}")
2593
- def cancel_regenerate(
2594
- job_id: str,
2595
- org_id: str = Depends(default_get_org_id),
2596
- ) -> dict[str, str]:
2597
- """Request cancellation of a running regenerate job.
2598
-
2599
- Sets the worker's cancel event; the worker checks the flag between
2600
- sessions and transitions to ``"cancelled"`` on its next iteration.
2601
-
2602
- Args:
2603
- job_id (str): Opaque handle returned by POST /api/evaluations/regenerate.
2604
- org_id (str): Organization ID resolved by the auth dependency.
2605
-
2606
- Returns:
2607
- dict[str, str]: ``{"status": "cancelled"}`` on successful flag set.
2608
-
2609
- Raises:
2610
- HTTPException: 404 when ``job_id`` is unknown or belongs to a
2611
- different org.
2612
- """
2613
- job = REGEN_JOBS.get(job_id)
2614
- if job is None or job.org_id != org_id:
2615
- raise HTTPException(status_code=404, detail="Unknown job_id")
2616
- REGEN_JOBS.cancel(job_id)
2617
- return {"status": "cancelled"}
2618
-
2619
-
2620
- # ---------------------------------------------------------------------------
2621
- # /api/evaluations/grade_on_demand — single-session click-through grading
2622
- # ---------------------------------------------------------------------------
2623
- #
2624
- # F3 sampling means most sessions in a regen window are NEVER graded —
2625
- # the sampler keeps cost predictable by capping each (day, group) stratum
2626
- # at ``Config.eval_sample_n_per_stratum``. Plan 3 (F1) surfaces a
2627
- # bounded list of sessions in the UI; when a customer clicks one that
2628
- # wasn't in the sampled subset, the frontend hits this endpoint to grade
2629
- # it on demand. The 24h cache prevents repeated clicks from triggering
2630
- # redundant LLM calls.
2631
-
2632
- _GRADE_ON_DEMAND_CACHE_TTL_SECONDS = 24 * 60 * 60
2633
- _GRADE_ON_DEMAND_CACHE_KEY_PREFIX = "grade_on_demand"
2634
-
2635
-
2636
- def _grade_on_demand_cache_key(
2637
- org_id: str, session_id: str, agent_version: str, evaluation_name: str
2638
- ) -> str:
2639
- """Build the operation_state key for the on-demand grading cache.
2640
-
2641
- The key embeds every active singleton dimension that could change the
2642
- verdict: org_id (multi-tenant scope), session_id (the unit of work),
2643
- agent_version (eval results are versioned), and evaluation_name (kept as a
2644
- compatibility/readback discriminator for historical multi-evaluator rows).
2645
-
2646
- Args:
2647
- org_id (str): Tenant identifier from the auth context.
2648
- session_id (str): Target session.
2649
- agent_version (str): Agent version filter.
2650
- evaluation_name (str): Evaluator/result namespace to isolate cache rows.
2651
- Returns:
2652
- str: A namespaced key suitable for ``storage.upsert_operation_state``.
2653
- """
2654
- return (
2655
- f"{_GRADE_ON_DEMAND_CACHE_KEY_PREFIX}::{org_id}::{session_id}"
2656
- f"::{agent_version}::{evaluation_name}"
2657
- )
2658
-
2659
-
2660
- def _read_grade_on_demand_cache(
2661
- storage: Any, cache_key: str, *, now: int
2662
- ) -> int | None:
2663
- """Return the cached ``result_id`` if a valid entry exists, else None.
2664
-
2665
- Returns None on three conditions: no entry, malformed entry, or entry
2666
- whose ``last_graded_at`` is older than the 24h TTL. Keeps the handler
2667
- body focused on the happy path.
2668
-
2669
- Args:
2670
- storage: The request's storage backend.
2671
- cache_key (str): Key produced by ``_grade_on_demand_cache_key``.
2672
- now (int): Current Unix-seconds wall-clock timestamp.
2673
-
2674
- Returns:
2675
- int | None: Cached result_id when fresh, else None.
2676
- """
2677
- cached_state = storage.get_operation_state(cache_key)
2678
- if not cached_state:
2679
- return None
2680
- state = cached_state.get("operation_state")
2681
- if not isinstance(state, dict):
2682
- return None
2683
- last_graded_at = state.get("last_graded_at")
2684
- if not isinstance(last_graded_at, int):
2685
- return None
2686
- if (now - last_graded_at) >= _GRADE_ON_DEMAND_CACHE_TTL_SECONDS:
2687
- return None
2688
- cached_result_id = state.get("result_id")
2689
- return cached_result_id if isinstance(cached_result_id, int) else None
2690
-
2691
-
2692
- def _resolve_session_user_id(storage: Any, session_id: str) -> str | None:
2693
- """Look up the user_id that owns a session_id without requiring it as input.
2694
-
2695
- Uses the first-request bulk helper even for this single-session path so the
2696
- lookup can use the same indexed query shape as evaluation overview.
2697
-
2698
- Args:
2699
- storage: The request's storage backend.
2700
- session_id (str): The target session whose owner to resolve.
2701
-
2702
- Returns:
2703
- str | None: The user_id of the earliest request in the session,
2704
- or None when no requests exist.
2705
- """
2706
- first_requests = storage.get_first_requests_by_session_ids([session_id])
2707
- first = first_requests.get(session_id)
2708
- if first is None:
2709
- return None
2710
- return first.user_id
2711
-
2712
-
2713
- def _find_fresh_result_id(
2714
- storage: Any,
2715
- *,
2716
- user_id: str,
2717
- session_id: str,
2718
- agent_version: str,
2719
- evaluation_name: str,
2720
- previous_result_ids: set[int],
2721
- ) -> int | None:
2722
- """Locate the result_id written by the most-recent grade for this session.
2723
-
2724
- The runner writes rows but doesn't return the id. Use the targeted result-id
2725
- lookup so this path does not scan broad evaluation windows.
2726
-
2727
- Args:
2728
- storage: The request's storage backend.
2729
- user_id (str): The user whose session slice was graded.
2730
- session_id (str): The graded session.
2731
- agent_version (str): The version dimension.
2732
- evaluation_name (str): Evaluator/result namespace to isolate readback.
2733
- previous_result_ids (set[int]): Matching rows observed before grading.
2734
-
2735
- Returns:
2736
- int | None: result_id of the latest matching row, or None if the
2737
- runner wrote nothing.
2738
- """
2739
- result_ids = storage.get_agent_success_evaluation_result_ids(
2740
- user_id=user_id,
2741
- session_id=session_id,
2742
- evaluation_name=evaluation_name,
2743
- agent_version=agent_version,
2744
- )
2745
- fresh_result_ids = [rid for rid in result_ids if rid not in previous_result_ids]
2746
- if not fresh_result_ids:
2747
- return None
2748
- return max(fresh_result_ids)
2749
-
2750
-
2751
- @core_router.post(
2752
- "/api/evaluations/grade_on_demand",
2753
- response_model=GradeOnDemandResponse,
2754
- response_model_exclude_none=False,
2755
- )
2756
- def grade_on_demand(
2757
- payload: GradeOnDemandRequest,
2758
- org_id: str = Depends(default_get_org_id),
2759
- ) -> GradeOnDemandResponse:
2760
- """Grade a single session synchronously; serve cached results within 24h.
2761
-
2762
- Flow:
2763
- 1. Read the operation_state cache; if a fresh entry exists, return it
2764
- with ``cached=True``.
2765
- 2. Resolve the session's user_id from storage (skip with ``NO_REQUESTS``
2766
- when the session is unknown — surfaced as 200 + ``skipped_reason``
2767
- so the frontend's bounded-list click-through can handle stale ids
2768
- locally without polluting 5xx telemetry).
2769
- 3. Invoke ``run_group_evaluation(force_regenerate=True)`` so the
2770
- "already evaluated" short-circuit doesn't suppress a customer's
2771
- explicit click.
2772
- 4. Find the freshly-written result_id and persist it in the cache
2773
- with ``last_graded_at`` so future calls within 24h short-circuit.
2774
-
2775
- Args:
2776
- payload (GradeOnDemandRequest): Session + version plus optional legacy evaluator name.
2777
- org_id (str): Tenant identifier resolved by the auth dependency.
2778
-
2779
- Returns:
2780
- GradeOnDemandResponse: Echoes ``session_id`` and carries either
2781
- a fresh ``result_id`` (``cached=False``), a cached one
2782
- (``cached=True``), or a ``skipped_reason`` (NO_REQUESTS).
2783
-
2784
- Raises:
2785
- HTTPException: 503 when storage is not configured.
2786
- """
2787
- reflexio = get_reflexio(org_id=org_id)
2788
- storage = reflexio.request_context.storage
2789
- if storage is None:
2790
- raise HTTPException(status_code=503, detail="Storage not configured")
2791
-
2792
- evaluation_name = payload.evaluation_name or SINGLETON_AGENT_SUCCESS_EVALUATION_NAME
2793
- cache_key = _grade_on_demand_cache_key(
2794
- org_id,
2795
- payload.session_id,
2796
- payload.agent_version,
2797
- evaluation_name,
2798
- )
2799
- now = int(datetime.now(UTC).timestamp())
2800
-
2801
- cached_result_id = _read_grade_on_demand_cache(storage, cache_key, now=now)
2802
- if cached_result_id is not None:
2803
- return GradeOnDemandResponse(
2804
- session_id=payload.session_id,
2805
- result_id=cached_result_id,
2806
- cached=True,
2807
- skipped_reason=None,
2808
- )
2809
-
2810
- user_id = _resolve_session_user_id(storage, payload.session_id)
2811
- if user_id is None:
2812
- return GradeOnDemandResponse(
2813
- session_id=payload.session_id,
2814
- result_id=None,
2815
- cached=False,
2816
- skipped_reason="NO_REQUESTS",
2817
- )
2818
-
2819
- previous_result_ids = set(
2820
- storage.get_agent_success_evaluation_result_ids(
2821
- user_id=user_id,
2822
- session_id=payload.session_id,
2823
- evaluation_name=evaluation_name,
2824
- agent_version=payload.agent_version,
2825
- )
2826
- )
2827
-
2828
- # Two operation_state rows are intentionally written for this session:
2829
- # 1) `grade_on_demand::{org_id}::{session_id}::{agent_version}::{evaluation_name}`
2830
- # — our 24h cache, set below after the result_id is resolved.
2831
- # 2) `agent_success_group_eval::{org_id}::{user_id}::{session_id}`
2832
- # — the runner's own "evaluated" marker, written by
2833
- # run_group_evaluation. Future background runs without
2834
- # force_regenerate will skip this session as a result.
2835
- # The cache key namespaces are distinct so the two markers do not
2836
- # interfere; the explicit force_regenerate=True here is what makes
2837
- # an on-demand grade always do real work on a cache miss.
2838
- run_group_evaluation(
2839
- org_id=org_id,
2840
- user_id=user_id,
2841
- session_id=payload.session_id,
2842
- agent_version=payload.agent_version,
2843
- source=None,
2844
- request_context=reflexio.request_context,
2845
- llm_client=reflexio.llm_client,
2846
- force_regenerate=True,
2847
- )
2848
-
2849
- result_id = _find_fresh_result_id(
2850
- storage,
2851
- user_id=user_id,
2852
- session_id=payload.session_id,
2853
- agent_version=payload.agent_version,
2854
- evaluation_name=evaluation_name,
2855
- previous_result_ids=previous_result_ids,
2856
- )
2857
-
2858
- storage.upsert_operation_state(
2859
- cache_key,
2860
- {"last_graded_at": now, "result_id": result_id},
2861
- )
2862
-
2863
- return GradeOnDemandResponse(
2864
- session_id=payload.session_id,
2865
- result_id=result_id,
2866
- cached=False,
2867
- skipped_reason=None,
2868
- )
2869
-
2870
-
2871
- # ---------------------------------------------------------------------------
2872
- # /api/evaluations/shadow_comparisons/recent — F1 drawer + Top 10 widget
2873
- # ---------------------------------------------------------------------------
2874
- #
2875
- # Powers two surfaces on /evaluations:
2876
- # 1. The drawer triggered from the per-turn comparison tile — shows the
2877
- # N most recent verdicts so customers can spot-check the judge.
2878
- # 2. The "Top 10 disagreements" widget — fetches a wider pool and the
2879
- # frontend filters to ``is_significantly_better=True`` losses to surface
2880
- # actionable rule-correction candidates.
2881
- #
2882
- # Filtering is restricted to the org's currently pinned
2883
- # ``shadow_comparison_judge_prompt_version`` so verdicts from an older rubric
2884
- # never mix into the drawer. The 30-day lookback is a defensive cap that lets
2885
- # the storage layer use an index range scan instead of a full table read.
2886
-
2887
- _RECENT_SHADOW_COMPARISONS_LOOKBACK_SECONDS = 30 * 24 * 60 * 60
2888
- _RECENT_SHADOW_COMPARISONS_MAX_LIMIT = 100
2889
-
2890
-
2891
- @core_router.get(
2892
- "/api/evaluations/shadow_comparisons/recent",
2893
- response_model=GetRecentShadowComparisonsResponse,
2894
- )
2895
- def get_recent_shadow_comparisons(
2896
- limit: int = 10,
2897
- org_id: str = Depends(default_get_org_id),
2898
- ) -> GetRecentShadowComparisonsResponse:
2899
- """Return the N most recent shadow comparison verdicts for the pinned rubric.
2900
-
2901
- Filters to the org's currently pinned
2902
- ``Config.shadow_comparison_judge_prompt_version`` so verdicts produced
2903
- under an older rubric do not mix into the drawer or the Top 10
2904
- disagreements widget. Storage returns verdicts newest-first and caps the
2905
- read at ``limit``.
2906
-
2907
- Args:
2908
- limit (int): Max verdicts to return. Clamped to ``[1, 100]``.
2909
- Default 10 — matches the size of the drawer and Top 10 widget.
2910
- org_id (str): Tenant identifier resolved by the auth dependency.
2911
-
2912
- Returns:
2913
- GetRecentShadowComparisonsResponse: Verdicts in newest-first order.
2914
- Empty list when the backend does not support the
2915
- ``shadow_comparison_verdicts`` storage feature, when no verdicts
2916
- exist in the 30-day window, or when no verdicts match the pinned
2917
- prompt version.
2918
-
2919
- Raises:
2920
- HTTPException: 503 when storage is not configured.
2921
- """
2922
- clamped_limit = max(1, min(int(limit), _RECENT_SHADOW_COMPARISONS_MAX_LIMIT))
2923
- reflexio = get_reflexio(org_id=org_id)
2924
- storage = reflexio.request_context.storage
2925
- if storage is None:
2926
- raise HTTPException(status_code=503, detail="Storage not configured")
2927
-
2928
- config = reflexio.request_context.configurator.get_config()
2929
- pinned_version = (
2930
- config.shadow_comparison_judge_prompt_version
2931
- if config is not None
2932
- else "v1.0.0"
2933
- )
2934
-
2935
- now = int(datetime.now(UTC).timestamp())
2936
- try:
2937
- verdicts = storage.get_recent_shadow_comparison_verdicts(
2938
- from_ts=now - _RECENT_SHADOW_COMPARISONS_LOOKBACK_SECONDS,
2939
- to_ts=now,
2940
- judge_prompt_version=pinned_version,
2941
- limit=clamped_limit,
2942
- )
2943
- except NotImplementedError:
2944
- # Backends that don't support shadow verdicts (e.g., Disk) should
2945
- # quietly return empty rather than 5xx — the surface degrades to
2946
- # "no data yet" in the UI.
2947
- return GetRecentShadowComparisonsResponse(verdicts=[])
2948
-
2949
- return GetRecentShadowComparisonsResponse(verdicts=verdicts)
2950
-
2951
-
2952
- @core_router.post(
2953
- "/api/rerun_profile_generation",
2954
- response_model=RerunProfileGenerationResponse,
2955
- response_model_exclude_none=True,
2956
- )
2957
- @limiter.limit("5/minute") # Strict limit for expensive operations
2958
- def rerun_profile_generation_endpoint(
2959
- request: Request,
2960
- payload: RerunProfileGenerationRequest,
2961
- background_tasks: BackgroundTasks,
2962
- org_id: str = Depends(default_get_org_id),
2963
- ) -> RerunProfileGenerationResponse:
2964
- """Rerun profile generation for a user with filtered interactions.
2965
-
2966
- Args:
2967
- request (Request): The HTTP request object (for rate limiting)
2968
- payload (RerunProfileGenerationRequest): Request containing user_id, time filters, and source
2969
- background_tasks (BackgroundTasks): Background task runner
2970
- org_id (str): Organization ID
2971
-
2972
- Returns:
2973
- RerunProfileGenerationResponse: Response containing success status and profiles generated count
2974
- """
2975
- # Create Reflexio instance
2976
- reflexio = get_reflexio(org_id=org_id)
2977
-
2978
- # Run the long-running task in the background to avoid proxy timeout
2979
- # Client polls get_operation_status for progress
2980
- background_tasks.add_task(reflexio.rerun_profile_generation, payload)
2981
-
2982
- return RerunProfileGenerationResponse(
2983
- success=True, msg="Profile generation started"
2984
- )
2985
-
2986
-
2987
- @core_router.post(
2988
- "/api/manual_profile_generation",
2989
- response_model=ManualProfileGenerationResponse,
2990
- response_model_exclude_none=True,
2991
- )
2992
- @limiter.limit("5/minute") # Strict limit for expensive operations
2993
- def manual_profile_generation_endpoint(
2994
- request: Request,
2995
- payload: ManualProfileGenerationRequest,
2996
- org_id: str = Depends(default_get_org_id),
2997
- ) -> ManualProfileGenerationResponse:
2998
- """Manually trigger profile generation with window-sized interactions and CURRENT output.
2999
-
3000
- Runs with auto_run=False, which bypasses the regular stride/should_run
3001
- gates. Only profile extraction is triggered. Each extractor uses its own
3002
- window_size_override when present, falling back to the global window_size.
3003
- Output is CURRENT profiles only.
3004
-
3005
- Args:
3006
- request (Request): The HTTP request object (for rate limiting)
3007
- payload (ManualProfileGenerationRequest): Request containing user_id, source, and extractor_names
3008
- org_id (str): Organization ID
3009
-
3010
- Returns:
3011
- ManualProfileGenerationResponse: Response containing success status and profiles generated count
3012
- """
3013
- # Create Reflexio instance
3014
- reflexio = get_reflexio(org_id=org_id)
3015
-
3016
- # Call manual_profile_generation
3017
- return reflexio.manual_profile_generation(payload)
3018
-
3019
-
3020
- @core_router.post(
3021
- "/api/rerun_playbook_generation",
3022
- response_model=RerunPlaybookGenerationResponse,
3023
- response_model_exclude_none=True,
3024
- )
3025
- @limiter.limit("5/minute") # Strict limit for expensive operations
3026
- def rerun_playbook_generation_endpoint(
3027
- request: Request,
3028
- payload: RerunPlaybookGenerationRequest,
3029
- background_tasks: BackgroundTasks,
3030
- org_id: str = Depends(default_get_org_id),
3031
- ) -> RerunPlaybookGenerationResponse:
3032
- """Rerun playbook generation with filtered interactions.
3033
-
3034
- Args:
3035
- request (Request): The HTTP request object (for rate limiting)
3036
- payload (RerunPlaybookGenerationRequest): Request containing agent_version, time filters, and optional playbook_name
3037
- background_tasks (BackgroundTasks): Background task runner
3038
- org_id (str): Organization ID
3039
-
3040
- Returns:
3041
- RerunPlaybookGenerationResponse: Response containing success status and playbooks generated count
3042
- """
3043
- # Create Reflexio instance
3044
- reflexio = get_reflexio(org_id=org_id)
3045
-
3046
- # Run the long-running task in the background to avoid proxy timeout
3047
- # Client polls get_operation_status for progress
3048
- background_tasks.add_task(reflexio.rerun_playbook_generation, payload)
3049
-
3050
- return RerunPlaybookGenerationResponse(
3051
- success=True, msg="Playbook generation started"
3052
- )
3053
-
3054
-
3055
- @core_router.post(
3056
- "/api/manual_playbook_generation",
3057
- response_model=ManualPlaybookGenerationResponse,
3058
- response_model_exclude_none=True,
3059
- )
3060
- @limiter.limit("5/minute") # Strict limit for expensive operations
3061
- def manual_playbook_generation_endpoint(
3062
- request: Request,
3063
- payload: ManualPlaybookGenerationRequest,
3064
- org_id: str = Depends(default_get_org_id),
3065
- ) -> ManualPlaybookGenerationResponse:
3066
- """Manually trigger playbook generation with window-sized interactions and CURRENT output.
3067
-
3068
- Runs with auto_run=False, which bypasses the regular stride/should_run
3069
- gates. Only playbook extraction is triggered. Each extractor uses its own
3070
- window_size_override when present, falling back to the global window_size.
3071
- Output is CURRENT playbooks only.
3072
-
3073
- Args:
3074
- request (Request): The HTTP request object (for rate limiting)
3075
- payload (ManualPlaybookGenerationRequest): Request containing agent_version, source, and playbook_name
3076
- org_id (str): Organization ID
3077
-
3078
- Returns:
3079
- ManualPlaybookGenerationResponse: Response containing success status and playbooks generated count
3080
- """
3081
- # Create Reflexio instance
3082
- reflexio = get_reflexio(org_id=org_id)
3083
-
3084
- # Call manual_playbook_generation
3085
- return reflexio.manual_playbook_generation(payload)
3086
-
3087
-
3088
- @core_router.post(
3089
- "/api/upgrade_all_profiles",
3090
- response_model=UpgradeProfilesResponse,
3091
- response_model_exclude_none=True,
3092
- )
3093
- def upgrade_all_profiles_endpoint(
3094
- request: UpgradeProfilesRequest,
3095
- org_id: str = Depends(default_get_org_id),
3096
- ) -> UpgradeProfilesResponse:
3097
- """Upgrade all profiles by deleting old ARCHIVED, archiving CURRENT, and promoting PENDING.
3098
-
3099
- This operation performs three atomic steps:
3100
- 1. Delete all ARCHIVED profiles (old archived profiles from previous upgrades)
3101
- 2. Archive all CURRENT profiles → ARCHIVED (save current state for potential rollback)
3102
- 3. Promote all PENDING profiles → CURRENT (activate new profiles)
3103
-
3104
- Args:
3105
- request (UpgradeProfilesRequest): The upgrade request with only_affected_users parameter
3106
- org_id (str): Organization ID
3107
-
3108
- Returns:
3109
- UpgradeProfilesResponse: Response containing success status and counts
3110
- """
3111
- # Create Reflexio instance
3112
- reflexio = get_reflexio(org_id=org_id)
3
+ ``create_app`` is a thin composition root: it builds the FastAPI instance, wires
4
+ the five middleware (in order), registers the CORS + rate-limit + auth-override
5
+ seams, mounts the data-plane routers, and attaches the capability lifespan loop.
3113
6
 
3114
- # Call upgrade_all_profiles with request
3115
- return reflexio.upgrade_all_profiles(request=request)
7
+ The data-plane handlers live in ``reflexio.server.routes.<domain>`` sub-routers.
8
+ ``core_router`` aggregates every sub-router via ``include_router`` so it remains
9
+ the single data-plane router surface the enterprise composition mounts and
10
+ iterates (e.g. the QPS billable-endpoint scan over ``core_router.routes``).
3116
11
 
12
+ ``limiter`` / ``configure_rate_limiter`` are re-exported from
13
+ ``reflexio.server.rate_limit`` and the middleware classes from
14
+ ``reflexio.server.middleware`` for backwards-compatible imports.
15
+ """
3117
16
 
3118
- @core_router.post(
3119
- "/api/downgrade_all_profiles",
3120
- response_model=DowngradeProfilesResponse,
3121
- response_model_exclude_none=True,
3122
- )
3123
- def downgrade_all_profiles_endpoint(
3124
- request: DowngradeProfilesRequest,
3125
- org_id: str = Depends(default_get_org_id),
3126
- ) -> DowngradeProfilesResponse:
3127
- """Downgrade all profiles by demoting CURRENT to PENDING and restoring ARCHIVED.
3128
-
3129
- This operation performs two atomic steps:
3130
- 1. Demote all CURRENT profiles → PENDING
3131
- 2. Restore all ARCHIVED profiles → CURRENT
3132
-
3133
- Args:
3134
- request (DowngradeProfilesRequest): The downgrade request with only_affected_users parameter
3135
- org_id (str): Organization ID
3136
-
3137
- Returns:
3138
- DowngradeProfilesResponse: Response containing success status and counts
3139
- """
3140
- # Create Reflexio instance
3141
- reflexio = get_reflexio(org_id=org_id)
17
+ import inspect
18
+ import logging
19
+ from collections.abc import Callable
20
+ from typing import TYPE_CHECKING
3142
21
 
3143
- # Call downgrade_all_profiles with request
3144
- return reflexio.downgrade_all_profiles(request=request)
22
+ if TYPE_CHECKING:
23
+ from reflexio.server.deployment_profile import DeploymentProfile
24
+ from reflexio.server.extensions import AppContext, CapabilityRegistry
3145
25
 
26
+ from fastapi import APIRouter, FastAPI
27
+ from fastapi.middleware.cors import CORSMiddleware
28
+ from slowapi import _rate_limit_exceeded_handler
29
+ from slowapi.errors import RateLimitExceeded
3146
30
 
3147
- @core_router.post(
3148
- "/api/upgrade_all_user_playbooks",
3149
- response_model=UpgradeUserPlaybooksResponse,
3150
- response_model_exclude_none=True,
31
+ from reflexio.server.api_endpoints import (
32
+ health_api,
33
+ pending_tool_call_api,
34
+ stall_state_api,
3151
35
  )
3152
- def upgrade_all_user_playbooks_endpoint(
3153
- request: UpgradeUserPlaybooksRequest,
3154
- org_id: str = Depends(default_get_org_id),
3155
- ) -> UpgradeUserPlaybooksResponse:
3156
- """Upgrade all user playbooks by deleting old ARCHIVED, archiving CURRENT, and promoting PENDING.
3157
-
3158
- This operation performs three atomic steps:
3159
- 1. Delete all ARCHIVED user playbooks (old archived from previous upgrades)
3160
- 2. Archive all CURRENT user playbooks → ARCHIVED (save current state for potential rollback)
3161
- 3. Promote all PENDING user playbooks → CURRENT (activate new user playbooks)
3162
-
3163
- Args:
3164
- request (UpgradeUserPlaybooksRequest): The upgrade request with optional agent_version and playbook_name filters
3165
- org_id (str): Organization ID
3166
-
3167
- Returns:
3168
- UpgradeUserPlaybooksResponse: Response containing success status and counts
3169
- """
3170
- # Create Reflexio instance
3171
- reflexio = get_reflexio(org_id=org_id)
3172
-
3173
- # Call upgrade_all_user_playbooks with request
3174
- return reflexio.upgrade_all_user_playbooks(request=request)
3175
-
3176
-
3177
- @core_router.post(
3178
- "/api/downgrade_all_user_playbooks",
3179
- response_model=DowngradeUserPlaybooksResponse,
3180
- response_model_exclude_none=True,
36
+ from reflexio.server.auth import (
37
+ DEFAULT_ORG_ID,
38
+ default_billing_gate,
39
+ default_get_caller_type,
40
+ default_get_org_id,
3181
41
  )
3182
- def downgrade_all_user_playbooks_endpoint(
3183
- request: DowngradeUserPlaybooksRequest,
3184
- org_id: str = Depends(default_get_org_id),
3185
- ) -> DowngradeUserPlaybooksResponse:
3186
- """Downgrade all user playbooks by archiving CURRENT and restoring ARCHIVED.
3187
-
3188
- This operation performs three atomic steps:
3189
- 1. Mark all CURRENT user playbooks → ARCHIVE_IN_PROGRESS (temporary status)
3190
- 2. Restore all ARCHIVED user playbooks → CURRENT
3191
- 3. Move all ARCHIVE_IN_PROGRESS user playbooks → ARCHIVED
3192
-
3193
- Args:
3194
- request (DowngradeUserPlaybooksRequest): The downgrade request with optional agent_version and playbook_name filters
3195
- org_id (str): Organization ID
3196
-
3197
- Returns:
3198
- DowngradeUserPlaybooksResponse: Response containing success status and counts
3199
- """
3200
- # Create Reflexio instance
3201
- reflexio = get_reflexio(org_id=org_id)
3202
-
3203
- # Call downgrade_all_user_playbooks with request
3204
- return reflexio.downgrade_all_user_playbooks(request=request)
3205
-
3206
-
3207
- @core_router.get(
3208
- "/api/get_operation_status",
3209
- response_model=GetOperationStatusResponse,
3210
- response_model_exclude_none=True,
42
+ from reflexio.server.middleware import (
43
+ BodySizeLimitMiddleware,
44
+ BotProtectionMiddleware,
45
+ CorrelationIdMiddleware,
46
+ SecurityHeadersMiddleware,
47
+ TimeoutMiddleware,
48
+ _resolve_cors_origins,
49
+ )
50
+ from reflexio.server.operation_limiter import log_publish_hardware_capacity
51
+ from reflexio.server.rate_limit import configure_rate_limiter, limiter
52
+ from reflexio.server.routes import (
53
+ braintrust,
54
+ config,
55
+ evaluation,
56
+ interactions,
57
+ playbooks,
58
+ profiles,
59
+ provenance,
60
+ search,
61
+ system,
3211
62
  )
3212
- def get_operation_status_endpoint(
3213
- service_name: str = "profile_generation",
3214
- org_id: str = Depends(default_get_org_id),
3215
- ) -> GetOperationStatusResponse:
3216
- """Get the status of an operation (e.g., profile generation rerun or manual).
3217
-
3218
- Args:
3219
- service_name (str): The service name to query. Defaults to "profile_generation"
3220
- org_id (str): Organization ID
3221
63
 
3222
- Returns:
3223
- GetOperationStatusResponse: Response containing operation status info
3224
- """
3225
- # Create Reflexio instance
3226
- reflexio = get_reflexio(org_id=org_id)
3227
-
3228
- # Get operation status
3229
- request = GetOperationStatusRequest(service_name=service_name)
3230
- return reflexio.get_operation_status(request)
3231
-
3232
-
3233
- @core_router.post(
3234
- "/api/cancel_operation",
3235
- response_model=CancelOperationResponse,
3236
- response_model_exclude_none=True,
3237
- )
3238
- @limiter.limit("10/minute")
3239
- def cancel_operation_endpoint(
3240
- request: Request,
3241
- payload: CancelOperationRequest,
3242
- org_id: str = Depends(default_get_org_id),
3243
- ) -> CancelOperationResponse:
3244
- """Cancel an in-progress operation (rerun or manual generation).
64
+ logger = logging.getLogger(__name__)
3245
65
 
3246
- Args:
3247
- request (Request): The HTTP request object (for rate limiting)
3248
- payload (CancelOperationRequest): Request containing optional service_name
3249
- org_id (str): Organization ID
66
+ # Re-exported for backwards compatibility — callers that did
67
+ # ``from reflexio.server.api import default_get_org_id`` / ``DEFAULT_ORG_ID`` /
68
+ # ``limiter`` / ``configure_rate_limiter`` / ``core_router`` continue to work.
69
+ __all__ = [
70
+ "DEFAULT_ORG_ID",
71
+ "configure_rate_limiter",
72
+ "core_router",
73
+ "create_app",
74
+ "default_billing_gate",
75
+ "default_get_caller_type",
76
+ "default_get_org_id",
77
+ "limiter",
78
+ ]
3250
79
 
3251
- Returns:
3252
- CancelOperationResponse: Response with list of services that were cancelled
3253
- """
3254
- reflexio = get_reflexio(org_id=org_id)
3255
- return reflexio.cancel_operation(payload)
80
+ # ``core_router`` stays an aggregator: it ``include_router``s every domain
81
+ # sub-router so ``core_router.routes`` still enumerates all data-plane handlers
82
+ # (enterprise QPS enforcement iterates this list). The include order is
83
+ # functionally irrelevant — all paths are distinct literals with no overlapping
84
+ # templates, so FastAPI's first-match routing is unaffected.
85
+ core_router = APIRouter()
86
+ for _domain_router in (
87
+ system.router,
88
+ interactions.router,
89
+ profiles.router,
90
+ playbooks.router,
91
+ search.router,
92
+ provenance.router,
93
+ evaluation.router,
94
+ braintrust.router,
95
+ config.router,
96
+ ):
97
+ core_router.include_router(_domain_router)
3256
98
 
3257
99
 
3258
100
  # Paths that should remain publicly accessible (no lock icon in Swagger)
@@ -3302,6 +144,120 @@ def _add_openapi_security(app: FastAPI) -> None:
3302
144
  app.openapi = custom_openapi # type: ignore[method-assign]
3303
145
 
3304
146
 
147
+ def _resolve_lifespan_org_id(get_org_id: Callable[..., str] | None) -> str:
148
+ """Resolve the bootstrap org ID for lifespan schedulers without a request context.
149
+
150
+ Args:
151
+ get_org_id (Callable[..., str] | None): Custom org-ID dependency, or None for
152
+ the default single-tenant mode.
153
+
154
+ Returns:
155
+ str: The resolved org ID string.
156
+ """
157
+ from reflexio.server.auth import default_get_org_id
158
+
159
+ if get_org_id is None:
160
+ return default_get_org_id()
161
+ try:
162
+ signature = inspect.signature(get_org_id)
163
+ except (TypeError, ValueError):
164
+ return default_get_org_id()
165
+ if signature.parameters:
166
+ return default_get_org_id()
167
+ try:
168
+ return str(get_org_id())
169
+ except Exception:
170
+ logger.exception("Failed to resolve lifespan org_id; using default org")
171
+ return default_get_org_id()
172
+
173
+
174
+ def _wire_capabilities(
175
+ app: FastAPI,
176
+ capabilities: "CapabilityRegistry | None",
177
+ mount_data_plane: bool,
178
+ additional_routers: list[APIRouter] | None = None,
179
+ ) -> None:
180
+ """Wire capability routers, services, and hooks into the app at construction time.
181
+
182
+ No-op when ``capabilities`` is None.
183
+
184
+ The deployment role passed to each capability's ``routers(role)`` is
185
+ sourced from ``capabilities.role`` when set (threaded in by the enterprise
186
+ composition root via ``build_registry(deployment_role())``). When
187
+ ``capabilities.role`` is ``None`` the role falls back to the
188
+ ``mount_data_plane`` derivation (``"all"`` when True, ``"control-plane"``
189
+ when False), preserving the existing OSS-only behaviour.
190
+
191
+ Args:
192
+ app (FastAPI): The application instance to wire into.
193
+ capabilities (CapabilityRegistry | None): Capabilities to install, or None.
194
+ mount_data_plane (bool): Whether the data-plane role is active.
195
+ additional_routers (list[APIRouter] | None): Routers already mounted via
196
+ ``additional_routers``; used to detect and reject double-mounts at boot.
197
+
198
+ Raises:
199
+ ValueError: If a capability's router object is also present in
200
+ ``additional_routers`` — each router must be mounted exactly once.
201
+ """
202
+ if capabilities is None:
203
+ return
204
+ from reflexio.server.auth import default_billing_gate
205
+ from reflexio.server.extensions import HookRegistry
206
+
207
+ if capabilities.role is not None:
208
+ role = capabilities.role
209
+ else:
210
+ role = "all" if mount_data_plane else "control-plane"
211
+ if capabilities.configurator_class is not None:
212
+ from reflexio.server.services.configurator.configurator import (
213
+ set_configurator_class,
214
+ )
215
+
216
+ set_configurator_class(capabilities.configurator_class)
217
+ if capabilities.billing_gate is not None:
218
+ for line in ("application", "learnings_generated"):
219
+ app.dependency_overrides[default_billing_gate(line)] = (
220
+ capabilities.billing_gate(line)
221
+ )
222
+ # HookRegistry is a stateless facade: its methods configure process-global
223
+ # tracer/usage-recorder/retrieval-capture singletons; the object needn't be stored.
224
+ hooks = HookRegistry()
225
+ additional = additional_routers or []
226
+ for cap in capabilities.capabilities:
227
+ cap.install_services()
228
+ cap.install_hooks(hooks)
229
+ for r in cap.routers(role):
230
+ if any(r is a for a in additional):
231
+ raise ValueError(
232
+ f"router for capability {cap.name!r} is mounted both via the "
233
+ f"registry and additional_routers; mount it exactly once"
234
+ )
235
+ app.include_router(r)
236
+
237
+
238
+ def _resolve_app_profile(
239
+ profile: "DeploymentProfile | None",
240
+ *,
241
+ mount_data_plane: bool,
242
+ require_auth: bool,
243
+ ) -> "DeploymentProfile":
244
+ """Return the caller's profile, or derive one from the legacy knobs.
245
+
246
+ Args:
247
+ profile (DeploymentProfile | None): Explicit profile, or None to derive.
248
+ mount_data_plane (bool): Legacy knob — whether data-plane routers/lifespan run.
249
+ require_auth (bool): Legacy knob — whether auth is declared.
250
+
251
+ Returns:
252
+ DeploymentProfile: ``profile`` when provided, else the derived profile.
253
+ """
254
+ from reflexio.server.deployment_profile import resolve_profile
255
+
256
+ if profile is not None:
257
+ return profile
258
+ return resolve_profile(mount_data_plane=mount_data_plane, require_auth=require_auth)
259
+
260
+
3305
261
  def create_app(
3306
262
  get_org_id: Callable[..., str] | None = None,
3307
263
  additional_routers: list[APIRouter] | None = None,
@@ -3310,6 +266,9 @@ def create_app(
3310
266
  get_caller_type: Callable[..., str] | None = None,
3311
267
  get_billing_gate: Callable[[str], Callable[..., None]] | None = None,
3312
268
  mount_data_plane: bool = True,
269
+ capabilities: "CapabilityRegistry | None" = None,
270
+ app_context_factory: "Callable[[], AppContext] | None" = None,
271
+ profile: "DeploymentProfile | None" = None,
3313
272
  ) -> FastAPI:
3314
273
  """Factory to create a FastAPI app.
3315
274
 
@@ -3337,19 +296,54 @@ def create_app(
3337
296
  scheduler, while keeping all other scaffolding (middleware, CORS,
3338
297
  auth overrides, OpenAPI security, health, ``/meta/version``,
3339
298
  ``additional_routers``).
299
+ capabilities: Optional registry of enterprise capabilities. When provided,
300
+ each capability's routers, services, hooks, and lifecycle methods are
301
+ wired into the app at construction time. Behavior is unchanged when
302
+ ``None``.
303
+ app_context_factory: Optional callable returning the AppContext passed to
304
+ each capability's on_startup. When None, an empty AppContext() is used
305
+ (local OSS / tests). Enterprise binds this to supply self_host_org_id /
306
+ activated computed during its own startup.
307
+ profile: Optional declarative :class:`DeploymentProfile`. When None
308
+ (default), it is DERIVED from the ``mount_data_plane`` / ``require_auth``
309
+ knobs so behaviour is identical to before this parameter existed. When
310
+ provided it is the single source for router-group mounting, auth, and
311
+ data-plane lifespan gating (the legacy knobs still populate the derived
312
+ profile, so callers may pass either — they express the same thing).
3340
313
 
3341
314
  Returns:
3342
315
  Configured FastAPI application.
3343
- """
316
+
317
+ Raises:
318
+ ValueError: If both ``get_billing_gate`` and ``capabilities.billing_gate``
319
+ are provided — pass billing_gate through exactly one path.
320
+ """
321
+ if (
322
+ get_billing_gate is not None
323
+ and capabilities is not None
324
+ and capabilities.billing_gate is not None
325
+ ):
326
+ raise ValueError(
327
+ "pass billing_gate via either get_billing_gate or capabilities, not both"
328
+ )
329
+ # The profile is a cleaner representation of the two existing knobs. When the
330
+ # caller does not pass one, derive it from ``mount_data_plane`` / ``require_auth``
331
+ # so behaviour is byte-identical to before this parameter existed.
332
+ profile = _resolve_app_profile(
333
+ profile, mount_data_plane=mount_data_plane, require_auth=require_auth
334
+ )
335
+ mounts_data_plane = profile.mounts_data_plane
336
+ auth_required = profile.auth_required
3344
337
  from collections.abc import AsyncIterator
3345
338
  from contextlib import asynccontextmanager
3346
339
 
3347
- from reflexio.server._auth import (
340
+ from reflexio.server.api_endpoints.request_context import RequestContext
341
+ from reflexio.server.auth import (
3348
342
  default_billing_gate,
3349
343
  default_get_caller_type,
3350
344
  default_get_org_id,
3351
345
  )
3352
- from reflexio.server.api_endpoints.request_context import RequestContext
346
+ from reflexio.server.extensions import AppContext
3353
347
  from reflexio.server.llm.model_defaults import validate_llm_availability
3354
348
  from reflexio.server.services.extraction.resume_scheduler import (
3355
349
  maybe_start_resume_scheduler,
@@ -3358,26 +352,12 @@ def create_app(
3358
352
  maybe_start_lineage_gc,
3359
353
  )
3360
354
 
3361
- def _lifespan_org_id() -> str:
3362
- if get_org_id is None:
3363
- return default_get_org_id()
3364
- try:
3365
- signature = inspect.signature(get_org_id)
3366
- except (TypeError, ValueError):
3367
- return default_get_org_id()
3368
- if signature.parameters:
3369
- return default_get_org_id()
3370
- try:
3371
- return str(get_org_id())
3372
- except Exception:
3373
- logger.exception("Failed to resolve lifespan org_id; using default org")
3374
- return default_get_org_id()
3375
-
3376
355
  @asynccontextmanager
3377
356
  async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001
3378
357
  scheduler = None
3379
358
  gc_scheduler = None
3380
- if mount_data_plane:
359
+ started_caps: list = []
360
+ if mounts_data_plane:
3381
361
  log_publish_hardware_capacity()
3382
362
  validate_llm_availability()
3383
363
  from reflexio.server.llm.rerank import prewarm as _prewarm_cross_encoder
@@ -3387,25 +367,49 @@ def create_app(
3387
367
  # drives a per-org worker with org-scoped claims, so it is not limited
3388
368
  # to the bootstrap org. The bootstrap org is only used to read config
3389
369
  # and to seed cross-org discovery.
370
+ bootstrap_org_id = _resolve_lifespan_org_id(get_org_id)
3390
371
  scheduler = maybe_start_resume_scheduler(
3391
372
  lambda org_id: RequestContext(org_id=org_id),
3392
- bootstrap_org_id=_lifespan_org_id(),
373
+ bootstrap_org_id=bootstrap_org_id,
3393
374
  )
3394
375
  gc_scheduler = maybe_start_lineage_gc(
3395
376
  lambda org_id: RequestContext(org_id=org_id),
3396
- bootstrap_org_id=_lifespan_org_id(),
377
+ bootstrap_org_id=bootstrap_org_id,
3397
378
  )
3398
379
  try:
380
+ if capabilities is not None:
381
+ ctx = (
382
+ app_context_factory()
383
+ if app_context_factory is not None
384
+ else AppContext()
385
+ )
386
+ for cap in capabilities.capabilities:
387
+ await cap.on_startup(ctx)
388
+ started_caps.append(cap)
3399
389
  yield
3400
390
  finally:
391
+ for cap in reversed(started_caps):
392
+ try:
393
+ await cap.on_shutdown()
394
+ except Exception:
395
+ logger.warning(
396
+ "capability %r on_shutdown raised; continuing cleanup",
397
+ cap,
398
+ exc_info=True,
399
+ )
3401
400
  if scheduler is not None:
3402
401
  scheduler.stop()
3403
402
  if gc_scheduler is not None:
3404
403
  gc_scheduler.stop()
404
+ from reflexio.server.services.publish_learning_worker import (
405
+ stop_publish_learning_worker,
406
+ )
407
+
408
+ stop_publish_learning_worker(timeout=5.0)
3405
409
 
3406
410
  app = FastAPI(docs_url="/docs", lifespan=lifespan)
3407
411
 
3408
- if require_auth:
412
+ if auth_required:
3409
413
  _add_openapi_security(app)
3410
414
 
3411
415
  @app.get("/meta/version")
@@ -3431,7 +435,7 @@ def create_app(
3431
435
  # hosts that wire in auth (``require_auth=True``) restrict browser origins.
3432
436
  # OSS/local runs have no auth and bundle their own docs playground on a
3433
437
  # separate port, so they allow any origin (no credentials needed).
3434
- if require_auth:
438
+ if auth_required:
3435
439
  app.add_middleware(
3436
440
  CORSMiddleware,
3437
441
  allow_origins=_resolve_cors_origins(),
@@ -3488,12 +492,12 @@ def create_app(
3488
492
  # ``app.state`` instead of a module-level global keeps the gate
3489
493
  # scoped to this FastAPI instance, so multiple apps (e.g. tests,
3490
494
  # multi-tenant embeddings) can coexist without leaking state.
3491
- app.state.my_config_enabled = bool(get_org_id is not None and require_auth)
495
+ app.state.my_config_enabled = bool(get_org_id is not None and auth_required)
3492
496
 
3493
497
  # Include data-plane routes (core, stall-state, pending-tool-call). A
3494
498
  # control-plane host sets mount_data_plane=False to skip these while
3495
499
  # keeping every other piece of scaffolding below.
3496
- if mount_data_plane:
500
+ if mounts_data_plane:
3497
501
  # Include core routes
3498
502
  app.include_router(core_router)
3499
503
 
@@ -3507,6 +511,9 @@ def create_app(
3507
511
  for router in additional_routers or []:
3508
512
  app.include_router(router)
3509
513
 
514
+ # Wire capability routers, services, and hooks (composition-root only)
515
+ _wire_capabilities(app, capabilities, mounts_data_plane, additional_routers)
516
+
3510
517
  # Health/observability endpoint (per-worker metrics for recycling)
3511
518
  health_api.install(app)
3512
519