claude-smart 0.2.47 → 0.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- 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/pyproject.toml +1 -1
- package/plugin/uv.lock +1 -1
- 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/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/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 +133 -3273
- 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 +73 -1819
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
- 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 +259 -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/extraction/resume_scheduler.py +26 -41
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
- 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/publish_learning_worker.py +288 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -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 +56 -351
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
- 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 +132 -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 +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
- 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 +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -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 +33 -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/_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 +73 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
- 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
|
@@ -2,46 +2,33 @@
|
|
|
2
2
|
Base class for generation services
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
import contextvars
|
|
6
5
|
import logging
|
|
7
|
-
import os
|
|
8
6
|
import re
|
|
9
7
|
import time
|
|
10
8
|
import uuid
|
|
11
9
|
from abc import ABC, abstractmethod
|
|
12
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
-
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
|
14
10
|
from dataclasses import dataclass
|
|
15
|
-
from datetime import UTC, datetime, timedelta
|
|
16
|
-
from enum import StrEnum
|
|
17
11
|
from typing import Any, Generic, TypeVar
|
|
18
12
|
|
|
19
13
|
from reflexio.models.api_schema.internal_schema import RequestInteractionDataModel
|
|
20
|
-
from reflexio.models.api_schema.service_schemas import Status
|
|
21
14
|
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
22
15
|
from reflexio.server.llm.litellm_client import LiteLLMClient
|
|
23
16
|
from reflexio.server.llm.token_accounting import RunTokenTotals
|
|
24
|
-
from reflexio.server.services.
|
|
17
|
+
from reflexio.server.services.base_generation import (
|
|
18
|
+
BatchProgressMixin,
|
|
19
|
+
ConfigFilterMixin,
|
|
20
|
+
ExtractionRunLifecycleMixin,
|
|
21
|
+
ShouldRunPrecheckMixin,
|
|
22
|
+
StatusChangeMixin,
|
|
23
|
+
UsageBillingMixin,
|
|
24
|
+
)
|
|
25
|
+
from reflexio.server.services.base_generation import (
|
|
26
|
+
StatusChangeOperation as StatusChangeOperation, # re-export for back-compat
|
|
27
|
+
)
|
|
25
28
|
from reflexio.server.services.extractor_config_utils import (
|
|
26
|
-
filter_extractor_configs,
|
|
27
29
|
get_extractor_name,
|
|
28
30
|
)
|
|
29
|
-
from reflexio.server.services.extractor_interaction_utils import (
|
|
30
|
-
get_effective_source_filter,
|
|
31
|
-
get_extractor_window_params,
|
|
32
|
-
should_extractor_run_by_stride,
|
|
33
|
-
)
|
|
34
31
|
from reflexio.server.services.operation_state_utils import OperationStateManager
|
|
35
|
-
from reflexio.server.services.service_utils import log_llm_messages, log_model_response
|
|
36
|
-
from reflexio.server.services.storage.storage_base import AgentRunStatus
|
|
37
|
-
from reflexio.server.usage_metrics import record_usage_event
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class StatusChangeOperation(StrEnum):
|
|
41
|
-
"""Operation type for upgrade/downgrade responses."""
|
|
42
|
-
|
|
43
|
-
UPGRADE = "upgrade"
|
|
44
|
-
DOWNGRADE = "downgrade"
|
|
45
32
|
|
|
46
33
|
|
|
47
34
|
class ExtractorExecutionError(RuntimeError):
|
|
@@ -163,6 +150,12 @@ class PreparedGenerationRun(Generic[TExtractorConfig]): # noqa: UP046
|
|
|
163
150
|
|
|
164
151
|
# Unified base class for all generation services (evaluation, playbook, profile)
|
|
165
152
|
class BaseGenerationService(
|
|
153
|
+
BatchProgressMixin[TRequest],
|
|
154
|
+
UsageBillingMixin[TExtractorConfig, TGenerationServiceConfig],
|
|
155
|
+
ExtractionRunLifecycleMixin[TExtractorConfig, TGenerationServiceConfig],
|
|
156
|
+
ShouldRunPrecheckMixin[TExtractorConfig, TGenerationServiceConfig],
|
|
157
|
+
ConfigFilterMixin[TExtractorConfig, TGenerationServiceConfig],
|
|
158
|
+
StatusChangeMixin[TRequest],
|
|
166
159
|
ABC,
|
|
167
160
|
Generic[TExtractorConfig, TExtractor, TGenerationServiceConfig, TRequest], # noqa: UP046
|
|
168
161
|
):
|
|
@@ -220,187 +213,6 @@ class BaseGenerationService(
|
|
|
220
213
|
# re-querying storage. None when the gate did not run (bypass paths).
|
|
221
214
|
self._last_precheck_sessions: list[Any] | None = None
|
|
222
215
|
|
|
223
|
-
def _usage_pipeline(self) -> str | None:
|
|
224
|
-
service_name = self._get_service_name()
|
|
225
|
-
if "profile" in service_name:
|
|
226
|
-
return "profile"
|
|
227
|
-
if "playbook" in service_name:
|
|
228
|
-
return "playbook"
|
|
229
|
-
if "evaluation" in service_name:
|
|
230
|
-
return "evaluation"
|
|
231
|
-
return None
|
|
232
|
-
|
|
233
|
-
def _usage_context(self) -> dict[str, Any]:
|
|
234
|
-
service_config = self.service_config
|
|
235
|
-
return {
|
|
236
|
-
"org_id": self.org_id,
|
|
237
|
-
"user_id": getattr(service_config, "user_id", None),
|
|
238
|
-
"request_id": getattr(service_config, "request_id", None),
|
|
239
|
-
"source": getattr(service_config, "source", None),
|
|
240
|
-
"agent_version": getattr(service_config, "agent_version", None),
|
|
241
|
-
"pipeline": self._usage_pipeline(),
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
def _record_generation_event(
|
|
245
|
-
self,
|
|
246
|
-
*,
|
|
247
|
-
event_name: str,
|
|
248
|
-
outcome: str,
|
|
249
|
-
count_value: int = 1,
|
|
250
|
-
duration_ms: int | None = None,
|
|
251
|
-
error_kind: str | None = None,
|
|
252
|
-
metadata: dict[str, Any] | None = None,
|
|
253
|
-
) -> None:
|
|
254
|
-
record_usage_event(
|
|
255
|
-
**self._usage_context(),
|
|
256
|
-
event_name=event_name,
|
|
257
|
-
event_category="generation",
|
|
258
|
-
outcome=outcome,
|
|
259
|
-
count_value=count_value,
|
|
260
|
-
duration_ms=duration_ms,
|
|
261
|
-
error_kind=error_kind,
|
|
262
|
-
metadata=metadata,
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
def _extraction_input_text(self, prepared: "PreparedGenerationRun[Any]") -> str:
|
|
266
|
-
"""Return the interaction content fed to the extractor as a single string.
|
|
267
|
-
|
|
268
|
-
Replays the same storage query the extractor uses (same user_id, source,
|
|
269
|
-
window params) so ``count_input_tokens`` sees the same text the LLM saw.
|
|
270
|
-
Returns an empty string when storage is unavailable or no interactions exist.
|
|
271
|
-
|
|
272
|
-
Args:
|
|
273
|
-
prepared: The prepared generation run, used to access extractor_config
|
|
274
|
-
for window/source parameters.
|
|
275
|
-
|
|
276
|
-
Returns:
|
|
277
|
-
str: Formatted interaction history string, or "" if nothing is found.
|
|
278
|
-
"""
|
|
279
|
-
from reflexio.server.services.service_utils import (
|
|
280
|
-
format_sessions_to_history_string,
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
if self.storage is None or self.service_config is None:
|
|
284
|
-
return ""
|
|
285
|
-
# Reuse the window the should-run gate already fetched
|
|
286
|
-
# (_collect_scoped_interactions_for_precheck stashed it). Same query params,
|
|
287
|
-
# so the token count is byte-identical — and we avoid a second storage read.
|
|
288
|
-
if self._last_precheck_sessions is not None:
|
|
289
|
-
return format_sessions_to_history_string(self._last_precheck_sessions)
|
|
290
|
-
# Fallback storage read: used ONLY when the gate did not pre-fetch
|
|
291
|
-
# (bypass paths: auto_run=False, force_extraction, skip_should_run_check,
|
|
292
|
-
# mock mode). Re-fetches the extractor's input window so billing token
|
|
293
|
-
# counting sees exactly the text the LLM saw.
|
|
294
|
-
try:
|
|
295
|
-
root_config = self.request_context.configurator.get_config()
|
|
296
|
-
global_window_size = (
|
|
297
|
-
getattr(root_config, "window_size", None) if root_config else None
|
|
298
|
-
)
|
|
299
|
-
global_stride_size = (
|
|
300
|
-
getattr(root_config, "stride_size", None) if root_config else None
|
|
301
|
-
)
|
|
302
|
-
window_size, _ = get_extractor_window_params(
|
|
303
|
-
prepared.extractor_config, global_window_size, global_stride_size
|
|
304
|
-
)
|
|
305
|
-
should_skip, effective_source = get_effective_source_filter(
|
|
306
|
-
prepared.extractor_config, getattr(self.service_config, "source", None)
|
|
307
|
-
)
|
|
308
|
-
if should_skip:
|
|
309
|
-
return ""
|
|
310
|
-
session_data_models, _ = self.storage.get_last_k_interactions_grouped(
|
|
311
|
-
user_id=getattr(self.service_config, "user_id", None),
|
|
312
|
-
k=window_size,
|
|
313
|
-
sources=effective_source,
|
|
314
|
-
start_time=getattr(self.service_config, "rerun_start_time", None),
|
|
315
|
-
end_time=getattr(self.service_config, "rerun_end_time", None),
|
|
316
|
-
**self._get_precheck_interaction_query_kwargs(),
|
|
317
|
-
)
|
|
318
|
-
return format_sessions_to_history_string(session_data_models)
|
|
319
|
-
except Exception:
|
|
320
|
-
logger.warning(
|
|
321
|
-
"_extraction_input_text failed; billing_input_tokens will be 0",
|
|
322
|
-
exc_info=True,
|
|
323
|
-
)
|
|
324
|
-
return ""
|
|
325
|
-
|
|
326
|
-
def _record_billing_learning_events(
|
|
327
|
-
self, *, prepared: "PreparedGenerationRun[Any]", generated_count: int
|
|
328
|
-
) -> None:
|
|
329
|
-
"""Emit the ② Learning billing events. Called ONLY when extraction fired.
|
|
330
|
-
|
|
331
|
-
Emits ``learnings_generated`` (value facet) and ``extraction_tokens``
|
|
332
|
-
(cost facet) via the OSS emission helpers. ``platform_storage`` is left
|
|
333
|
-
``None`` here and resolved enterprise-side at rollup (Phase 1).
|
|
334
|
-
|
|
335
|
-
Gated by ``EMITS_LEARNING_BILLING`` — only profile/playbook generation
|
|
336
|
-
services opt in. Evaluation, reflection, consolidation and aggregation
|
|
337
|
-
are bundled and must NOT separately meter the ② Learning line.
|
|
338
|
-
|
|
339
|
-
Args:
|
|
340
|
-
prepared: The prepared generation run (used for input-text computation).
|
|
341
|
-
generated_count: Number of learnings produced by this extraction run.
|
|
342
|
-
"""
|
|
343
|
-
if not self.EMITS_LEARNING_BILLING:
|
|
344
|
-
return
|
|
345
|
-
|
|
346
|
-
try:
|
|
347
|
-
from reflexio.server.billing_meter import (
|
|
348
|
-
record_extraction_tokens,
|
|
349
|
-
record_learnings_generated,
|
|
350
|
-
)
|
|
351
|
-
from reflexio.server.billing_signals import (
|
|
352
|
-
count_input_tokens,
|
|
353
|
-
platform_llm_from_config,
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
config = self.request_context.configurator.get_config()
|
|
357
|
-
platform_llm = platform_llm_from_config(config)
|
|
358
|
-
ctx = self._usage_context()
|
|
359
|
-
|
|
360
|
-
# session_id is intentionally not passed: the generation path has no
|
|
361
|
-
# session_id source. _usage_context() never includes it, and neither
|
|
362
|
-
# the Profile/Playbook service configs nor their requests carry a
|
|
363
|
-
# session_id (unlike the Application-line path in server/api.py, which
|
|
364
|
-
# reads request_id/session_id off the search request payload). Learning
|
|
365
|
-
# events therefore meter without session attribution by design.
|
|
366
|
-
|
|
367
|
-
# ② Learning — value: learnings generated (helper no-ops on count <= 0).
|
|
368
|
-
record_learnings_generated(
|
|
369
|
-
org_id=ctx["org_id"],
|
|
370
|
-
count=generated_count,
|
|
371
|
-
platform_llm=platform_llm,
|
|
372
|
-
platform_storage=None,
|
|
373
|
-
pipeline=ctx.get("pipeline"),
|
|
374
|
-
request_id=ctx.get("request_id"),
|
|
375
|
-
)
|
|
376
|
-
|
|
377
|
-
# ② Learning — cost: input-anchored extraction tokens + real provider tokens.
|
|
378
|
-
totals = self._last_token_totals or RunTokenTotals()
|
|
379
|
-
billing_input_tokens = count_input_tokens(
|
|
380
|
-
self._extraction_input_text(prepared)
|
|
381
|
-
)
|
|
382
|
-
record_extraction_tokens(
|
|
383
|
-
org_id=ctx["org_id"],
|
|
384
|
-
billing_input_tokens=billing_input_tokens,
|
|
385
|
-
prompt_tokens=totals.prompt_tokens,
|
|
386
|
-
completion_tokens=totals.completion_tokens,
|
|
387
|
-
platform_llm=platform_llm,
|
|
388
|
-
platform_storage=None,
|
|
389
|
-
pipeline=ctx.get("pipeline"),
|
|
390
|
-
request_id=ctx.get("request_id"),
|
|
391
|
-
)
|
|
392
|
-
except Exception:
|
|
393
|
-
logger.warning(
|
|
394
|
-
"_record_billing_learning_events failed; billing learning events not emitted",
|
|
395
|
-
exc_info=True,
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
@staticmethod
|
|
399
|
-
def _count_generated_results(result: Any) -> int:
|
|
400
|
-
if isinstance(result, list):
|
|
401
|
-
return len(result)
|
|
402
|
-
return 1 if result else 0
|
|
403
|
-
|
|
404
216
|
@abstractmethod
|
|
405
217
|
def _load_extractor_config(self) -> TExtractorConfig | None:
|
|
406
218
|
"""
|
|
@@ -506,107 +318,6 @@ class BaseGenerationService(
|
|
|
506
318
|
Optional[str]: Scope ID (e.g., user_id) or None for org-level scope
|
|
507
319
|
"""
|
|
508
320
|
|
|
509
|
-
def _filter_extractor_config_by_service_config(
|
|
510
|
-
self,
|
|
511
|
-
extractor_config: TExtractorConfig,
|
|
512
|
-
service_config: TGenerationServiceConfig,
|
|
513
|
-
) -> TExtractorConfig | None:
|
|
514
|
-
"""
|
|
515
|
-
Filter the extractor config based on request_sources_enabled and manual_trigger.
|
|
516
|
-
"""
|
|
517
|
-
filtered = filter_extractor_configs(
|
|
518
|
-
extractor_configs=[extractor_config],
|
|
519
|
-
source=getattr(service_config, "source", None),
|
|
520
|
-
allow_manual_trigger=getattr(service_config, "allow_manual_trigger", False),
|
|
521
|
-
)
|
|
522
|
-
return filtered[0] if filtered else None
|
|
523
|
-
|
|
524
|
-
def _get_extractor_state_service_name(self) -> str | None:
|
|
525
|
-
"""
|
|
526
|
-
Get the service name used for extractor state (stride_size bookmark) lookups.
|
|
527
|
-
|
|
528
|
-
Override in subclasses that support stride_size-based pre-filtering to return
|
|
529
|
-
the OperationStateManager service name (e.g., "profile_extractor", "playbook_extractor").
|
|
530
|
-
Returns None by default, meaning stride_size pre-filtering is skipped.
|
|
531
|
-
|
|
532
|
-
Returns:
|
|
533
|
-
Optional[str]: Service name for OperationStateManager, or None to skip stride_size pre-filtering
|
|
534
|
-
"""
|
|
535
|
-
return None
|
|
536
|
-
|
|
537
|
-
def _filter_config_by_stride(
|
|
538
|
-
self, extractor_config: TExtractorConfig
|
|
539
|
-
) -> TExtractorConfig | None:
|
|
540
|
-
"""
|
|
541
|
-
Filter extractor config by stride_size check before the should_run LLM call.
|
|
542
|
-
|
|
543
|
-
Skips filtering when:
|
|
544
|
-
- _get_extractor_state_service_name() returns None (service doesn't support stride_size)
|
|
545
|
-
- auto_run is False (rerun/manual flows skip stride_size)
|
|
546
|
-
|
|
547
|
-
Args:
|
|
548
|
-
extractor_config: Extractor config after source/manual_trigger filtering
|
|
549
|
-
|
|
550
|
-
Returns:
|
|
551
|
-
Extractor config when it passes the stride_size check, otherwise None.
|
|
552
|
-
"""
|
|
553
|
-
state_service_name = self._get_extractor_state_service_name()
|
|
554
|
-
if state_service_name is None:
|
|
555
|
-
return extractor_config
|
|
556
|
-
|
|
557
|
-
if not getattr(self.service_config, "auto_run", True):
|
|
558
|
-
return extractor_config
|
|
559
|
-
|
|
560
|
-
if getattr(self.service_config, "force_extraction", False):
|
|
561
|
-
return extractor_config
|
|
562
|
-
|
|
563
|
-
root_config = self.request_context.configurator.get_config()
|
|
564
|
-
global_window_size = (
|
|
565
|
-
getattr(root_config, "window_size", None) if root_config else None
|
|
566
|
-
)
|
|
567
|
-
global_stride_size = (
|
|
568
|
-
getattr(root_config, "stride_size", None) if root_config else None
|
|
569
|
-
)
|
|
570
|
-
|
|
571
|
-
state_manager = OperationStateManager(
|
|
572
|
-
self.storage, # type: ignore[reportArgumentType]
|
|
573
|
-
self.org_id,
|
|
574
|
-
state_service_name, # type: ignore[reportArgumentType]
|
|
575
|
-
)
|
|
576
|
-
|
|
577
|
-
name = get_extractor_name(extractor_config)
|
|
578
|
-
_, stride_size = get_extractor_window_params(
|
|
579
|
-
extractor_config, global_window_size, global_stride_size
|
|
580
|
-
)
|
|
581
|
-
|
|
582
|
-
# Resolve effective source filter for this extractor
|
|
583
|
-
should_skip, effective_source = get_effective_source_filter(
|
|
584
|
-
extractor_config, getattr(self.service_config, "source", None)
|
|
585
|
-
)
|
|
586
|
-
if should_skip:
|
|
587
|
-
return None
|
|
588
|
-
|
|
589
|
-
(
|
|
590
|
-
_,
|
|
591
|
-
new_interactions,
|
|
592
|
-
) = state_manager.get_extractor_state_with_new_interactions(
|
|
593
|
-
extractor_name=name,
|
|
594
|
-
user_id=getattr(self.service_config, "user_id", None),
|
|
595
|
-
sources=effective_source,
|
|
596
|
-
)
|
|
597
|
-
new_count = sum(len(ri.interactions) for ri in new_interactions)
|
|
598
|
-
|
|
599
|
-
if should_extractor_run_by_stride(new_count, stride_size):
|
|
600
|
-
return extractor_config
|
|
601
|
-
|
|
602
|
-
logger.info(
|
|
603
|
-
"Stride pre-filter: skipping extractor '%s' (new=%d, stride_size=%s)",
|
|
604
|
-
name,
|
|
605
|
-
new_count,
|
|
606
|
-
stride_size,
|
|
607
|
-
)
|
|
608
|
-
return None
|
|
609
|
-
|
|
610
321
|
# ===============================
|
|
611
322
|
# In-progress state management via OperationStateManager
|
|
612
323
|
# ===============================
|
|
@@ -949,880 +660,3 @@ class BaseGenerationService(
|
|
|
949
660
|
extractor_name=extractor_name,
|
|
950
661
|
identifier=identifier,
|
|
951
662
|
)
|
|
952
|
-
|
|
953
|
-
def _execute_extractor(
|
|
954
|
-
self,
|
|
955
|
-
extractor_config: TExtractorConfig,
|
|
956
|
-
identifier: str,
|
|
957
|
-
) -> Any | None:
|
|
958
|
-
"""
|
|
959
|
-
Run the configured extractor with timeout and error handling.
|
|
960
|
-
|
|
961
|
-
The extractor runs in a thread pool with a timeout guard so providers that
|
|
962
|
-
ignore their own timeout cannot block generation forever.
|
|
963
|
-
|
|
964
|
-
Args:
|
|
965
|
-
extractor_config: Filtered extractor config to execute
|
|
966
|
-
identifier: Logging context identifier (user_id or request_id)
|
|
967
|
-
|
|
968
|
-
Returns:
|
|
969
|
-
Extractor result, or None if the extractor succeeded with no output.
|
|
970
|
-
|
|
971
|
-
Raises:
|
|
972
|
-
ExtractorExecutionError: If the extractor fails with an exception or timeout.
|
|
973
|
-
"""
|
|
974
|
-
if (
|
|
975
|
-
self.service_config is None
|
|
976
|
-
): # pragma: no cover — set by _prepare_generation_run
|
|
977
|
-
raise RuntimeError("service_config must be set before executing extractor")
|
|
978
|
-
|
|
979
|
-
self._last_extractor_run_stats = {"total": 1, "failed": 0, "timed_out": 0}
|
|
980
|
-
extractor = self._create_extractor(extractor_config, self.service_config)
|
|
981
|
-
executor: ThreadPoolExecutor | None = None
|
|
982
|
-
try:
|
|
983
|
-
executor = ThreadPoolExecutor(max_workers=1)
|
|
984
|
-
# Copy context so correlation ID propagates to worker thread
|
|
985
|
-
ctx = contextvars.copy_context()
|
|
986
|
-
future = executor.submit(ctx.run, extractor.run) # type: ignore[reportAttributeAccessIssue]
|
|
987
|
-
result = future.result(timeout=EXTRACTOR_TIMEOUT_SECONDS)
|
|
988
|
-
if isinstance(result, ExtractionOutcome):
|
|
989
|
-
if result.run_id:
|
|
990
|
-
self._last_extraction_run_ids.append(result.run_id)
|
|
991
|
-
self._last_token_totals = result.token_totals
|
|
992
|
-
if result.status == "completed" and result.items:
|
|
993
|
-
return result.items
|
|
994
|
-
logger.info(
|
|
995
|
-
"No results generated for %s identifier: %s",
|
|
996
|
-
self._get_service_name(),
|
|
997
|
-
identifier,
|
|
998
|
-
)
|
|
999
|
-
return None
|
|
1000
|
-
if result:
|
|
1001
|
-
return result
|
|
1002
|
-
logger.info(
|
|
1003
|
-
"No results generated for %s identifier: %s",
|
|
1004
|
-
self._get_service_name(),
|
|
1005
|
-
identifier,
|
|
1006
|
-
)
|
|
1007
|
-
return None
|
|
1008
|
-
except FuturesTimeoutError as exc:
|
|
1009
|
-
self._last_extractor_run_stats = {"total": 1, "failed": 1, "timed_out": 1}
|
|
1010
|
-
error_msg = (
|
|
1011
|
-
f"Extractor timed out after {EXTRACTOR_TIMEOUT_SECONDS} seconds "
|
|
1012
|
-
f"for {self._get_service_name()} identifier={identifier}"
|
|
1013
|
-
)
|
|
1014
|
-
logger.error(error_msg)
|
|
1015
|
-
self._fail_active_extraction_runs(
|
|
1016
|
-
extractor_kind=get_extractor_name(extractor_config),
|
|
1017
|
-
last_error=error_msg,
|
|
1018
|
-
)
|
|
1019
|
-
raise ExtractorExecutionError(error_msg) from exc
|
|
1020
|
-
except Exception as exc:
|
|
1021
|
-
self._last_extractor_run_stats = {"total": 1, "failed": 1, "timed_out": 0}
|
|
1022
|
-
error_msg = (
|
|
1023
|
-
f"Extractor failed for {self._get_service_name()} "
|
|
1024
|
-
f"identifier={identifier}: {exc} (type={type(exc).__name__})"
|
|
1025
|
-
)
|
|
1026
|
-
logger.error(error_msg)
|
|
1027
|
-
raise ExtractorExecutionError(error_msg) from exc
|
|
1028
|
-
finally:
|
|
1029
|
-
if executor is not None:
|
|
1030
|
-
executor.shutdown(wait=False, cancel_futures=True)
|
|
1031
|
-
|
|
1032
|
-
def _fail_active_extraction_runs(
|
|
1033
|
-
self,
|
|
1034
|
-
*,
|
|
1035
|
-
extractor_kind: str,
|
|
1036
|
-
last_error: str,
|
|
1037
|
-
) -> None:
|
|
1038
|
-
"""Mark active agent-run rows failed when the service timeout fires."""
|
|
1039
|
-
if self.storage is None or self.service_config is None:
|
|
1040
|
-
return
|
|
1041
|
-
request_id = getattr(self.service_config, "request_id", None)
|
|
1042
|
-
if not request_id:
|
|
1043
|
-
return
|
|
1044
|
-
user_id = getattr(self.service_config, "user_id", None)
|
|
1045
|
-
try:
|
|
1046
|
-
failed_count = self.storage.fail_running_agent_runs_for_request(
|
|
1047
|
-
org_id=self.request_context.org_id,
|
|
1048
|
-
extractor_kind=extractor_kind,
|
|
1049
|
-
user_id=user_id,
|
|
1050
|
-
request_id=request_id,
|
|
1051
|
-
last_error=last_error,
|
|
1052
|
-
)
|
|
1053
|
-
except NotImplementedError:
|
|
1054
|
-
return
|
|
1055
|
-
except Exception as exc: # noqa: BLE001 - keep timeout error primary
|
|
1056
|
-
logger.warning(
|
|
1057
|
-
"Failed to mark timed-out %s agent runs failed: %s",
|
|
1058
|
-
extractor_kind,
|
|
1059
|
-
exc,
|
|
1060
|
-
)
|
|
1061
|
-
return
|
|
1062
|
-
if failed_count:
|
|
1063
|
-
logger.warning(
|
|
1064
|
-
"Marked %d timed-out %s agent run(s) failed for request_id=%s",
|
|
1065
|
-
failed_count,
|
|
1066
|
-
extractor_kind,
|
|
1067
|
-
request_id,
|
|
1068
|
-
)
|
|
1069
|
-
|
|
1070
|
-
def _finalize_extraction_runs(self) -> None:
|
|
1071
|
-
if self.storage is None:
|
|
1072
|
-
return
|
|
1073
|
-
for run_id in self._last_extraction_run_ids:
|
|
1074
|
-
run = self.storage.get_agent_run(run_id)
|
|
1075
|
-
if run is None:
|
|
1076
|
-
continue
|
|
1077
|
-
status = (
|
|
1078
|
-
AgentRunStatus.FINALIZED_PENDING_TOOL
|
|
1079
|
-
if run.pending_tool_call_ids
|
|
1080
|
-
else AgentRunStatus.FINALIZED
|
|
1081
|
-
)
|
|
1082
|
-
self.storage.update_agent_run_status(
|
|
1083
|
-
run_id,
|
|
1084
|
-
status,
|
|
1085
|
-
pending_tool_call_ids=run.pending_tool_call_ids,
|
|
1086
|
-
)
|
|
1087
|
-
|
|
1088
|
-
def _mark_extraction_runs_finalization_failed(self, exc: Exception) -> None:
|
|
1089
|
-
if self.storage is None:
|
|
1090
|
-
return
|
|
1091
|
-
root_config = self.request_context.configurator.get_config()
|
|
1092
|
-
pending_config = getattr(root_config, "pending_tool_call_config", None)
|
|
1093
|
-
for run_id in self._last_extraction_run_ids:
|
|
1094
|
-
run = self.storage.get_agent_run(run_id)
|
|
1095
|
-
if run is None or run.committed_output is None:
|
|
1096
|
-
continue
|
|
1097
|
-
next_attempt_count = run.finalization_attempts + 1
|
|
1098
|
-
max_attempts = (
|
|
1099
|
-
pending_config.max_finalization_attempts
|
|
1100
|
-
if pending_config is not None
|
|
1101
|
-
else 3
|
|
1102
|
-
)
|
|
1103
|
-
status = (
|
|
1104
|
-
AgentRunStatus.FAILED
|
|
1105
|
-
if next_attempt_count >= max_attempts
|
|
1106
|
-
else AgentRunStatus.FINALIZATION_FAILED
|
|
1107
|
-
)
|
|
1108
|
-
delay_seconds = min(300, max(1, 2 ** max(0, next_attempt_count - 1)))
|
|
1109
|
-
self.storage.update_agent_run_status(
|
|
1110
|
-
run_id,
|
|
1111
|
-
status,
|
|
1112
|
-
next_resume_at=datetime.now(UTC) + timedelta(seconds=delay_seconds),
|
|
1113
|
-
last_error=str(exc),
|
|
1114
|
-
increment_finalization_attempts=True,
|
|
1115
|
-
)
|
|
1116
|
-
|
|
1117
|
-
def _should_run_before_extraction(self, extractor_config: TExtractorConfig) -> bool:
|
|
1118
|
-
"""
|
|
1119
|
-
Pre-extraction check called before extractor execution.
|
|
1120
|
-
|
|
1121
|
-
Template method that:
|
|
1122
|
-
1. Skips for non-auto runs and mock mode
|
|
1123
|
-
2. Returns True immediately when service_config.force_extraction=True
|
|
1124
|
-
(bypasses cheap pre-filter and LLM should_run vote)
|
|
1125
|
-
3. Collects scoped interactions via _collect_scoped_interactions_for_precheck
|
|
1126
|
-
4. Delegates prompt building to _build_should_run_prompt (subclass hook)
|
|
1127
|
-
5. Makes a single LLM call to determine if extraction should proceed
|
|
1128
|
-
|
|
1129
|
-
Override _build_should_run_prompt in subclasses to provide service-specific
|
|
1130
|
-
criteria and prompt construction. Default returns True (always run) when
|
|
1131
|
-
no prompt hook is provided.
|
|
1132
|
-
|
|
1133
|
-
Args:
|
|
1134
|
-
extractor_config: Enabled extractor config that will be run
|
|
1135
|
-
|
|
1136
|
-
Returns:
|
|
1137
|
-
bool: True if extraction should proceed, False to skip
|
|
1138
|
-
"""
|
|
1139
|
-
# Skip for non-auto runs (rerun/manual flows always run)
|
|
1140
|
-
if not getattr(self.service_config, "auto_run", True):
|
|
1141
|
-
return True
|
|
1142
|
-
|
|
1143
|
-
# Skip for mock mode
|
|
1144
|
-
if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
|
|
1145
|
-
return True
|
|
1146
|
-
|
|
1147
|
-
# `force_extraction=True` is the caller's explicit "no gates" signal —
|
|
1148
|
-
# corrections, manual /learn, anything time-sensitive. Bypass the
|
|
1149
|
-
# cheap pre-filter (slash-only / too-short rejects) and the LLM
|
|
1150
|
-
# should_run vote so the extractor always runs on this batch.
|
|
1151
|
-
if getattr(self.service_config, "force_extraction", False):
|
|
1152
|
-
return True
|
|
1153
|
-
|
|
1154
|
-
# Skip if org config disables the pre-extraction check
|
|
1155
|
-
root_config = self.request_context.configurator.get_config()
|
|
1156
|
-
if root_config and root_config.skip_should_run_check:
|
|
1157
|
-
logger.info(
|
|
1158
|
-
"skip_should_run_check is enabled for %s, bypassing pre-extraction check",
|
|
1159
|
-
self._get_service_name(),
|
|
1160
|
-
)
|
|
1161
|
-
return True
|
|
1162
|
-
|
|
1163
|
-
# Collect scoped interactions
|
|
1164
|
-
session_data_models, scoped_config = (
|
|
1165
|
-
self._collect_scoped_interactions_for_precheck(extractor_config)
|
|
1166
|
-
)
|
|
1167
|
-
if not session_data_models:
|
|
1168
|
-
logger.info(
|
|
1169
|
-
"No interactions found for consolidated should_generate check for %s",
|
|
1170
|
-
self._get_service_name(),
|
|
1171
|
-
)
|
|
1172
|
-
return False
|
|
1173
|
-
|
|
1174
|
-
# Cheap pre-filter: reject batches that are structurally unable
|
|
1175
|
-
# to yield signal (slash-commands only, too-short user turns,
|
|
1176
|
-
# extractor-prompt echoes) without burning a 5–7s LLM call. See
|
|
1177
|
-
# _cheap_should_run_reject for the rule set.
|
|
1178
|
-
reject_reason = _cheap_should_run_reject(session_data_models)
|
|
1179
|
-
if reject_reason is not None:
|
|
1180
|
-
logger.info(
|
|
1181
|
-
"Cheap pre-filter rejected %s should_run: reason=%s identifier=%s",
|
|
1182
|
-
self._get_service_name(),
|
|
1183
|
-
reject_reason,
|
|
1184
|
-
getattr(self.service_config, "user_id", None) or "unknown",
|
|
1185
|
-
)
|
|
1186
|
-
return False
|
|
1187
|
-
|
|
1188
|
-
# Build prompt via subclass hook
|
|
1189
|
-
prompt = self._build_should_run_prompt(scoped_config, session_data_models)
|
|
1190
|
-
if not prompt:
|
|
1191
|
-
return True # No prompt means no check needed, proceed
|
|
1192
|
-
|
|
1193
|
-
# Resolve model and make LLM call
|
|
1194
|
-
should_run_model = self._resolve_should_run_model()
|
|
1195
|
-
identifier = getattr(self.service_config, "user_id", None) or "unknown"
|
|
1196
|
-
try:
|
|
1197
|
-
should_start = time.perf_counter()
|
|
1198
|
-
logger.info(
|
|
1199
|
-
"event=consolidated_should_run_start service=%s identifier=%s model=%s extractor=%s",
|
|
1200
|
-
self._get_service_name(),
|
|
1201
|
-
identifier,
|
|
1202
|
-
should_run_model,
|
|
1203
|
-
get_extractor_name(extractor_config),
|
|
1204
|
-
)
|
|
1205
|
-
log_llm_messages(
|
|
1206
|
-
logger,
|
|
1207
|
-
"Should extract check",
|
|
1208
|
-
[{"role": "user", "content": prompt}],
|
|
1209
|
-
)
|
|
1210
|
-
|
|
1211
|
-
content = self.client.generate_chat_response(
|
|
1212
|
-
messages=[{"role": "user", "content": prompt}],
|
|
1213
|
-
model=should_run_model,
|
|
1214
|
-
)
|
|
1215
|
-
log_model_response(
|
|
1216
|
-
logger,
|
|
1217
|
-
f"Consolidated {self._get_service_name()} should_run response",
|
|
1218
|
-
content,
|
|
1219
|
-
)
|
|
1220
|
-
decision = bool(content and "true" in content.lower()) # type: ignore[reportAttributeAccessIssue]
|
|
1221
|
-
logger.info(
|
|
1222
|
-
"event=consolidated_should_run_end service=%s identifier=%s elapsed_seconds=%.3f decision=%s",
|
|
1223
|
-
self._get_service_name(),
|
|
1224
|
-
identifier,
|
|
1225
|
-
time.perf_counter() - should_start,
|
|
1226
|
-
decision,
|
|
1227
|
-
)
|
|
1228
|
-
return decision
|
|
1229
|
-
except Exception as exc:
|
|
1230
|
-
logger.error(
|
|
1231
|
-
"Consolidated should_generate check failed for %s: %s, defaulting to run",
|
|
1232
|
-
self._get_service_name(),
|
|
1233
|
-
str(exc),
|
|
1234
|
-
)
|
|
1235
|
-
return True
|
|
1236
|
-
|
|
1237
|
-
def _build_should_run_prompt(
|
|
1238
|
-
self,
|
|
1239
|
-
scoped_config: TExtractorConfig, # noqa: ARG002
|
|
1240
|
-
session_data_models: list[RequestInteractionDataModel], # noqa: ARG002
|
|
1241
|
-
) -> str | None:
|
|
1242
|
-
"""
|
|
1243
|
-
Build the prompt for the consolidated should_run LLM check.
|
|
1244
|
-
|
|
1245
|
-
Override in subclasses to provide service-specific criteria building
|
|
1246
|
-
and prompt rendering. Return None if no check is needed (always proceed).
|
|
1247
|
-
|
|
1248
|
-
Args:
|
|
1249
|
-
scoped_config: Extractor config that had scoped interactions
|
|
1250
|
-
session_data_models: Deduplicated request interaction data models
|
|
1251
|
-
|
|
1252
|
-
Returns:
|
|
1253
|
-
Optional[str]: The rendered prompt string, or None to skip the check
|
|
1254
|
-
"""
|
|
1255
|
-
return None
|
|
1256
|
-
|
|
1257
|
-
def _collect_scoped_interactions_for_precheck(
|
|
1258
|
-
self, extractor_config: TExtractorConfig
|
|
1259
|
-
) -> tuple[list[RequestInteractionDataModel], TExtractorConfig]:
|
|
1260
|
-
"""
|
|
1261
|
-
Collect interactions for consolidated pre-check using extractor-scoped filters.
|
|
1262
|
-
|
|
1263
|
-
Mirrors each extractor's source/window scope so the consolidated gate
|
|
1264
|
-
does not skip valid extraction because of an unrelated fixed interaction slice.
|
|
1265
|
-
|
|
1266
|
-
Args:
|
|
1267
|
-
extractor_config: Enabled extractor config after request-level filtering
|
|
1268
|
-
|
|
1269
|
-
Returns:
|
|
1270
|
-
tuple: (session data models, extractor config)
|
|
1271
|
-
"""
|
|
1272
|
-
root_config = self.request_context.configurator.get_config()
|
|
1273
|
-
global_window_size = (
|
|
1274
|
-
getattr(root_config, "window_size", None) if root_config else None
|
|
1275
|
-
)
|
|
1276
|
-
global_stride_size = (
|
|
1277
|
-
getattr(root_config, "stride_size", None) if root_config else None
|
|
1278
|
-
)
|
|
1279
|
-
|
|
1280
|
-
extra_kwargs = self._get_precheck_interaction_query_kwargs()
|
|
1281
|
-
|
|
1282
|
-
should_skip, effective_source = get_effective_source_filter(
|
|
1283
|
-
extractor_config, getattr(self.service_config, "source", None)
|
|
1284
|
-
)
|
|
1285
|
-
if should_skip:
|
|
1286
|
-
# Stash for the billing path to reuse (same window the gate saw).
|
|
1287
|
-
self._last_precheck_sessions = []
|
|
1288
|
-
return [], extractor_config
|
|
1289
|
-
|
|
1290
|
-
window_size, _ = get_extractor_window_params(
|
|
1291
|
-
extractor_config, global_window_size, global_stride_size
|
|
1292
|
-
)
|
|
1293
|
-
session_data_models, _ = self.storage.get_last_k_interactions_grouped( # type: ignore[reportOptionalMemberAccess]
|
|
1294
|
-
user_id=getattr(self.service_config, "user_id", None),
|
|
1295
|
-
k=window_size,
|
|
1296
|
-
sources=effective_source,
|
|
1297
|
-
start_time=getattr(self.service_config, "rerun_start_time", None),
|
|
1298
|
-
end_time=getattr(self.service_config, "rerun_end_time", None),
|
|
1299
|
-
**extra_kwargs,
|
|
1300
|
-
)
|
|
1301
|
-
|
|
1302
|
-
# Stash for the billing path (_extraction_input_text) to reuse instead of
|
|
1303
|
-
# re-querying storage for billing_input_tokens.
|
|
1304
|
-
self._last_precheck_sessions = session_data_models
|
|
1305
|
-
return session_data_models, extractor_config
|
|
1306
|
-
|
|
1307
|
-
def _get_precheck_interaction_query_kwargs(self) -> dict:
|
|
1308
|
-
"""
|
|
1309
|
-
Return extra keyword arguments for get_last_k_interactions_grouped in precheck.
|
|
1310
|
-
|
|
1311
|
-
Override in subclasses that need additional query parameters
|
|
1312
|
-
(e.g., agent_version for playbook services).
|
|
1313
|
-
|
|
1314
|
-
Returns:
|
|
1315
|
-
dict: Extra kwargs to pass to get_last_k_interactions_grouped
|
|
1316
|
-
"""
|
|
1317
|
-
return {}
|
|
1318
|
-
|
|
1319
|
-
def _resolve_should_run_model(self) -> str:
|
|
1320
|
-
"""
|
|
1321
|
-
Resolve the model name for should_run/should_generate LLM checks.
|
|
1322
|
-
|
|
1323
|
-
Uses LLM config override if available, falls back to site var setting.
|
|
1324
|
-
|
|
1325
|
-
Returns:
|
|
1326
|
-
str: Model name for the should_run check
|
|
1327
|
-
"""
|
|
1328
|
-
from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
|
|
1329
|
-
from reflexio.server.site_var.site_var_manager import SiteVarManager
|
|
1330
|
-
|
|
1331
|
-
root_config = self.request_context.configurator.get_config()
|
|
1332
|
-
llm_config = root_config.llm_config if root_config else None
|
|
1333
|
-
api_key_config = root_config.api_key_config if root_config else None
|
|
1334
|
-
|
|
1335
|
-
model_setting = SiteVarManager().get_site_var("llm_model_setting")
|
|
1336
|
-
site_var = model_setting if isinstance(model_setting, dict) else {}
|
|
1337
|
-
|
|
1338
|
-
return resolve_model_name(
|
|
1339
|
-
ModelRole.SHOULD_RUN,
|
|
1340
|
-
site_var_value=site_var.get("should_run_model_name"),
|
|
1341
|
-
config_override=llm_config.should_run_model_name if llm_config else None,
|
|
1342
|
-
api_key_config=api_key_config,
|
|
1343
|
-
)
|
|
1344
|
-
|
|
1345
|
-
# ===============================
|
|
1346
|
-
# Batch with progress (shared by rerun + manual)
|
|
1347
|
-
# ===============================
|
|
1348
|
-
|
|
1349
|
-
def _run_batch_with_progress(
|
|
1350
|
-
self,
|
|
1351
|
-
user_ids: list[str],
|
|
1352
|
-
request: TRequest,
|
|
1353
|
-
request_params: dict,
|
|
1354
|
-
state_manager: OperationStateManager,
|
|
1355
|
-
) -> tuple[int, int]:
|
|
1356
|
-
"""Run a batch of users with progress tracking.
|
|
1357
|
-
|
|
1358
|
-
Shared logic for both run_rerun() and run_manual_regular().
|
|
1359
|
-
Initializes progress, processes each user, and finalizes.
|
|
1360
|
-
Checks for cancellation before each user.
|
|
1361
|
-
|
|
1362
|
-
Args:
|
|
1363
|
-
user_ids: List of user IDs to process
|
|
1364
|
-
request: The original request object
|
|
1365
|
-
request_params: Parameters dict for progress state
|
|
1366
|
-
state_manager: OperationStateManager instance
|
|
1367
|
-
|
|
1368
|
-
Returns:
|
|
1369
|
-
Tuple of (users_processed, total_generated)
|
|
1370
|
-
"""
|
|
1371
|
-
total_users = len(user_ids)
|
|
1372
|
-
self._is_batch_mode = True
|
|
1373
|
-
|
|
1374
|
-
# Initialize progress
|
|
1375
|
-
state_manager.initialize_progress(
|
|
1376
|
-
total_users=total_users,
|
|
1377
|
-
request_params=request_params,
|
|
1378
|
-
)
|
|
1379
|
-
|
|
1380
|
-
try:
|
|
1381
|
-
# Process each user
|
|
1382
|
-
users_processed = 0
|
|
1383
|
-
processed_user_ids: list[str] = []
|
|
1384
|
-
for user_id in user_ids:
|
|
1385
|
-
# Check for cancellation before starting next user
|
|
1386
|
-
if state_manager.is_cancellation_requested():
|
|
1387
|
-
logger.info(
|
|
1388
|
-
"Cancellation requested for %s, stopping after %d/%d users",
|
|
1389
|
-
self._get_base_service_name(),
|
|
1390
|
-
users_processed,
|
|
1391
|
-
total_users,
|
|
1392
|
-
)
|
|
1393
|
-
state_manager.mark_cancelled()
|
|
1394
|
-
return users_processed, self._get_generated_count(
|
|
1395
|
-
request, processed_user_ids=processed_user_ids
|
|
1396
|
-
)
|
|
1397
|
-
|
|
1398
|
-
state_manager.set_current_item(user_id)
|
|
1399
|
-
|
|
1400
|
-
try:
|
|
1401
|
-
run_request = self._create_run_request_for_item(user_id, request)
|
|
1402
|
-
self.run(run_request)
|
|
1403
|
-
users_processed += 1
|
|
1404
|
-
processed_user_ids.append(user_id)
|
|
1405
|
-
|
|
1406
|
-
state_manager.update_progress(
|
|
1407
|
-
item_id=user_id,
|
|
1408
|
-
count=0, # Extractors collect their own data
|
|
1409
|
-
success=True,
|
|
1410
|
-
total_users=total_users,
|
|
1411
|
-
)
|
|
1412
|
-
|
|
1413
|
-
except Exception as e:
|
|
1414
|
-
logger.error(
|
|
1415
|
-
"Failed to process user %s for %s: %s",
|
|
1416
|
-
user_id,
|
|
1417
|
-
self._get_base_service_name(),
|
|
1418
|
-
str(e),
|
|
1419
|
-
)
|
|
1420
|
-
state_manager.update_progress(
|
|
1421
|
-
item_id=user_id,
|
|
1422
|
-
count=0,
|
|
1423
|
-
success=False,
|
|
1424
|
-
total_users=total_users,
|
|
1425
|
-
error=str(e),
|
|
1426
|
-
)
|
|
1427
|
-
continue
|
|
1428
|
-
|
|
1429
|
-
# Get generated count and finalize
|
|
1430
|
-
total_generated = self._get_generated_count(
|
|
1431
|
-
request, processed_user_ids=processed_user_ids
|
|
1432
|
-
)
|
|
1433
|
-
state_manager.finalize_progress(users_processed, total_generated)
|
|
1434
|
-
|
|
1435
|
-
return users_processed, total_generated
|
|
1436
|
-
finally:
|
|
1437
|
-
self._is_batch_mode = False
|
|
1438
|
-
|
|
1439
|
-
# ===============================
|
|
1440
|
-
# Rerun methods (optional - override to enable rerun functionality)
|
|
1441
|
-
# ===============================
|
|
1442
|
-
|
|
1443
|
-
def _get_rerun_user_ids(self, request: TRequest) -> list[str]:
|
|
1444
|
-
"""Get user IDs to process during rerun.
|
|
1445
|
-
|
|
1446
|
-
Override this method to enable rerun functionality for the service.
|
|
1447
|
-
Returns a list of user IDs that have interactions matching the request filters.
|
|
1448
|
-
Each extractor collects its own data using its configured window_size.
|
|
1449
|
-
|
|
1450
|
-
Args:
|
|
1451
|
-
request: The rerun request object
|
|
1452
|
-
|
|
1453
|
-
Returns:
|
|
1454
|
-
List of user IDs to process
|
|
1455
|
-
"""
|
|
1456
|
-
raise NotImplementedError("Rerun not supported by this service")
|
|
1457
|
-
|
|
1458
|
-
def _build_rerun_request_params(self, request: TRequest) -> dict:
|
|
1459
|
-
"""Build request params dict for operation state tracking.
|
|
1460
|
-
|
|
1461
|
-
Override this method to enable rerun functionality for the service.
|
|
1462
|
-
|
|
1463
|
-
Args:
|
|
1464
|
-
request: The rerun request object
|
|
1465
|
-
|
|
1466
|
-
Returns:
|
|
1467
|
-
Dictionary of request parameters for state tracking
|
|
1468
|
-
"""
|
|
1469
|
-
raise NotImplementedError("Rerun not supported by this service")
|
|
1470
|
-
|
|
1471
|
-
def _create_run_request_for_item(self, user_id: str, request: TRequest) -> TRequest:
|
|
1472
|
-
"""Create the request object to pass to self.run() for a single user.
|
|
1473
|
-
|
|
1474
|
-
Override this method to enable rerun functionality for the service.
|
|
1475
|
-
Each extractor collects its own data using its configured window_size.
|
|
1476
|
-
|
|
1477
|
-
Args:
|
|
1478
|
-
user_id: The user ID to process
|
|
1479
|
-
request: The original rerun request object
|
|
1480
|
-
|
|
1481
|
-
Returns:
|
|
1482
|
-
A request object suitable for self.run()
|
|
1483
|
-
"""
|
|
1484
|
-
raise NotImplementedError("Rerun not supported by this service")
|
|
1485
|
-
|
|
1486
|
-
def _create_rerun_response(self, success: bool, msg: str, count: int) -> Any:
|
|
1487
|
-
"""Create the rerun response object.
|
|
1488
|
-
|
|
1489
|
-
Override this method to enable rerun functionality for the service.
|
|
1490
|
-
|
|
1491
|
-
Args:
|
|
1492
|
-
success: Whether the operation succeeded
|
|
1493
|
-
msg: Status message
|
|
1494
|
-
count: Number of items generated
|
|
1495
|
-
|
|
1496
|
-
Returns:
|
|
1497
|
-
A response object (e.g., RerunProfileGenerationResponse)
|
|
1498
|
-
"""
|
|
1499
|
-
raise NotImplementedError("Rerun not supported by this service")
|
|
1500
|
-
|
|
1501
|
-
def _get_generated_count(
|
|
1502
|
-
self,
|
|
1503
|
-
request: TRequest,
|
|
1504
|
-
processed_user_ids: list[str] | None = None,
|
|
1505
|
-
) -> int:
|
|
1506
|
-
"""Get the count of generated items (profiles or playbooks) after rerun.
|
|
1507
|
-
|
|
1508
|
-
Override this method to enable rerun functionality for the service.
|
|
1509
|
-
|
|
1510
|
-
Args:
|
|
1511
|
-
request: The rerun request object (for filtering)
|
|
1512
|
-
processed_user_ids: List of user IDs that were successfully processed
|
|
1513
|
-
in the batch. Provided by _run_batch_with_progress so overrides
|
|
1514
|
-
don't need to handle user_id=None from batch requests.
|
|
1515
|
-
|
|
1516
|
-
Returns:
|
|
1517
|
-
Number of items generated during rerun
|
|
1518
|
-
"""
|
|
1519
|
-
raise NotImplementedError("Rerun not supported by this service")
|
|
1520
|
-
|
|
1521
|
-
def _pre_process_rerun(self, request: TRequest) -> None: # noqa: B027
|
|
1522
|
-
"""Hook called before processing rerun items.
|
|
1523
|
-
|
|
1524
|
-
Override in subclasses to perform cleanup or preparation before rerun.
|
|
1525
|
-
Default implementation does nothing.
|
|
1526
|
-
|
|
1527
|
-
Args:
|
|
1528
|
-
request: The rerun request object
|
|
1529
|
-
"""
|
|
1530
|
-
|
|
1531
|
-
def run_rerun(self, request: TRequest) -> Any:
|
|
1532
|
-
"""Run the rerun workflow for the service.
|
|
1533
|
-
|
|
1534
|
-
This template method orchestrates the rerun process:
|
|
1535
|
-
1. Check for existing in-progress operations
|
|
1536
|
-
2. Get user IDs to process
|
|
1537
|
-
3. Pre-process hook
|
|
1538
|
-
4. Run batch with progress tracking
|
|
1539
|
-
5. Return response
|
|
1540
|
-
|
|
1541
|
-
Child classes must implement the hook methods to enable rerun functionality:
|
|
1542
|
-
- _get_rerun_user_ids()
|
|
1543
|
-
- _build_rerun_request_params()
|
|
1544
|
-
- _create_run_request_for_item()
|
|
1545
|
-
- _create_rerun_response()
|
|
1546
|
-
|
|
1547
|
-
Args:
|
|
1548
|
-
request: The rerun request object
|
|
1549
|
-
|
|
1550
|
-
Returns:
|
|
1551
|
-
A response object with success status, message, and count
|
|
1552
|
-
"""
|
|
1553
|
-
state_manager = self._create_state_manager()
|
|
1554
|
-
|
|
1555
|
-
try:
|
|
1556
|
-
# 1. Check for existing in-progress operation
|
|
1557
|
-
error = state_manager.check_in_progress()
|
|
1558
|
-
if error:
|
|
1559
|
-
return self._create_rerun_response(False, error, 0)
|
|
1560
|
-
|
|
1561
|
-
# 2. Get user IDs to process
|
|
1562
|
-
user_ids = self._get_rerun_user_ids(request)
|
|
1563
|
-
if not user_ids:
|
|
1564
|
-
return self._create_rerun_response(
|
|
1565
|
-
False, "No interactions found matching the specified filters", 0
|
|
1566
|
-
)
|
|
1567
|
-
|
|
1568
|
-
# 3. Pre-process hook (e.g., delete existing pending items)
|
|
1569
|
-
self._pre_process_rerun(request)
|
|
1570
|
-
|
|
1571
|
-
# 4. Run batch with progress tracking
|
|
1572
|
-
users_processed, total_generated = self._run_batch_with_progress(
|
|
1573
|
-
user_ids=user_ids,
|
|
1574
|
-
request=request,
|
|
1575
|
-
request_params=self._build_rerun_request_params(request),
|
|
1576
|
-
state_manager=state_manager,
|
|
1577
|
-
)
|
|
1578
|
-
|
|
1579
|
-
msg = f"Completed for {users_processed} user(s)"
|
|
1580
|
-
return self._create_rerun_response(True, msg, total_generated)
|
|
1581
|
-
|
|
1582
|
-
except Exception as e:
|
|
1583
|
-
state_manager.mark_progress_failed(str(e))
|
|
1584
|
-
return self._create_rerun_response(
|
|
1585
|
-
False,
|
|
1586
|
-
f"Failed to run {self._get_base_service_name()}: {str(e)}",
|
|
1587
|
-
0,
|
|
1588
|
-
)
|
|
1589
|
-
|
|
1590
|
-
# ===============================
|
|
1591
|
-
# Upgrade/Downgrade methods (optional - override to enable)
|
|
1592
|
-
# ===============================
|
|
1593
|
-
|
|
1594
|
-
def _has_items_with_status(self, status: Status | None, request: TRequest) -> bool:
|
|
1595
|
-
"""Check if items exist with given status and filters from request.
|
|
1596
|
-
|
|
1597
|
-
Override this method to enable upgrade/downgrade functionality for the service.
|
|
1598
|
-
|
|
1599
|
-
Args:
|
|
1600
|
-
status: The status to check for (None for CURRENT)
|
|
1601
|
-
request: The upgrade/downgrade request object with filters
|
|
1602
|
-
|
|
1603
|
-
Returns:
|
|
1604
|
-
bool: True if any matching items exist
|
|
1605
|
-
"""
|
|
1606
|
-
raise NotImplementedError("Upgrade/downgrade not supported by this service")
|
|
1607
|
-
|
|
1608
|
-
def _delete_items_by_status(self, status: Status, request: TRequest) -> int:
|
|
1609
|
-
"""Delete items with given status matching request filters.
|
|
1610
|
-
|
|
1611
|
-
Override this method to enable upgrade/downgrade functionality for the service.
|
|
1612
|
-
|
|
1613
|
-
Args:
|
|
1614
|
-
status: The status of items to delete
|
|
1615
|
-
request: The upgrade/downgrade request object with filters
|
|
1616
|
-
|
|
1617
|
-
Returns:
|
|
1618
|
-
int: Number of items deleted
|
|
1619
|
-
"""
|
|
1620
|
-
raise NotImplementedError("Upgrade/downgrade not supported by this service")
|
|
1621
|
-
|
|
1622
|
-
def _update_items_status(
|
|
1623
|
-
self,
|
|
1624
|
-
old_status: Status | None,
|
|
1625
|
-
new_status: Status | None,
|
|
1626
|
-
request: TRequest,
|
|
1627
|
-
user_ids: list[str] | None = None,
|
|
1628
|
-
) -> int:
|
|
1629
|
-
"""Update items from old_status to new_status with request filters.
|
|
1630
|
-
|
|
1631
|
-
Override this method to enable upgrade/downgrade functionality for the service.
|
|
1632
|
-
|
|
1633
|
-
Args:
|
|
1634
|
-
old_status: The current status to match (None for CURRENT)
|
|
1635
|
-
new_status: The new status to set (None for CURRENT)
|
|
1636
|
-
request: The upgrade/downgrade request object with filters
|
|
1637
|
-
user_ids: Optional pre-computed list of user IDs to filter by
|
|
1638
|
-
|
|
1639
|
-
Returns:
|
|
1640
|
-
int: Number of items updated
|
|
1641
|
-
"""
|
|
1642
|
-
raise NotImplementedError("Upgrade/downgrade not supported by this service")
|
|
1643
|
-
|
|
1644
|
-
def _get_affected_user_ids_for_upgrade(self, request: TRequest) -> list[str] | None: # noqa: ARG002
|
|
1645
|
-
"""Get user IDs to filter by for upgrade operations.
|
|
1646
|
-
|
|
1647
|
-
Override this method to support the only_affected_users flag.
|
|
1648
|
-
By default returns None (no filtering).
|
|
1649
|
-
|
|
1650
|
-
Args:
|
|
1651
|
-
request: The upgrade request object
|
|
1652
|
-
|
|
1653
|
-
Returns:
|
|
1654
|
-
Optional[list[str]]: List of user IDs to filter by, or None for no filtering
|
|
1655
|
-
"""
|
|
1656
|
-
return None
|
|
1657
|
-
|
|
1658
|
-
def _get_affected_user_ids_for_downgrade(
|
|
1659
|
-
self,
|
|
1660
|
-
request: TRequest, # noqa: ARG002
|
|
1661
|
-
) -> list[str] | None:
|
|
1662
|
-
"""Get user IDs to filter by for downgrade operations.
|
|
1663
|
-
|
|
1664
|
-
Override this method to support the only_affected_users flag.
|
|
1665
|
-
By default returns None (no filtering).
|
|
1666
|
-
|
|
1667
|
-
Args:
|
|
1668
|
-
request: The downgrade request object
|
|
1669
|
-
|
|
1670
|
-
Returns:
|
|
1671
|
-
Optional[list[str]]: List of user IDs to filter by, or None for no filtering
|
|
1672
|
-
"""
|
|
1673
|
-
return None
|
|
1674
|
-
|
|
1675
|
-
def _create_status_change_response(
|
|
1676
|
-
self,
|
|
1677
|
-
operation: StatusChangeOperation,
|
|
1678
|
-
success: bool,
|
|
1679
|
-
counts: dict,
|
|
1680
|
-
msg: str,
|
|
1681
|
-
) -> Any:
|
|
1682
|
-
"""Create upgrade or downgrade response object based on operation type.
|
|
1683
|
-
|
|
1684
|
-
Override this method to enable upgrade/downgrade functionality for the service.
|
|
1685
|
-
|
|
1686
|
-
Args:
|
|
1687
|
-
operation: The operation type (UPGRADE or DOWNGRADE)
|
|
1688
|
-
success: Whether the operation succeeded
|
|
1689
|
-
counts: Dictionary of counts (upgrade: deleted/archived/promoted, downgrade: demoted/restored)
|
|
1690
|
-
msg: Status message
|
|
1691
|
-
|
|
1692
|
-
Returns:
|
|
1693
|
-
A response object (e.g., UpgradeProfilesResponse, DowngradeUserPlaybooksResponse)
|
|
1694
|
-
"""
|
|
1695
|
-
raise NotImplementedError("Upgrade/downgrade not supported by this service")
|
|
1696
|
-
|
|
1697
|
-
def run_upgrade(self, request: TRequest) -> Any:
|
|
1698
|
-
"""Run the upgrade workflow for the service.
|
|
1699
|
-
|
|
1700
|
-
This template method orchestrates the upgrade process:
|
|
1701
|
-
1. Validate that pending items exist
|
|
1702
|
-
2. Delete old archived items
|
|
1703
|
-
3. Archive current items (None → ARCHIVED)
|
|
1704
|
-
4. Promote pending items (PENDING → None/CURRENT)
|
|
1705
|
-
|
|
1706
|
-
Child classes must implement the hook methods to enable upgrade functionality:
|
|
1707
|
-
- _has_items_with_status()
|
|
1708
|
-
- _delete_items_by_status()
|
|
1709
|
-
- _update_items_status()
|
|
1710
|
-
- _create_status_change_response()
|
|
1711
|
-
|
|
1712
|
-
Args:
|
|
1713
|
-
request: The upgrade request object with optional filters
|
|
1714
|
-
|
|
1715
|
-
Returns:
|
|
1716
|
-
A response object with success status, counts, and message
|
|
1717
|
-
"""
|
|
1718
|
-
try:
|
|
1719
|
-
# 1. Validate pending items exist
|
|
1720
|
-
if not self._has_items_with_status(Status.PENDING, request):
|
|
1721
|
-
return self._create_status_change_response(
|
|
1722
|
-
StatusChangeOperation.UPGRADE,
|
|
1723
|
-
False,
|
|
1724
|
-
{"deleted": 0, "archived": 0, "promoted": 0},
|
|
1725
|
-
"No pending items found to upgrade",
|
|
1726
|
-
)
|
|
1727
|
-
|
|
1728
|
-
# Get affected user IDs once (child class determines the logic)
|
|
1729
|
-
affected_user_ids = self._get_affected_user_ids_for_upgrade(request)
|
|
1730
|
-
|
|
1731
|
-
# 2. Delete old archived items (skip if archive_current=False)
|
|
1732
|
-
deleted = 0
|
|
1733
|
-
archived = 0
|
|
1734
|
-
if getattr(request, "archive_current", True):
|
|
1735
|
-
deleted = self._delete_items_by_status(Status.ARCHIVED, request)
|
|
1736
|
-
|
|
1737
|
-
# 3. Archive current items (None → ARCHIVED)
|
|
1738
|
-
archived = self._update_items_status(
|
|
1739
|
-
None, Status.ARCHIVED, request, user_ids=affected_user_ids
|
|
1740
|
-
)
|
|
1741
|
-
|
|
1742
|
-
# 4. Promote pending items (PENDING → None)
|
|
1743
|
-
promoted = self._update_items_status(
|
|
1744
|
-
Status.PENDING, None, request, user_ids=affected_user_ids
|
|
1745
|
-
)
|
|
1746
|
-
|
|
1747
|
-
msg = f"Upgraded: {promoted} promoted, {archived} archived, {deleted} old archived deleted"
|
|
1748
|
-
return self._create_status_change_response(
|
|
1749
|
-
StatusChangeOperation.UPGRADE,
|
|
1750
|
-
True,
|
|
1751
|
-
{"deleted": deleted, "archived": archived, "promoted": promoted},
|
|
1752
|
-
msg,
|
|
1753
|
-
)
|
|
1754
|
-
|
|
1755
|
-
except Exception as e:
|
|
1756
|
-
return self._create_status_change_response(
|
|
1757
|
-
StatusChangeOperation.UPGRADE,
|
|
1758
|
-
False,
|
|
1759
|
-
{"deleted": 0, "archived": 0, "promoted": 0},
|
|
1760
|
-
f"Failed to upgrade: {str(e)}",
|
|
1761
|
-
)
|
|
1762
|
-
|
|
1763
|
-
def run_downgrade(self, request: TRequest) -> Any:
|
|
1764
|
-
"""Run the downgrade workflow for the service.
|
|
1765
|
-
|
|
1766
|
-
This template method orchestrates the downgrade process:
|
|
1767
|
-
1. Validate that archived items exist
|
|
1768
|
-
2. Demote current items (None → ARCHIVE_IN_PROGRESS)
|
|
1769
|
-
3. Restore archived items (ARCHIVED → None/CURRENT)
|
|
1770
|
-
4. Complete archiving (ARCHIVE_IN_PROGRESS → ARCHIVED)
|
|
1771
|
-
|
|
1772
|
-
Child classes must implement the hook methods to enable downgrade functionality:
|
|
1773
|
-
- _has_items_with_status()
|
|
1774
|
-
- _update_items_status()
|
|
1775
|
-
- _create_status_change_response()
|
|
1776
|
-
|
|
1777
|
-
Args:
|
|
1778
|
-
request: The downgrade request object with optional filters
|
|
1779
|
-
|
|
1780
|
-
Returns:
|
|
1781
|
-
A response object with success status, counts, and message
|
|
1782
|
-
"""
|
|
1783
|
-
try:
|
|
1784
|
-
# 1. Validate archived items exist
|
|
1785
|
-
if not self._has_items_with_status(Status.ARCHIVED, request):
|
|
1786
|
-
return self._create_status_change_response(
|
|
1787
|
-
StatusChangeOperation.DOWNGRADE,
|
|
1788
|
-
False,
|
|
1789
|
-
{"demoted": 0, "restored": 0},
|
|
1790
|
-
"No archived items found to restore",
|
|
1791
|
-
)
|
|
1792
|
-
|
|
1793
|
-
# Get affected user IDs once (child class determines the logic)
|
|
1794
|
-
affected_user_ids = self._get_affected_user_ids_for_downgrade(request)
|
|
1795
|
-
|
|
1796
|
-
# 2. Demote current (None → ARCHIVE_IN_PROGRESS)
|
|
1797
|
-
demoted = self._update_items_status(
|
|
1798
|
-
None, Status.ARCHIVE_IN_PROGRESS, request, user_ids=affected_user_ids
|
|
1799
|
-
)
|
|
1800
|
-
|
|
1801
|
-
# 3. Restore archived (ARCHIVED → None)
|
|
1802
|
-
restored = self._update_items_status(
|
|
1803
|
-
Status.ARCHIVED, None, request, user_ids=affected_user_ids
|
|
1804
|
-
)
|
|
1805
|
-
|
|
1806
|
-
# 4. Complete archiving (ARCHIVE_IN_PROGRESS → ARCHIVED)
|
|
1807
|
-
self._update_items_status(
|
|
1808
|
-
Status.ARCHIVE_IN_PROGRESS,
|
|
1809
|
-
Status.ARCHIVED,
|
|
1810
|
-
request,
|
|
1811
|
-
user_ids=affected_user_ids,
|
|
1812
|
-
)
|
|
1813
|
-
|
|
1814
|
-
msg = f"Downgraded: {demoted} archived, {restored} restored"
|
|
1815
|
-
return self._create_status_change_response(
|
|
1816
|
-
StatusChangeOperation.DOWNGRADE,
|
|
1817
|
-
True,
|
|
1818
|
-
{"demoted": demoted, "restored": restored},
|
|
1819
|
-
msg,
|
|
1820
|
-
)
|
|
1821
|
-
|
|
1822
|
-
except Exception as e:
|
|
1823
|
-
return self._create_status_change_response(
|
|
1824
|
-
StatusChangeOperation.DOWNGRADE,
|
|
1825
|
-
False,
|
|
1826
|
-
{"demoted": 0, "restored": 0},
|
|
1827
|
-
f"Failed to downgrade: {str(e)}",
|
|
1828
|
-
)
|