claude-smart 0.2.49 → 0.2.50
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 +2 -2
- package/README.md +10 -43
- package/bin/claude-smart.js +105 -0
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.coverage +0 -0
- package/plugin/README.md +4 -3
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
- package/plugin/dashboard/app/dashboard/page.tsx +6 -1
- package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
- package/plugin/dashboard/app/preferences/page.tsx +32 -35
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
- package/plugin/dashboard/app/sessions/page.tsx +2 -0
- package/plugin/dashboard/app/skills/page.tsx +65 -50
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
- package/plugin/dashboard/components/common/host-badge.tsx +118 -0
- package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
- package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
- package/plugin/dashboard/components/common/page-header.tsx +3 -3
- package/plugin/dashboard/lib/config-file.ts +5 -1
- package/plugin/dashboard/lib/host-attribution.ts +62 -0
- package/plugin/dashboard/lib/session-reader.ts +40 -2
- package/plugin/dashboard/lib/types.ts +7 -1
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +197 -1
- package/plugin/scripts/backend-python-runner.py +46 -0
- package/plugin/scripts/backend-service.sh +757 -119
- package/plugin/scripts/codex-hook.js +63 -225
- package/plugin/scripts/dashboard-open.sh +6 -4
- package/plugin/scripts/dashboard-service.sh +117 -136
- package/plugin/scripts/hook_entry.sh +3 -0
- package/plugin/scripts/smart-install.sh +15 -1
- package/plugin/src/claude_smart/cli.py +14 -0
- package/plugin/src/claude_smart/context_inject.py +3 -0
- package/plugin/src/claude_smart/env_config.py +4 -1
- package/plugin/src/claude_smart/events/post_tool.py +2 -1
- package/plugin/src/claude_smart/events/session_end.py +2 -1
- package/plugin/src/claude_smart/events/stop.py +3 -0
- package/plugin/src/claude_smart/events/user_prompt.py +2 -1
- package/plugin/src/claude_smart/internal_call.py +5 -2
- package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
- package/plugin/src/claude_smart/publish.py +59 -7
- package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
- package/plugin/src/claude_smart/runtime.py +15 -6
- package/plugin/src/claude_smart/state.py +211 -52
- package/plugin/uv.lock +1 -1
- package/plugin/vendor/reflexio/.env.example +13 -0
- package/plugin/vendor/reflexio/reflexio/README.md +7 -3
- package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
- package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
- package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
- package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
- package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
- package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
- package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
- package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
- package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
- package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
- package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
- package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
- package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
- package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
- package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
- package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
- package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
- package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
- package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
- package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
- package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
- package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
- package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
- package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
- package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
|
@@ -7,12 +7,12 @@ import os
|
|
|
7
7
|
import threading
|
|
8
8
|
import time
|
|
9
9
|
import uuid
|
|
10
|
-
from collections.abc import Callable
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
12
|
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
|
13
13
|
from contextlib import nullcontext
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
|
-
from typing import TYPE_CHECKING
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
16
|
|
|
17
17
|
from reflexio.defaults import resolve_agent_version
|
|
18
18
|
from reflexio.models.api_schema.service_schemas import (
|
|
@@ -31,6 +31,7 @@ from reflexio.server.services.agent_success_evaluation.runner import (
|
|
|
31
31
|
from reflexio.server.services.agent_success_evaluation.scheduler import (
|
|
32
32
|
GroupEvaluationScheduler,
|
|
33
33
|
)
|
|
34
|
+
from reflexio.server.services.deferred_learning_plan import DeferredLearningPlan
|
|
34
35
|
from reflexio.server.services.operation_state_utils import OperationStateManager
|
|
35
36
|
from reflexio.server.services.playbook.playbook_service_utils import (
|
|
36
37
|
PlaybookGenerationRequest,
|
|
@@ -48,6 +49,10 @@ from reflexio.server.services.reflection.reflection_service_utils import (
|
|
|
48
49
|
ReflectionServiceRequest,
|
|
49
50
|
)
|
|
50
51
|
from reflexio.server.services.reflection.service import ReflectionService
|
|
52
|
+
from reflexio.server.services.shadow_comparison.worker import (
|
|
53
|
+
ShadowComparisonJob,
|
|
54
|
+
enqueue_shadow_comparison,
|
|
55
|
+
)
|
|
51
56
|
from reflexio.server.services.storage.retention import (
|
|
52
57
|
delete_count_for_retention,
|
|
53
58
|
get_row_retention_limits,
|
|
@@ -57,6 +62,7 @@ from reflexio.server.tracing import sentry_tags
|
|
|
57
62
|
from reflexio.server.usage_metrics import record_usage_event
|
|
58
63
|
|
|
59
64
|
if TYPE_CHECKING:
|
|
65
|
+
from reflexio.server.services.deferred_learning_plan import ReflectionWritePlan
|
|
60
66
|
from reflexio.server.services.unified_search_service import UnifiedSearchService
|
|
61
67
|
|
|
62
68
|
logger = logging.getLogger(__name__)
|
|
@@ -66,6 +72,24 @@ CLEANUP_STALE_LOCK_SECONDS = 600
|
|
|
66
72
|
GENERATION_SERVICE_TIMEOUT_SECONDS = 600
|
|
67
73
|
_STALL_WARNING_PREFIX = "Reflexio learning is paused"
|
|
68
74
|
|
|
75
|
+
# ── Durable-learning same-user guard (F4) ──
|
|
76
|
+
# Service prefix for the per-user in-progress lock that serializes concurrent
|
|
77
|
+
# durable-learning jobs for one user (compute → persist across the fence).
|
|
78
|
+
_DURABLE_LEARNING_LOCK_SERVICE = "durable_learning"
|
|
79
|
+
# Stale timeout for the per-user durable lock. Deliberately set ABOVE the 300s
|
|
80
|
+
# durable-job claim lease (V4): a job's claim can be re-leased after 300s, so the
|
|
81
|
+
# lock must outlive one claim window and NOT expire in lockstep with the lease
|
|
82
|
+
# under compute-time throttling — 900s = 3x the lease (>= the required 2x).
|
|
83
|
+
_DURABLE_LOCK_STALE_SECONDS = 900
|
|
84
|
+
# Operation-state payload written when releasing the per-user lock (mirrors the
|
|
85
|
+
# clean-slate state OperationStateManager.clear_lock_if_owner writes).
|
|
86
|
+
_DURABLE_LOCK_CLEARED_STATE = {
|
|
87
|
+
"in_progress": False,
|
|
88
|
+
"current_request_id": None,
|
|
89
|
+
"pending_request_id": None,
|
|
90
|
+
"pending_request_queue": [],
|
|
91
|
+
}
|
|
92
|
+
|
|
69
93
|
|
|
70
94
|
def _retention_cleanup_interval_seconds() -> float:
|
|
71
95
|
raw = os.getenv("REFLEXIO_RETENTION_CLEANUP_INTERVAL_SECONDS", "300") or "300"
|
|
@@ -93,6 +117,22 @@ def _stable_group_sampling_fraction(
|
|
|
93
117
|
return int.from_bytes(digest[:8], "big") / 2**64
|
|
94
118
|
|
|
95
119
|
|
|
120
|
+
def _org_in_durable_allowlist(org_id: str | None) -> bool:
|
|
121
|
+
"""Whether ``org_id`` may use the durable learning queue.
|
|
122
|
+
|
|
123
|
+
Reads ``REFLEXIO_DURABLE_LEARNING_QUEUE_ORG_ALLOWLIST`` (comma-separated org
|
|
124
|
+
IDs). An empty/whitespace-only value means the allowlist is unset — the
|
|
125
|
+
default global behavior, so every org is eligible. The allowlist can only
|
|
126
|
+
*narrow* the durable path; it never enables it on its own (the
|
|
127
|
+
``REFLEXIO_DURABLE_LEARNING_QUEUE`` flag still gates activation).
|
|
128
|
+
"""
|
|
129
|
+
raw = env_str("REFLEXIO_DURABLE_LEARNING_QUEUE_ORG_ALLOWLIST", "")
|
|
130
|
+
allowed = {s.strip() for s in raw.split(",") if s.strip()}
|
|
131
|
+
if not allowed:
|
|
132
|
+
return True
|
|
133
|
+
return str(org_id) in allowed
|
|
134
|
+
|
|
135
|
+
|
|
96
136
|
@dataclass
|
|
97
137
|
class GenerationServiceResult:
|
|
98
138
|
"""Result of a GenerationService.run call.
|
|
@@ -257,7 +297,11 @@ class GenerationService:
|
|
|
257
297
|
use_durable_queue = env_truthy(
|
|
258
298
|
env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")
|
|
259
299
|
)
|
|
260
|
-
_durable_defer =
|
|
300
|
+
_durable_defer = (
|
|
301
|
+
defer_learning
|
|
302
|
+
and use_durable_queue
|
|
303
|
+
and _org_in_durable_allowlist(self.org_id)
|
|
304
|
+
)
|
|
261
305
|
|
|
262
306
|
if not _durable_defer:
|
|
263
307
|
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
@@ -276,24 +320,21 @@ class GenerationService:
|
|
|
276
320
|
self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
|
|
277
321
|
user_id=user_id, interactions=new_interactions
|
|
278
322
|
)
|
|
279
|
-
self.
|
|
323
|
+
self._schedule_post_publish_evaluations(
|
|
280
324
|
new_request=new_request,
|
|
325
|
+
interactions=new_interactions,
|
|
281
326
|
user_id=user_id,
|
|
282
327
|
agent_version=agent_version,
|
|
283
328
|
source=source,
|
|
284
329
|
)
|
|
285
|
-
|
|
286
|
-
|
|
330
|
+
self._emit_publish_success_events(
|
|
331
|
+
interactions=new_interactions,
|
|
287
332
|
user_id=user_id,
|
|
288
333
|
request_id=request_id,
|
|
289
334
|
session_id=new_request.session_id,
|
|
290
335
|
source=source,
|
|
291
336
|
agent_version=agent_version,
|
|
292
337
|
backend="evaluation_only",
|
|
293
|
-
event_name="publish_request_succeeded",
|
|
294
|
-
event_category="publish",
|
|
295
|
-
outcome="success",
|
|
296
|
-
count_value=len(new_interactions),
|
|
297
338
|
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
298
339
|
metadata={
|
|
299
340
|
"evaluation_only": True,
|
|
@@ -314,17 +355,20 @@ class GenerationService:
|
|
|
314
355
|
)
|
|
315
356
|
result.warnings.append(stall_warning)
|
|
316
357
|
logger.warning("%s; skipping automatic extraction", stall_warning)
|
|
317
|
-
|
|
318
|
-
|
|
358
|
+
self._schedule_post_publish_evaluations(
|
|
359
|
+
new_request=new_request,
|
|
360
|
+
interactions=new_interactions,
|
|
361
|
+
user_id=user_id,
|
|
362
|
+
agent_version=agent_version,
|
|
363
|
+
source=source,
|
|
364
|
+
)
|
|
365
|
+
self._emit_publish_success_events(
|
|
366
|
+
interactions=new_interactions,
|
|
319
367
|
user_id=user_id,
|
|
320
368
|
request_id=request_id,
|
|
321
369
|
session_id=new_request.session_id,
|
|
322
370
|
source=source,
|
|
323
371
|
agent_version=agent_version,
|
|
324
|
-
event_name="publish_request_succeeded",
|
|
325
|
-
event_category="publish",
|
|
326
|
-
outcome="success",
|
|
327
|
-
count_value=len(new_interactions),
|
|
328
372
|
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
329
373
|
metadata={"warning_count": len(result.warnings)},
|
|
330
374
|
)
|
|
@@ -352,24 +396,21 @@ class GenerationService:
|
|
|
352
396
|
force_extraction=publish_user_interaction_request.force_extraction,
|
|
353
397
|
skip_aggregation=publish_user_interaction_request.skip_aggregation,
|
|
354
398
|
)
|
|
355
|
-
self.
|
|
399
|
+
self._schedule_post_publish_evaluations(
|
|
356
400
|
new_request=new_request,
|
|
401
|
+
interactions=new_interactions,
|
|
357
402
|
user_id=user_id,
|
|
358
403
|
agent_version=agent_version,
|
|
359
404
|
source=source,
|
|
360
405
|
)
|
|
361
|
-
|
|
362
|
-
|
|
406
|
+
self._emit_publish_success_events(
|
|
407
|
+
interactions=new_interactions,
|
|
363
408
|
user_id=user_id,
|
|
364
409
|
request_id=request_id,
|
|
365
410
|
session_id=new_request.session_id,
|
|
366
411
|
source=source,
|
|
367
412
|
agent_version=agent_version,
|
|
368
413
|
backend="durable",
|
|
369
|
-
event_name="publish_request_succeeded",
|
|
370
|
-
event_category="publish",
|
|
371
|
-
outcome="success",
|
|
372
|
-
count_value=len(new_interactions),
|
|
373
414
|
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
374
415
|
metadata={
|
|
375
416
|
"defer_learning": True,
|
|
@@ -384,8 +425,9 @@ class GenerationService:
|
|
|
384
425
|
enqueue_publish_learning,
|
|
385
426
|
)
|
|
386
427
|
|
|
387
|
-
self.
|
|
428
|
+
self._schedule_post_publish_evaluations(
|
|
388
429
|
new_request=new_request,
|
|
430
|
+
interactions=new_interactions,
|
|
389
431
|
user_id=user_id,
|
|
390
432
|
agent_version=agent_version,
|
|
391
433
|
source=source,
|
|
@@ -402,18 +444,14 @@ class GenerationService:
|
|
|
402
444
|
skip_aggregation=publish_user_interaction_request.skip_aggregation,
|
|
403
445
|
)
|
|
404
446
|
)
|
|
405
|
-
|
|
406
|
-
|
|
447
|
+
self._emit_publish_success_events(
|
|
448
|
+
interactions=new_interactions,
|
|
407
449
|
user_id=user_id,
|
|
408
450
|
request_id=request_id,
|
|
409
451
|
session_id=new_request.session_id,
|
|
410
452
|
source=source,
|
|
411
453
|
agent_version=agent_version,
|
|
412
454
|
backend="classic",
|
|
413
|
-
event_name="publish_request_succeeded",
|
|
414
|
-
event_category="publish",
|
|
415
|
-
outcome="success",
|
|
416
|
-
count_value=len(new_interactions),
|
|
417
455
|
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
418
456
|
metadata={
|
|
419
457
|
"defer_learning": True,
|
|
@@ -447,30 +485,26 @@ class GenerationService:
|
|
|
447
485
|
result=result,
|
|
448
486
|
)
|
|
449
487
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
488
|
+
self._schedule_post_publish_evaluations(
|
|
489
|
+
new_request=new_request,
|
|
490
|
+
interactions=new_interactions,
|
|
491
|
+
user_id=user_id,
|
|
492
|
+
agent_version=agent_version,
|
|
493
|
+
source=source,
|
|
494
|
+
)
|
|
457
495
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
471
|
-
metadata={"warning_count": len(result.warnings)},
|
|
472
|
-
)
|
|
473
|
-
return result
|
|
496
|
+
self._emit_publish_success_events(
|
|
497
|
+
interactions=new_interactions,
|
|
498
|
+
user_id=user_id,
|
|
499
|
+
request_id=request_id,
|
|
500
|
+
session_id=new_request.session_id,
|
|
501
|
+
source=source,
|
|
502
|
+
agent_version=agent_version,
|
|
503
|
+
backend="classic",
|
|
504
|
+
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
505
|
+
metadata={"warning_count": len(result.warnings)},
|
|
506
|
+
)
|
|
507
|
+
return result
|
|
474
508
|
|
|
475
509
|
except Exception as e:
|
|
476
510
|
record_usage_event(
|
|
@@ -500,6 +534,47 @@ class GenerationService:
|
|
|
500
534
|
)
|
|
501
535
|
raise
|
|
502
536
|
|
|
537
|
+
def _emit_publish_success_events(
|
|
538
|
+
self,
|
|
539
|
+
*,
|
|
540
|
+
interactions: list[Interaction],
|
|
541
|
+
user_id: str,
|
|
542
|
+
request_id: str,
|
|
543
|
+
session_id: str,
|
|
544
|
+
source: str | None,
|
|
545
|
+
agent_version: str,
|
|
546
|
+
duration_ms: int,
|
|
547
|
+
metadata: Mapping[str, Any],
|
|
548
|
+
backend: str | None = None,
|
|
549
|
+
) -> None:
|
|
550
|
+
"""Emit one ``publish_request_succeeded`` event per interaction.
|
|
551
|
+
|
|
552
|
+
Replaces the old single aggregate event (``count_value=len(interactions)``)
|
|
553
|
+
with one entity-backed event per interaction (``count_value=1``,
|
|
554
|
+
``event_key=f"pub:{interaction_id}"``, ``entity_id=interaction_id``) so
|
|
555
|
+
downstream dedup can key on the interaction id. The summed
|
|
556
|
+
``count_value`` across the emitted events equals the old aggregate
|
|
557
|
+
count, so totals are unchanged.
|
|
558
|
+
"""
|
|
559
|
+
for interaction in interactions:
|
|
560
|
+
record_usage_event(
|
|
561
|
+
org_id=self.org_id,
|
|
562
|
+
user_id=user_id,
|
|
563
|
+
request_id=request_id,
|
|
564
|
+
session_id=session_id,
|
|
565
|
+
source=source,
|
|
566
|
+
agent_version=agent_version,
|
|
567
|
+
backend=backend,
|
|
568
|
+
event_name="publish_request_succeeded",
|
|
569
|
+
event_category="publish",
|
|
570
|
+
outcome="success",
|
|
571
|
+
count_value=1,
|
|
572
|
+
event_key=f"pub:{interaction.interaction_id}",
|
|
573
|
+
entity_id=str(interaction.interaction_id),
|
|
574
|
+
duration_ms=duration_ms,
|
|
575
|
+
metadata=metadata,
|
|
576
|
+
)
|
|
577
|
+
|
|
503
578
|
# ===============================
|
|
504
579
|
# deferred learning
|
|
505
580
|
# ===============================
|
|
@@ -514,15 +589,13 @@ class GenerationService:
|
|
|
514
589
|
agent_version: str,
|
|
515
590
|
force_extraction: bool,
|
|
516
591
|
skip_aggregation: bool,
|
|
517
|
-
sequential: bool = False,
|
|
518
592
|
) -> GenerationServiceResult:
|
|
519
|
-
"""Run the learning steps for a deferred job
|
|
593
|
+
"""Run the learning steps for a deferred job (synchronous / non-durable
|
|
594
|
+
callers: ``PublishLearningWorker`` and manual reruns).
|
|
520
595
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
caller already holds the storage commit_scope lock — spawning
|
|
525
|
-
worker threads inside that scope would deadlock on the RLock.
|
|
596
|
+
Runs compute + persist + side-effects together, with profile and playbook
|
|
597
|
+
generation in parallel — no external ``commit_scope`` is held on this
|
|
598
|
+
path (the durable worker uses the compute/persist/emit split instead).
|
|
526
599
|
"""
|
|
527
600
|
result = GenerationServiceResult(request_id=request_id)
|
|
528
601
|
self._run_learning_steps(
|
|
@@ -533,14 +606,353 @@ class GenerationService:
|
|
|
533
606
|
skip_aggregation=skip_aggregation,
|
|
534
607
|
source=source,
|
|
535
608
|
result=result,
|
|
536
|
-
parallel=
|
|
609
|
+
parallel=True,
|
|
537
610
|
)
|
|
538
611
|
return result
|
|
539
612
|
|
|
613
|
+
# ===============================
|
|
614
|
+
# deferred learning — compute / persist / emit split (gate b)
|
|
615
|
+
# ===============================
|
|
616
|
+
|
|
617
|
+
def compute_deferred_learning(
|
|
618
|
+
self,
|
|
619
|
+
*,
|
|
620
|
+
user_id: str,
|
|
621
|
+
request_id: str,
|
|
622
|
+
session_id: str | None, # noqa: ARG002 - kept for worker call symmetry
|
|
623
|
+
source: str | None,
|
|
624
|
+
agent_version: str,
|
|
625
|
+
force_extraction: bool,
|
|
626
|
+
skip_aggregation: bool,
|
|
627
|
+
) -> DeferredLearningPlan:
|
|
628
|
+
"""Compute half of one durable-learning job — NO ``commit_scope`` held.
|
|
629
|
+
|
|
630
|
+
Runs the same-user guard (F4) first, then reflection + profile + playbook
|
|
631
|
+
compute (LLM extraction, dedup, embeddings) entirely OUTSIDE any writer
|
|
632
|
+
transaction, and assembles a :class:`DeferredLearningPlan` of the held
|
|
633
|
+
``(service, plan)`` pairs. Issues NO learning DB write (F3): the only
|
|
634
|
+
writes are the extractor's own ``agent_run`` rows + the per-user
|
|
635
|
+
coordination lock (``try_acquire_in_progress_lock``, not a learning
|
|
636
|
+
terminal).
|
|
637
|
+
|
|
638
|
+
F4 GUARD FIRST: acquires a DB-backed per-user in-progress lock BEFORE any
|
|
639
|
+
LLM work. On contention returns ``DeferredLearningPlan(lock_acquired=
|
|
640
|
+
False, reflection/profile/playbook=None)`` immediately (no compute) — the
|
|
641
|
+
worker must then leave the job reclaimable (Task 9), NOT complete it.
|
|
642
|
+
|
|
643
|
+
Reflection runs first on the calling thread (it reads the pre-persist
|
|
644
|
+
snapshot); profile + playbook ``compute_generation`` then run in parallel
|
|
645
|
+
(no ``commit_scope`` is held, so the old ``sequential=True`` RLock
|
|
646
|
+
avoidance is unnecessary). Per-half failures are best-effort — captured
|
|
647
|
+
into ``warnings`` and the half dropped — mirroring
|
|
648
|
+
``_run_learning_steps``.
|
|
649
|
+
"""
|
|
650
|
+
warnings: list[str] = []
|
|
651
|
+
|
|
652
|
+
# F4 GUARD — before any LLM. On contention, no compute runs.
|
|
653
|
+
if not self._acquire_durable_learning_lock(
|
|
654
|
+
user_id=user_id, request_id=request_id
|
|
655
|
+
):
|
|
656
|
+
logger.info(
|
|
657
|
+
"durable-learning same-user lock held; leaving job reclaimable "
|
|
658
|
+
"(user_id=%s request_id=%s)",
|
|
659
|
+
user_id,
|
|
660
|
+
request_id,
|
|
661
|
+
)
|
|
662
|
+
return DeferredLearningPlan(
|
|
663
|
+
request_id=request_id,
|
|
664
|
+
user_id=user_id,
|
|
665
|
+
agent_version=agent_version,
|
|
666
|
+
lock_acquired=False,
|
|
667
|
+
reflection=None,
|
|
668
|
+
profile=None,
|
|
669
|
+
playbook=None,
|
|
670
|
+
warnings=warnings,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
# Reflection first (calling thread) — one pre-persist read snapshot.
|
|
674
|
+
reflection_pair = self._compute_reflection(
|
|
675
|
+
user_id=user_id,
|
|
676
|
+
request_id=request_id,
|
|
677
|
+
agent_version=agent_version,
|
|
678
|
+
source=source,
|
|
679
|
+
warnings=warnings,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
# Profile + playbook compute in parallel — no scope held, so no RLock
|
|
683
|
+
# contention (the pre-split sequential mode only existed to avoid the
|
|
684
|
+
# SQLite commit_scope RLock, which compute never takes).
|
|
685
|
+
profile_service = ProfileGenerationService(
|
|
686
|
+
llm_client=self.client, request_context=self.request_context
|
|
687
|
+
)
|
|
688
|
+
profile_request = ProfileGenerationRequest(
|
|
689
|
+
user_id=user_id,
|
|
690
|
+
request_id=request_id,
|
|
691
|
+
source=source,
|
|
692
|
+
force_extraction=force_extraction,
|
|
693
|
+
)
|
|
694
|
+
playbook_service = PlaybookGenerationService(
|
|
695
|
+
llm_client=self.client,
|
|
696
|
+
request_context=self.request_context,
|
|
697
|
+
skip_aggregation=skip_aggregation,
|
|
698
|
+
)
|
|
699
|
+
playbook_request = PlaybookGenerationRequest(
|
|
700
|
+
request_id=request_id,
|
|
701
|
+
agent_version=agent_version,
|
|
702
|
+
user_id=user_id,
|
|
703
|
+
source=source,
|
|
704
|
+
force_extraction=force_extraction,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
profile_pair: tuple = None # type: ignore[assignment]
|
|
708
|
+
playbook_pair: tuple = None # type: ignore[assignment]
|
|
709
|
+
executor = ThreadPoolExecutor(max_workers=2)
|
|
710
|
+
try:
|
|
711
|
+
profile_future = executor.submit(
|
|
712
|
+
contextvars.copy_context().run,
|
|
713
|
+
profile_service.compute_generation,
|
|
714
|
+
profile_request,
|
|
715
|
+
)
|
|
716
|
+
playbook_future = executor.submit(
|
|
717
|
+
contextvars.copy_context().run,
|
|
718
|
+
playbook_service.compute_generation,
|
|
719
|
+
playbook_request,
|
|
720
|
+
)
|
|
721
|
+
for future, service_name, service in (
|
|
722
|
+
(profile_future, "profile_generation", profile_service),
|
|
723
|
+
(playbook_future, "playbook_generation", playbook_service),
|
|
724
|
+
):
|
|
725
|
+
try:
|
|
726
|
+
plan = future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
|
|
727
|
+
except FuturesTimeoutError: # noqa: PERF203
|
|
728
|
+
msg = (
|
|
729
|
+
f"{service_name} timed out after "
|
|
730
|
+
f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
|
|
731
|
+
)
|
|
732
|
+
with sentry_tags(
|
|
733
|
+
subsystem="generation",
|
|
734
|
+
service=service_name,
|
|
735
|
+
request_id=request_id,
|
|
736
|
+
error_type="timeout",
|
|
737
|
+
):
|
|
738
|
+
logger.error("%s for request %s", msg, request_id)
|
|
739
|
+
warnings.append(msg)
|
|
740
|
+
continue
|
|
741
|
+
except Exception as e:
|
|
742
|
+
msg = f"{service_name} failed: {e}"
|
|
743
|
+
with sentry_tags(
|
|
744
|
+
subsystem="generation",
|
|
745
|
+
service=service_name,
|
|
746
|
+
request_id=request_id,
|
|
747
|
+
error_type=type(e).__name__,
|
|
748
|
+
):
|
|
749
|
+
logger.exception(
|
|
750
|
+
"Generation service failed for request %s",
|
|
751
|
+
request_id,
|
|
752
|
+
)
|
|
753
|
+
warnings.append(msg)
|
|
754
|
+
continue
|
|
755
|
+
if plan is None:
|
|
756
|
+
continue
|
|
757
|
+
if service_name == "profile_generation":
|
|
758
|
+
profile_pair = (service, plan)
|
|
759
|
+
else:
|
|
760
|
+
playbook_pair = (service, plan)
|
|
761
|
+
finally:
|
|
762
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
763
|
+
|
|
764
|
+
return DeferredLearningPlan(
|
|
765
|
+
request_id=request_id,
|
|
766
|
+
user_id=user_id,
|
|
767
|
+
agent_version=agent_version,
|
|
768
|
+
lock_acquired=True,
|
|
769
|
+
reflection=reflection_pair,
|
|
770
|
+
profile=profile_pair,
|
|
771
|
+
playbook=playbook_pair,
|
|
772
|
+
warnings=warnings,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
def persist_deferred_learning(self, plan: DeferredLearningPlan) -> None:
|
|
776
|
+
"""Persist half — apply the fence-critical writes for a computed job.
|
|
777
|
+
|
|
778
|
+
This is the ONLY part the durable worker runs inside its fenced
|
|
779
|
+
``commit_scope``. For each present half it calls the held instance's
|
|
780
|
+
persist (reflection apply loop + bookmark; profile/playbook row writes +
|
|
781
|
+
extractor bookmark advance). Issues NO side-effects (telemetry, billing,
|
|
782
|
+
tagging, off-thread schedulers, lock release) — those are post-commit
|
|
783
|
+
(``emit_deferred_learning_side_effects``) so a fence-lost job never fires
|
|
784
|
+
them.
|
|
785
|
+
"""
|
|
786
|
+
if plan.reflection is not None:
|
|
787
|
+
reflection_service, reflection_plan = plan.reflection
|
|
788
|
+
reflection_service.persist(reflection_plan)
|
|
789
|
+
if plan.profile is not None:
|
|
790
|
+
profile_service, profile_plan = plan.profile
|
|
791
|
+
profile_service.persist_generation(profile_plan)
|
|
792
|
+
if plan.playbook is not None:
|
|
793
|
+
playbook_service, playbook_plan = plan.playbook
|
|
794
|
+
playbook_service.persist_generation(playbook_plan)
|
|
795
|
+
|
|
796
|
+
def emit_deferred_learning_side_effects(self, plan: DeferredLearningPlan) -> None:
|
|
797
|
+
"""Post-commit side-effects — telemetry, billing, tagging, lock release.
|
|
798
|
+
|
|
799
|
+
Runs only for a fence-winning job (the durable worker calls it after the
|
|
800
|
+
scope commits). Fires each held half's post-commit emit + schedules the
|
|
801
|
+
deferred tagging pass, then ALWAYS releases the per-user F4 lock (even if
|
|
802
|
+
an emit raised) so a completed job never strands the lock.
|
|
803
|
+
|
|
804
|
+
A ``lock_acquired=False`` plan never reaches here (the worker skips emit
|
|
805
|
+
on contention), so releasing here is safe — this instance owns the lock.
|
|
806
|
+
|
|
807
|
+
Each emit half is isolated in its own try/except (mirroring how
|
|
808
|
+
``schedule_tagging`` already self-guards): the halves are independent
|
|
809
|
+
post-commit side effects (billing / telemetry / off-thread schedulers),
|
|
810
|
+
so a failure in an early half must NOT starve the later halves. The
|
|
811
|
+
per-user lock release always runs in ``finally`` so a completed job never
|
|
812
|
+
strands the lock.
|
|
813
|
+
"""
|
|
814
|
+
try:
|
|
815
|
+
if plan.reflection is not None:
|
|
816
|
+
reflection_service, reflection_plan = plan.reflection
|
|
817
|
+
try:
|
|
818
|
+
reflection_service.emit_side_effects(reflection_plan)
|
|
819
|
+
except Exception:
|
|
820
|
+
logger.exception(
|
|
821
|
+
"Failed to emit reflection side effects for deferred "
|
|
822
|
+
"learning request %s",
|
|
823
|
+
plan.request_id,
|
|
824
|
+
)
|
|
825
|
+
if plan.profile is not None:
|
|
826
|
+
profile_service, profile_plan = plan.profile
|
|
827
|
+
try:
|
|
828
|
+
profile_service.emit_generation_side_effects(profile_plan)
|
|
829
|
+
except Exception:
|
|
830
|
+
logger.exception(
|
|
831
|
+
"Failed to emit profile side effects for deferred "
|
|
832
|
+
"learning request %s",
|
|
833
|
+
plan.request_id,
|
|
834
|
+
)
|
|
835
|
+
if plan.playbook is not None:
|
|
836
|
+
playbook_service, playbook_plan = plan.playbook
|
|
837
|
+
try:
|
|
838
|
+
playbook_service.emit_generation_side_effects(playbook_plan)
|
|
839
|
+
except Exception:
|
|
840
|
+
logger.exception(
|
|
841
|
+
"Failed to emit playbook side effects for deferred "
|
|
842
|
+
"learning request %s",
|
|
843
|
+
plan.request_id,
|
|
844
|
+
)
|
|
845
|
+
try:
|
|
846
|
+
schedule_tagging(
|
|
847
|
+
org_id=self.org_id,
|
|
848
|
+
user_id=plan.user_id,
|
|
849
|
+
agent_version=plan.agent_version,
|
|
850
|
+
request_context=self.request_context,
|
|
851
|
+
llm_client=self.client,
|
|
852
|
+
)
|
|
853
|
+
except Exception:
|
|
854
|
+
logger.exception(
|
|
855
|
+
"Failed to schedule tagging for deferred learning request %s",
|
|
856
|
+
plan.request_id,
|
|
857
|
+
)
|
|
858
|
+
finally:
|
|
859
|
+
self._release_durable_learning_lock(
|
|
860
|
+
user_id=plan.user_id, request_id=plan.request_id
|
|
861
|
+
)
|
|
862
|
+
|
|
540
863
|
# ===============================
|
|
541
864
|
# private methods
|
|
542
865
|
# ===============================
|
|
543
866
|
|
|
867
|
+
def _durable_learning_lock_key(self, user_id: str) -> str:
|
|
868
|
+
"""Per-user durable-learning in-progress lock key.
|
|
869
|
+
|
|
870
|
+
Mirrors ``OperationStateManager._lock_key`` shape
|
|
871
|
+
(``{service}::{org}::{scope}::lock``) but on a DEDICATED
|
|
872
|
+
``durable_learning`` service prefix, distinct from the
|
|
873
|
+
profile/playbook generation locks and from any extractor bookmark row.
|
|
874
|
+
"""
|
|
875
|
+
return f"{_DURABLE_LEARNING_LOCK_SERVICE}::{self.org_id}::{user_id}::lock"
|
|
876
|
+
|
|
877
|
+
def _acquire_durable_learning_lock(self, *, user_id: str, request_id: str) -> bool:
|
|
878
|
+
"""Try to acquire the per-user durable-learning lock (F4).
|
|
879
|
+
|
|
880
|
+
Uses the atomic DB-backed ``try_acquire_in_progress_lock`` (a distinct
|
|
881
|
+
storage method, NOT one of the learning-write terminals the compute
|
|
882
|
+
purity contract forbids), so the guard does not trip the compute
|
|
883
|
+
write-tripwire. Returns ``True`` when acquired (proceed with compute),
|
|
884
|
+
``False`` on contention (another same-user durable job holds it).
|
|
885
|
+
"""
|
|
886
|
+
if self.storage is None:
|
|
887
|
+
return True
|
|
888
|
+
result = self.storage.try_acquire_in_progress_lock(
|
|
889
|
+
self._durable_learning_lock_key(user_id),
|
|
890
|
+
request_id,
|
|
891
|
+
stale_lock_seconds=_DURABLE_LOCK_STALE_SECONDS,
|
|
892
|
+
)
|
|
893
|
+
return bool(result.get("acquired", False))
|
|
894
|
+
|
|
895
|
+
def _release_durable_learning_lock(self, *, user_id: str, request_id: str) -> None:
|
|
896
|
+
"""Release the per-user durable-learning lock iff we still own it.
|
|
897
|
+
|
|
898
|
+
Uses ``clear_in_progress_lock_if_owner`` (CAS on the holder) so a job
|
|
899
|
+
whose lock was already stolen after a stale-timeout does not clobber the
|
|
900
|
+
new holder's lock.
|
|
901
|
+
"""
|
|
902
|
+
if self.storage is None:
|
|
903
|
+
return
|
|
904
|
+
self.storage.clear_in_progress_lock_if_owner(
|
|
905
|
+
self._durable_learning_lock_key(user_id),
|
|
906
|
+
request_id,
|
|
907
|
+
dict(_DURABLE_LOCK_CLEARED_STATE),
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
def _compute_reflection(
|
|
911
|
+
self,
|
|
912
|
+
*,
|
|
913
|
+
user_id: str,
|
|
914
|
+
request_id: str,
|
|
915
|
+
agent_version: str,
|
|
916
|
+
source: str | None,
|
|
917
|
+
warnings: list[str],
|
|
918
|
+
) -> tuple[ReflectionService, ReflectionWritePlan] | None:
|
|
919
|
+
"""Run reflection ``compute`` best-effort; return its held pair or None.
|
|
920
|
+
|
|
921
|
+
Mirrors ``_maybe_run_reflection`` (a reflection failure must never break
|
|
922
|
+
the publish) but returns the ``(service, plan)`` pair so the caller can
|
|
923
|
+
defer ``persist`` / ``emit_side_effects`` across the worker fence.
|
|
924
|
+
Returns ``None`` when reflection has nothing to persist (gate closed /
|
|
925
|
+
disabled / storage None) or a compute error (logged + appended to
|
|
926
|
+
``warnings``).
|
|
927
|
+
"""
|
|
928
|
+
try:
|
|
929
|
+
service = ReflectionService(
|
|
930
|
+
request_context=self.request_context,
|
|
931
|
+
llm_client=self.client,
|
|
932
|
+
)
|
|
933
|
+
reflection_plan = service.compute(
|
|
934
|
+
ReflectionServiceRequest(
|
|
935
|
+
user_id=user_id,
|
|
936
|
+
request_id=request_id,
|
|
937
|
+
agent_version=agent_version,
|
|
938
|
+
source=source,
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
if reflection_plan is None:
|
|
942
|
+
return None
|
|
943
|
+
return (service, reflection_plan)
|
|
944
|
+
except Exception as exc: # noqa: BLE001 — reflection must not break publish
|
|
945
|
+
with sentry_tags(
|
|
946
|
+
subsystem="generation",
|
|
947
|
+
op="reflection",
|
|
948
|
+
org_id=self.org_id,
|
|
949
|
+
user_id=user_id,
|
|
950
|
+
error_type=type(exc).__name__,
|
|
951
|
+
):
|
|
952
|
+
logger.exception("reflection compute failed for user %s", user_id)
|
|
953
|
+
warnings.append(f"reflection failed: {exc}")
|
|
954
|
+
return None
|
|
955
|
+
|
|
544
956
|
def _run_learning_steps(
|
|
545
957
|
self,
|
|
546
958
|
*,
|
|
@@ -565,9 +977,10 @@ class GenerationService:
|
|
|
565
977
|
profile_generation_service = ProfileGenerationService(
|
|
566
978
|
llm_client=self.client, request_context=self.request_context
|
|
567
979
|
)
|
|
980
|
+
source_request_id = request_id
|
|
568
981
|
profile_generation_request = ProfileGenerationRequest(
|
|
569
982
|
user_id=user_id,
|
|
570
|
-
request_id=
|
|
983
|
+
request_id=source_request_id,
|
|
571
984
|
source=source,
|
|
572
985
|
force_extraction=force_extraction,
|
|
573
986
|
)
|
|
@@ -578,7 +991,7 @@ class GenerationService:
|
|
|
578
991
|
skip_aggregation=skip_aggregation,
|
|
579
992
|
)
|
|
580
993
|
playbook_generation_request = PlaybookGenerationRequest(
|
|
581
|
-
request_id=
|
|
994
|
+
request_id=source_request_id,
|
|
582
995
|
agent_version=agent_version,
|
|
583
996
|
user_id=user_id,
|
|
584
997
|
source=source,
|
|
@@ -679,6 +1092,39 @@ class GenerationService:
|
|
|
679
1092
|
request_id,
|
|
680
1093
|
)
|
|
681
1094
|
|
|
1095
|
+
def _schedule_post_publish_evaluations(
|
|
1096
|
+
self,
|
|
1097
|
+
*,
|
|
1098
|
+
new_request: Request,
|
|
1099
|
+
interactions: list[Interaction],
|
|
1100
|
+
user_id: str,
|
|
1101
|
+
agent_version: str,
|
|
1102
|
+
source: str | None,
|
|
1103
|
+
) -> None:
|
|
1104
|
+
"""Schedule all best-effort evaluation work after publish persistence."""
|
|
1105
|
+
if any(interaction.shadow_content for interaction in interactions):
|
|
1106
|
+
try:
|
|
1107
|
+
enqueue_shadow_comparison(
|
|
1108
|
+
ShadowComparisonJob(
|
|
1109
|
+
org_id=self.org_id,
|
|
1110
|
+
interactions=interactions,
|
|
1111
|
+
session_id=new_request.session_id,
|
|
1112
|
+
agent_version=agent_version,
|
|
1113
|
+
)
|
|
1114
|
+
)
|
|
1115
|
+
except Exception:
|
|
1116
|
+
logger.exception(
|
|
1117
|
+
"Failed to enqueue shadow comparison for session %s",
|
|
1118
|
+
new_request.session_id,
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
self._schedule_group_evaluation_if_needed(
|
|
1122
|
+
new_request=new_request,
|
|
1123
|
+
user_id=user_id,
|
|
1124
|
+
agent_version=agent_version,
|
|
1125
|
+
source=source,
|
|
1126
|
+
)
|
|
1127
|
+
|
|
682
1128
|
def _schedule_group_evaluation_if_needed(
|
|
683
1129
|
self,
|
|
684
1130
|
*,
|
|
@@ -958,6 +1404,7 @@ class GenerationService:
|
|
|
958
1404
|
expert_content=interaction_data.expert_content,
|
|
959
1405
|
tools_used=interaction_data.tools_used,
|
|
960
1406
|
citations=interaction_data.citations,
|
|
1407
|
+
retrieved_learnings=interaction_data.retrieved_learnings,
|
|
961
1408
|
)
|
|
962
1409
|
for interaction_data in interaction_data_list
|
|
963
1410
|
]
|