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