claude-smart 0.2.48 → 0.2.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +6 -2
- package/bin/claude-smart.js +289 -56
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/opencode/dist/server.mjs +60 -2
- package/plugin/opencode/server.mts +64 -2
- package/plugin/pyproject.toml +2 -2
- package/plugin/scripts/_lib.sh +58 -6
- package/plugin/scripts/backend-service.sh +15 -10
- package/plugin/scripts/codex-hook.js +16 -4
- package/plugin/scripts/dashboard-service.sh +1 -1
- package/plugin/scripts/ensure-plugin-root.sh +106 -8
- package/plugin/scripts/opencode-claude-compat.js +8 -1
- package/plugin/scripts/smart-install.sh +101 -20
- package/plugin/src/README.md +1 -1
- package/plugin/src/claude_smart/cli.py +79 -29
- package/plugin/src/claude_smart/env_config.py +53 -11
- package/plugin/uv.lock +5 -5
- package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
- package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
- package/plugin/vendor/reflexio/reflexio/server/api.py +15 -4
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
- package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
- package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
- package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +193 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +150 -37
- package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +140 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +16 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +12 -5
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +52 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +28 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +219 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +23 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +22 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""DurableLearningWorker: claim jobs from the durable queue, process in a fenced
|
|
2
|
+
commit_scope so exactly one of N racing workers commits its outputs.
|
|
3
|
+
|
|
4
|
+
Design constraints (v1 — do not remove):
|
|
5
|
+
- LLM calls are inside the commit_scope (compute/persist separation is deferred).
|
|
6
|
+
- No heartbeat thread (deferred; would deadlock a SQLite scope anyway).
|
|
7
|
+
- Exactly-once is guaranteed by the fenced complete_learning_job:
|
|
8
|
+
rowcount == 0 -> lease was stolen -> _SupersededError -> rollback.
|
|
9
|
+
rowcount == 1 -> we own the lease -> commit succeeds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import uuid
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
19
|
+
from reflexio.server.cache.reflexio_cache import get_reflexio
|
|
20
|
+
from reflexio.server.services.generation_service import GenerationService
|
|
21
|
+
from reflexio.server.services.storage.storage_base._learning_jobs import LearningJob
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _SupersededError(Exception):
|
|
27
|
+
"""Raised inside commit_scope when complete_learning_job returns 0.
|
|
28
|
+
|
|
29
|
+
Propagating this exception causes commit_scope to roll back all writes
|
|
30
|
+
(profiles, playbooks) made by run_deferred_learning for the superseded
|
|
31
|
+
worker — guaranteeing exactly-once side effects.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, job_id: str) -> None:
|
|
35
|
+
super().__init__(f"learning job {job_id} superseded (lease stolen)")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DurableLearningWorker:
|
|
39
|
+
"""Claim durable learning jobs and process each in a fenced commit_scope.
|
|
40
|
+
|
|
41
|
+
Two instances racing the same job produce profile/playbook side effects
|
|
42
|
+
identical to a single run: the loser's complete_learning_job returns 0,
|
|
43
|
+
which raises _SupersededError inside the scope, rolling back its writes.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
request_context_factory: Callable that returns a RequestContext for a
|
|
47
|
+
given org_id. Called once per drain_org invocation.
|
|
48
|
+
instance_id: Stable label written to claimed_by on each claim.
|
|
49
|
+
Defaults to a random short UUID suffix.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
request_context_factory: Callable[[str], RequestContext],
|
|
55
|
+
*,
|
|
56
|
+
instance_id: str | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._factory = request_context_factory
|
|
59
|
+
self._instance_id = instance_id or str(uuid.uuid4())[:8]
|
|
60
|
+
|
|
61
|
+
def drain_org(self, org_id: str, batch_size: int, lease_seconds: int) -> int:
|
|
62
|
+
"""Claim up to batch_size of this org's jobs and process each.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
org_id: Organisation to drain.
|
|
66
|
+
batch_size: Maximum number of jobs to claim in one call.
|
|
67
|
+
lease_seconds: Lease duration passed to claim_learning_jobs.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Number of jobs successfully completed (not counting superseded or failed).
|
|
71
|
+
"""
|
|
72
|
+
ctx = self._factory(org_id)
|
|
73
|
+
storage = ctx.storage
|
|
74
|
+
if storage is None:
|
|
75
|
+
logger.error("event=learning_worker_no_storage org_id=%s", org_id)
|
|
76
|
+
return 0
|
|
77
|
+
jobs = storage.claim_learning_jobs(
|
|
78
|
+
claimed_by=self._instance_id,
|
|
79
|
+
limit=batch_size,
|
|
80
|
+
lease_seconds=lease_seconds,
|
|
81
|
+
)
|
|
82
|
+
processed = 0
|
|
83
|
+
for job in jobs:
|
|
84
|
+
if self._process_job(ctx, job):
|
|
85
|
+
processed += 1
|
|
86
|
+
return processed
|
|
87
|
+
|
|
88
|
+
def _process_job(self, ctx: RequestContext, job: LearningJob) -> bool:
|
|
89
|
+
"""Process a single claimed job.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
ctx: RequestContext for the job's org.
|
|
93
|
+
job: The claimed LearningJob (claim_token set).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if the job was successfully completed; False if superseded or failed.
|
|
97
|
+
"""
|
|
98
|
+
storage = ctx.storage
|
|
99
|
+
if storage is None:
|
|
100
|
+
logger.error(
|
|
101
|
+
"event=learning_job_no_storage job_id=%s org_id=%s",
|
|
102
|
+
job.job_id,
|
|
103
|
+
job.org_id,
|
|
104
|
+
)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# claim_learning_jobs always sets claim_token on a returned job.
|
|
108
|
+
claim_token = job.claim_token
|
|
109
|
+
if claim_token is None:
|
|
110
|
+
logger.error(
|
|
111
|
+
"event=learning_job_no_claim_token job_id=%s org_id=%s",
|
|
112
|
+
job.job_id,
|
|
113
|
+
job.org_id,
|
|
114
|
+
)
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
dead = job.attempts >= job.max_attempts
|
|
118
|
+
|
|
119
|
+
# The job does not carry agent_version / session_id / source.
|
|
120
|
+
# These must be read from the durably-persisted Request.
|
|
121
|
+
if job.latest_request_id is None:
|
|
122
|
+
logger.error(
|
|
123
|
+
"event=learning_job_no_request_id job_id=%s org_id=%s user_id=%s",
|
|
124
|
+
job.job_id,
|
|
125
|
+
job.org_id,
|
|
126
|
+
job.user_id,
|
|
127
|
+
)
|
|
128
|
+
storage.fail_learning_job(
|
|
129
|
+
job_id=job.job_id, claim_token=claim_token, dead=dead
|
|
130
|
+
)
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
request = storage.get_request(job.latest_request_id)
|
|
134
|
+
if request is None:
|
|
135
|
+
logger.error(
|
|
136
|
+
"event=learning_job_missing_request job_id=%s request_id=%s",
|
|
137
|
+
job.job_id,
|
|
138
|
+
job.latest_request_id,
|
|
139
|
+
)
|
|
140
|
+
storage.fail_learning_job(
|
|
141
|
+
job_id=job.job_id, claim_token=claim_token, dead=dead
|
|
142
|
+
)
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
reflexio = get_reflexio(
|
|
147
|
+
org_id=ctx.org_id, storage_base_dir=ctx.storage_base_dir
|
|
148
|
+
)
|
|
149
|
+
gen = GenerationService(llm_client=reflexio.llm_client, request_context=ctx)
|
|
150
|
+
|
|
151
|
+
with storage.commit_scope():
|
|
152
|
+
gen.run_deferred_learning(
|
|
153
|
+
user_id=job.user_id,
|
|
154
|
+
request_id=job.latest_request_id,
|
|
155
|
+
session_id=request.session_id,
|
|
156
|
+
source=request.source,
|
|
157
|
+
agent_version=request.agent_version,
|
|
158
|
+
force_extraction=job.force_extraction,
|
|
159
|
+
skip_aggregation=job.skip_aggregation,
|
|
160
|
+
sequential=True, # prevent ThreadPoolExecutor deadlock on commit_scope RLock
|
|
161
|
+
)
|
|
162
|
+
rows = storage.complete_learning_job(
|
|
163
|
+
job_id=job.job_id, claim_token=claim_token
|
|
164
|
+
)
|
|
165
|
+
if rows == 0:
|
|
166
|
+
raise _SupersededError(job.job_id)
|
|
167
|
+
logger.info(
|
|
168
|
+
"event=learning_job_done job_id=%s org_id=%s user_id=%s",
|
|
169
|
+
job.job_id,
|
|
170
|
+
job.org_id,
|
|
171
|
+
job.user_id,
|
|
172
|
+
)
|
|
173
|
+
return True
|
|
174
|
+
except _SupersededError:
|
|
175
|
+
logger.info(
|
|
176
|
+
"event=learning_job_superseded job_id=%s org_id=%s",
|
|
177
|
+
job.job_id,
|
|
178
|
+
job.org_id,
|
|
179
|
+
)
|
|
180
|
+
return False
|
|
181
|
+
except Exception:
|
|
182
|
+
logger.exception(
|
|
183
|
+
"event=learning_job_failed job_id=%s org_id=%s user_id=%s",
|
|
184
|
+
job.job_id,
|
|
185
|
+
job.org_id,
|
|
186
|
+
job.user_id,
|
|
187
|
+
)
|
|
188
|
+
# attempts was incremented by claim_learning_jobs; go dead when we've
|
|
189
|
+
# exhausted max_attempts total claim attempts.
|
|
190
|
+
storage.fail_learning_job(
|
|
191
|
+
job_id=job.job_id, claim_token=claim_token, dead=dead
|
|
192
|
+
)
|
|
193
|
+
return False
|
|
@@ -107,9 +107,7 @@ class ExtractionResumeScheduler(ThreadedScheduler):
|
|
|
107
107
|
try:
|
|
108
108
|
bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
|
|
109
109
|
config = bootstrap_ctx.configurator.get_config()
|
|
110
|
-
poll_interval =
|
|
111
|
-
config.pending_tool_call_config.resume_poll_interval_seconds
|
|
112
|
-
)
|
|
110
|
+
poll_interval = config.pending_tool_call_config.resume_poll_interval_seconds
|
|
113
111
|
self._expire_pending_tool_calls(bootstrap_ctx)
|
|
114
112
|
for org_id in self._discover_org_ids(bootstrap_ctx):
|
|
115
113
|
if self._stop_event.is_set():
|
|
@@ -22,6 +22,7 @@ from reflexio.models.api_schema.service_schemas import (
|
|
|
22
22
|
)
|
|
23
23
|
from reflexio.models.config_schema import Config
|
|
24
24
|
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
25
|
+
from reflexio.server.env_utils import env_str, env_truthy
|
|
25
26
|
from reflexio.server.llm.litellm_client import LiteLLMClient
|
|
26
27
|
from reflexio.server.operation_limiter import operation_limit
|
|
27
28
|
from reflexio.server.services.agent_success_evaluation.runner import (
|
|
@@ -249,17 +250,32 @@ class GenerationService:
|
|
|
249
250
|
session_id=publish_user_interaction_request.session_id,
|
|
250
251
|
evaluation_only=publish_user_interaction_request.evaluation_only,
|
|
251
252
|
)
|
|
252
|
-
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
253
253
|
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
# When the durable queue is enabled and this is a deferred publish, the
|
|
255
|
+
# request + interactions + job row are written together inside a single
|
|
256
|
+
# commit_scope (below). Every other path persists here unconditionally.
|
|
257
|
+
use_durable_queue = env_truthy(
|
|
258
|
+
env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")
|
|
257
259
|
)
|
|
260
|
+
_durable_defer = defer_learning and use_durable_queue
|
|
261
|
+
|
|
262
|
+
if not _durable_defer:
|
|
263
|
+
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
264
|
+
# Add interactions to storage (bulk insert with batched embedding generation)
|
|
265
|
+
self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
|
|
266
|
+
user_id=user_id, interactions=new_interactions
|
|
267
|
+
)
|
|
258
268
|
|
|
259
269
|
# Extract source (empty string treated as None)
|
|
260
270
|
source = publish_user_interaction_request.source or None
|
|
261
271
|
|
|
262
272
|
if publish_user_interaction_request.evaluation_only:
|
|
273
|
+
if _durable_defer:
|
|
274
|
+
# evaluation_only returns early; ensure data lands even on durable path.
|
|
275
|
+
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
276
|
+
self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
|
|
277
|
+
user_id=user_id, interactions=new_interactions
|
|
278
|
+
)
|
|
263
279
|
self._schedule_group_evaluation_if_needed(
|
|
264
280
|
new_request=new_request,
|
|
265
281
|
user_id=user_id,
|
|
@@ -290,6 +306,12 @@ class GenerationService:
|
|
|
290
306
|
not publish_user_interaction_request.override_learning_stall
|
|
291
307
|
and (stall_warning := self._active_learning_stall_warning()) is not None
|
|
292
308
|
):
|
|
309
|
+
if _durable_defer:
|
|
310
|
+
# Stall skips learning but data must still land.
|
|
311
|
+
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
312
|
+
self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
|
|
313
|
+
user_id=user_id, interactions=new_interactions
|
|
314
|
+
)
|
|
293
315
|
result.warnings.append(stall_warning)
|
|
294
316
|
logger.warning("%s; skipping automatic extraction", stall_warning)
|
|
295
317
|
record_usage_event(
|
|
@@ -309,6 +331,54 @@ class GenerationService:
|
|
|
309
331
|
return result
|
|
310
332
|
|
|
311
333
|
if defer_learning:
|
|
334
|
+
if _durable_defer:
|
|
335
|
+
# Durable atomic path: pre-generate embeddings OUTSIDE the
|
|
336
|
+
# transaction (network call), then persist request + interactions
|
|
337
|
+
# + job in one commit_scope.
|
|
338
|
+
self.storage.prepare_interaction_embeddings(new_interactions) # type: ignore[reportOptionalMemberAccess]
|
|
339
|
+
covers_through = max(i.created_at for i in new_interactions)
|
|
340
|
+
with self.storage.commit_scope(): # type: ignore[reportOptionalMemberAccess]
|
|
341
|
+
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
342
|
+
self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
|
|
343
|
+
user_id=user_id,
|
|
344
|
+
interactions=new_interactions,
|
|
345
|
+
embeddings_prepared=True,
|
|
346
|
+
)
|
|
347
|
+
self.storage.enqueue_learning_job( # type: ignore[reportOptionalMemberAccess]
|
|
348
|
+
org_id=self.org_id,
|
|
349
|
+
user_id=user_id,
|
|
350
|
+
request_id=request_id,
|
|
351
|
+
covers_through=covers_through,
|
|
352
|
+
force_extraction=publish_user_interaction_request.force_extraction,
|
|
353
|
+
skip_aggregation=publish_user_interaction_request.skip_aggregation,
|
|
354
|
+
)
|
|
355
|
+
self._schedule_group_evaluation_if_needed(
|
|
356
|
+
new_request=new_request,
|
|
357
|
+
user_id=user_id,
|
|
358
|
+
agent_version=agent_version,
|
|
359
|
+
source=source,
|
|
360
|
+
)
|
|
361
|
+
record_usage_event(
|
|
362
|
+
org_id=self.org_id,
|
|
363
|
+
user_id=user_id,
|
|
364
|
+
request_id=request_id,
|
|
365
|
+
session_id=new_request.session_id,
|
|
366
|
+
source=source,
|
|
367
|
+
agent_version=agent_version,
|
|
368
|
+
backend="durable",
|
|
369
|
+
event_name="publish_request_succeeded",
|
|
370
|
+
event_category="publish",
|
|
371
|
+
outcome="success",
|
|
372
|
+
count_value=len(new_interactions),
|
|
373
|
+
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
374
|
+
metadata={
|
|
375
|
+
"defer_learning": True,
|
|
376
|
+
"durable_queue": True,
|
|
377
|
+
"warning_count": len(result.warnings),
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
return result
|
|
381
|
+
|
|
312
382
|
from reflexio.server.services.publish_learning_worker import (
|
|
313
383
|
PublishLearningJob,
|
|
314
384
|
enqueue_publish_learning,
|
|
@@ -444,7 +514,16 @@ class GenerationService:
|
|
|
444
514
|
agent_version: str,
|
|
445
515
|
force_extraction: bool,
|
|
446
516
|
skip_aggregation: bool,
|
|
517
|
+
sequential: bool = False,
|
|
447
518
|
) -> GenerationServiceResult:
|
|
519
|
+
"""Run the learning steps for a deferred job.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
sequential: When True, run profile and playbook generation on the
|
|
523
|
+
calling thread (no ThreadPoolExecutor). Required when the
|
|
524
|
+
caller already holds the storage commit_scope lock — spawning
|
|
525
|
+
worker threads inside that scope would deadlock on the RLock.
|
|
526
|
+
"""
|
|
448
527
|
result = GenerationServiceResult(request_id=request_id)
|
|
449
528
|
self._run_learning_steps(
|
|
450
529
|
user_id=user_id,
|
|
@@ -454,6 +533,7 @@ class GenerationService:
|
|
|
454
533
|
skip_aggregation=skip_aggregation,
|
|
455
534
|
source=source,
|
|
456
535
|
result=result,
|
|
536
|
+
parallel=not sequential,
|
|
457
537
|
)
|
|
458
538
|
return result
|
|
459
539
|
|
|
@@ -471,6 +551,7 @@ class GenerationService:
|
|
|
471
551
|
skip_aggregation: bool,
|
|
472
552
|
source: str | None,
|
|
473
553
|
result: GenerationServiceResult,
|
|
554
|
+
parallel: bool = True,
|
|
474
555
|
) -> None:
|
|
475
556
|
# Reflection runs as its own sliding-window step BEFORE the extractor
|
|
476
557
|
# pool spins up, so replacements are visible to extractors.
|
|
@@ -504,43 +585,29 @@ class GenerationService:
|
|
|
504
585
|
force_extraction=force_extraction,
|
|
505
586
|
)
|
|
506
587
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
588
|
+
if not parallel:
|
|
589
|
+
# Sequential mode: run both services on the calling thread.
|
|
590
|
+
# Required when the caller holds a commit_scope lock — spawning
|
|
591
|
+
# child threads inside that scope would deadlock the SQLite RLock.
|
|
592
|
+
for svc_name, _run in (
|
|
593
|
+
(
|
|
594
|
+
"profile_generation",
|
|
595
|
+
lambda: profile_generation_service.run(profile_generation_request),
|
|
514
596
|
),
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
playbook_generation_service.run
|
|
518
|
-
|
|
597
|
+
(
|
|
598
|
+
"playbook_generation",
|
|
599
|
+
lambda: playbook_generation_service.run(
|
|
600
|
+
playbook_generation_request
|
|
601
|
+
),
|
|
519
602
|
),
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
service_names = ["profile_generation", "playbook_generation"]
|
|
523
|
-
for future, service_name in zip(futures, service_names, strict=True):
|
|
603
|
+
):
|
|
524
604
|
try:
|
|
525
|
-
|
|
526
|
-
except FuturesTimeoutError: # noqa: PERF203
|
|
527
|
-
msg = (
|
|
528
|
-
f"{service_name} timed out after "
|
|
529
|
-
f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
|
|
530
|
-
)
|
|
531
|
-
with sentry_tags(
|
|
532
|
-
subsystem="generation",
|
|
533
|
-
service=service_name,
|
|
534
|
-
request_id=request_id,
|
|
535
|
-
error_type="timeout",
|
|
536
|
-
):
|
|
537
|
-
logger.error("%s for request %s", msg, request_id)
|
|
538
|
-
result.warnings.append(msg)
|
|
605
|
+
_run()
|
|
539
606
|
except Exception as e:
|
|
540
|
-
msg = f"{
|
|
607
|
+
msg = f"{svc_name} failed: {e}"
|
|
541
608
|
with sentry_tags(
|
|
542
609
|
subsystem="generation",
|
|
543
|
-
service=
|
|
610
|
+
service=svc_name,
|
|
544
611
|
request_id=request_id,
|
|
545
612
|
error_type=type(e).__name__,
|
|
546
613
|
):
|
|
@@ -549,8 +616,54 @@ class GenerationService:
|
|
|
549
616
|
request_id,
|
|
550
617
|
)
|
|
551
618
|
result.warnings.append(msg)
|
|
552
|
-
|
|
553
|
-
executor
|
|
619
|
+
else:
|
|
620
|
+
executor = ThreadPoolExecutor(max_workers=2)
|
|
621
|
+
try:
|
|
622
|
+
futures = [
|
|
623
|
+
executor.submit(
|
|
624
|
+
contextvars.copy_context().run,
|
|
625
|
+
profile_generation_service.run,
|
|
626
|
+
profile_generation_request,
|
|
627
|
+
),
|
|
628
|
+
executor.submit(
|
|
629
|
+
contextvars.copy_context().run,
|
|
630
|
+
playbook_generation_service.run,
|
|
631
|
+
playbook_generation_request,
|
|
632
|
+
),
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
service_names = ["profile_generation", "playbook_generation"]
|
|
636
|
+
for future, service_name in zip(futures, service_names, strict=True):
|
|
637
|
+
try:
|
|
638
|
+
future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
|
|
639
|
+
except FuturesTimeoutError: # noqa: PERF203
|
|
640
|
+
msg = (
|
|
641
|
+
f"{service_name} timed out after "
|
|
642
|
+
f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
|
|
643
|
+
)
|
|
644
|
+
with sentry_tags(
|
|
645
|
+
subsystem="generation",
|
|
646
|
+
service=service_name,
|
|
647
|
+
request_id=request_id,
|
|
648
|
+
error_type="timeout",
|
|
649
|
+
):
|
|
650
|
+
logger.error("%s for request %s", msg, request_id)
|
|
651
|
+
result.warnings.append(msg)
|
|
652
|
+
except Exception as e:
|
|
653
|
+
msg = f"{service_name} failed: {e}"
|
|
654
|
+
with sentry_tags(
|
|
655
|
+
subsystem="generation",
|
|
656
|
+
service=service_name,
|
|
657
|
+
request_id=request_id,
|
|
658
|
+
error_type=type(e).__name__,
|
|
659
|
+
):
|
|
660
|
+
logger.exception(
|
|
661
|
+
"Generation service failed for request %s",
|
|
662
|
+
request_id,
|
|
663
|
+
)
|
|
664
|
+
result.warnings.append(msg)
|
|
665
|
+
finally:
|
|
666
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
554
667
|
|
|
555
668
|
try:
|
|
556
669
|
schedule_tagging(
|
|
@@ -477,29 +477,43 @@ class ProfileGenerationService(
|
|
|
477
477
|
|
|
478
478
|
def _get_generated_count(
|
|
479
479
|
self,
|
|
480
|
-
request: RerunProfileGenerationRequest,
|
|
480
|
+
request: RerunProfileGenerationRequest | ManualProfileGenerationRequest,
|
|
481
481
|
processed_user_ids: list[str] | None = None,
|
|
482
482
|
) -> int:
|
|
483
|
-
"""Get the count of profiles generated during
|
|
483
|
+
"""Get the count of profiles generated during batch generation.
|
|
484
484
|
|
|
485
|
-
Counts profiles
|
|
485
|
+
Counts PENDING profiles for rerun requests and CURRENT profiles for manual
|
|
486
|
+
regular requests, scoped to users the batch runner actually processed.
|
|
486
487
|
|
|
487
488
|
Args:
|
|
488
|
-
request: The rerun request object
|
|
489
|
+
request: The rerun or manual generation request object
|
|
489
490
|
processed_user_ids: List of user IDs processed in the batch
|
|
490
491
|
|
|
491
492
|
Returns:
|
|
492
493
|
Number of profiles generated
|
|
493
494
|
"""
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
profiles = self.storage.get_user_profile( # type: ignore[reportOptionalMemberAccess]
|
|
498
|
-
user_id=user_id,
|
|
499
|
-
status_filter=[Status.PENDING],
|
|
495
|
+
if isinstance(request, ManualProfileGenerationRequest):
|
|
496
|
+
return self._count_manual_generated(
|
|
497
|
+
request, processed_user_ids=processed_user_ids
|
|
500
498
|
)
|
|
501
|
-
|
|
502
|
-
|
|
499
|
+
|
|
500
|
+
user_ids = self._count_user_ids(request.user_id, processed_user_ids)
|
|
501
|
+
if not user_ids:
|
|
502
|
+
return 0
|
|
503
|
+
return self.storage.count_user_profiles_by_status( # type: ignore[reportOptionalMemberAccess]
|
|
504
|
+
user_ids=user_ids,
|
|
505
|
+
status=Status.PENDING,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
@staticmethod
|
|
509
|
+
def _count_user_ids(
|
|
510
|
+
request_user_id: str | None, processed_user_ids: list[str] | None
|
|
511
|
+
) -> list[str]:
|
|
512
|
+
"""Resolve users to count without treating an empty processed batch as missing."""
|
|
513
|
+
|
|
514
|
+
if processed_user_ids is not None:
|
|
515
|
+
return processed_user_ids
|
|
516
|
+
return [request_user_id] if request_user_id else []
|
|
503
517
|
|
|
504
518
|
# ===============================
|
|
505
519
|
# Upgrade/Downgrade hook implementations (override base class methods)
|
|
@@ -676,16 +690,15 @@ class ProfileGenerationService(
|
|
|
676
690
|
"source": request.source,
|
|
677
691
|
"mode": "manual_regular",
|
|
678
692
|
}
|
|
679
|
-
|
|
693
|
+
# total_profiles is computed inside the batch runner via
|
|
694
|
+
# _get_generated_count(processed_user_ids=...).
|
|
695
|
+
users_processed, total_profiles = self._run_batch_with_progress(
|
|
680
696
|
user_ids=user_ids,
|
|
681
697
|
request=request, # type: ignore[reportArgumentType]
|
|
682
698
|
request_params=request_params,
|
|
683
699
|
state_manager=state_manager,
|
|
684
700
|
)
|
|
685
701
|
|
|
686
|
-
# 3. Count generated profiles (CURRENT status = None)
|
|
687
|
-
total_profiles = self._count_manual_generated(request)
|
|
688
|
-
|
|
689
702
|
return ManualProfileGenerationResponse(
|
|
690
703
|
success=True,
|
|
691
704
|
msg=f"Generated {total_profiles} profiles for {users_processed} user(s)",
|
|
@@ -700,20 +713,29 @@ class ProfileGenerationService(
|
|
|
700
713
|
profiles_generated=0,
|
|
701
714
|
)
|
|
702
715
|
|
|
703
|
-
def _count_manual_generated(
|
|
716
|
+
def _count_manual_generated(
|
|
717
|
+
self,
|
|
718
|
+
request: ManualProfileGenerationRequest,
|
|
719
|
+
processed_user_ids: list[str] | None = None,
|
|
720
|
+
) -> int:
|
|
704
721
|
"""
|
|
705
722
|
Count profiles generated during manual regular generation.
|
|
706
723
|
|
|
707
|
-
Counts profiles with CURRENT status (None)
|
|
724
|
+
Counts profiles with CURRENT status (None) for each processed user.
|
|
708
725
|
|
|
709
726
|
Args:
|
|
710
727
|
request: The manual generation request object
|
|
728
|
+
processed_user_ids: User IDs processed by the manual batch. When
|
|
729
|
+
the request omitted user_id, this prevents passing None through
|
|
730
|
+
to storage methods that require a concrete user.
|
|
711
731
|
|
|
712
732
|
Returns:
|
|
713
733
|
Number of profiles with CURRENT status
|
|
714
734
|
"""
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
735
|
+
user_ids = self._count_user_ids(request.user_id, processed_user_ids)
|
|
736
|
+
if not user_ids:
|
|
737
|
+
return 0
|
|
738
|
+
return self.storage.count_user_profiles_by_status( # type: ignore[reportOptionalMemberAccess]
|
|
739
|
+
user_ids=user_ids,
|
|
740
|
+
status=None,
|
|
718
741
|
)
|
|
719
|
-
return len(profiles)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from ._learning_jobs import SQLiteLearningJobStoreMixin
|
|
1
2
|
from ._base import (
|
|
2
3
|
SQLiteStorageBase,
|
|
3
4
|
_cosine_similarity,
|
|
@@ -48,6 +49,7 @@ from .profiles import InteractionStoreMixin, ProfileSearchMixin, ProfileStoreMix
|
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
class SQLiteStorage(
|
|
52
|
+
SQLiteLearningJobStoreMixin,
|
|
51
53
|
SQLiteAgentRunStoreMixin,
|
|
52
54
|
SQLitePendingToolCallStoreMixin,
|
|
53
55
|
SQLiteRunToolDependencyStoreMixin,
|