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
|
@@ -445,12 +445,17 @@ class ReflexioClient:
|
|
|
445
445
|
explicit retry after reauth or limit reset.
|
|
446
446
|
|
|
447
447
|
Returns:
|
|
448
|
-
PublishUserInteractionResponse: Server response.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
448
|
+
PublishUserInteractionResponse: Server response.
|
|
449
|
+
|
|
450
|
+
In deferred mode (``wait_for_response=False``) the
|
|
451
|
+
response carries ``learning_status="deferred"`` and a
|
|
452
|
+
``request_id``; ``profiles_added``/``playbooks_added`` are
|
|
453
|
+
0 because extraction has not run yet. Poll
|
|
454
|
+
``get_learning_status(request_id)`` for completion.
|
|
455
|
+
|
|
456
|
+
In ``wait_for_response=True`` mode the server waits for
|
|
457
|
+
extraction and the response includes ``request_id``,
|
|
458
|
+
storage routing, and real profile/playbook deltas.
|
|
454
459
|
"""
|
|
455
460
|
if session_id is None or not session_id.strip():
|
|
456
461
|
raise ValueError("session_id is required and cannot be empty")
|
|
@@ -481,6 +486,35 @@ class ReflexioClient:
|
|
|
481
486
|
self._cache.invalidate("get_agent_playbooks")
|
|
482
487
|
return result
|
|
483
488
|
|
|
489
|
+
def get_learning_status(self, request_id: str) -> str:
|
|
490
|
+
"""Poll the learning status for a previously published request.
|
|
491
|
+
|
|
492
|
+
Call this after a deferred ``publish_interaction`` (where
|
|
493
|
+
``wait_for_response=False``). The response carries
|
|
494
|
+
``learning_status="deferred"`` to signal that extraction has been
|
|
495
|
+
queued; use this method to track progress once the durable queue
|
|
496
|
+
is active.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
request_id: The ``request_id`` of the published interaction, as
|
|
500
|
+
returned in ``PublishUserInteractionResponse.request_id``
|
|
501
|
+
(populated on both the deferred and ``wait_for_response=True``
|
|
502
|
+
paths) or known by the caller.
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
One of: ``"pending"`` | ``"processing"`` | ``"done"`` | ``"failed"``.
|
|
506
|
+
|
|
507
|
+
Raises:
|
|
508
|
+
requests.HTTPError: 404 when the request_id is not known to the
|
|
509
|
+
server for this org.
|
|
510
|
+
"""
|
|
511
|
+
response = self._make_request(
|
|
512
|
+
"GET",
|
|
513
|
+
"/api/learning_status",
|
|
514
|
+
params={"request_id": request_id},
|
|
515
|
+
)
|
|
516
|
+
return str(response["status"])
|
|
517
|
+
|
|
484
518
|
def search_interactions(
|
|
485
519
|
self,
|
|
486
520
|
request: SearchInteractionRequest | dict | None = None,
|
|
@@ -123,6 +123,7 @@ __all__ = [
|
|
|
123
123
|
"LineageEvent",
|
|
124
124
|
"LineageContext",
|
|
125
125
|
"RecordRef",
|
|
126
|
+
"LearningStatusResponse",
|
|
126
127
|
]
|
|
127
128
|
|
|
128
129
|
# ===============================
|
|
@@ -689,6 +690,23 @@ class PublishUserInteractionResponse(BaseModel):
|
|
|
689
690
|
profiles_updated: int | None = None
|
|
690
691
|
playbooks_added: int | None = None
|
|
691
692
|
playbooks_updated: int | None = None
|
|
693
|
+
# Set to "deferred" when the server queued extraction asynchronously.
|
|
694
|
+
# None on the sync (wait_for_response=True) path. Poll GET
|
|
695
|
+
# /api/learning_status?request_id=... to track progress once the
|
|
696
|
+
# durable queue is active.
|
|
697
|
+
learning_status: str | None = None
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class LearningStatusResponse(BaseModel):
|
|
701
|
+
"""Response for GET /api/learning_status.
|
|
702
|
+
|
|
703
|
+
Attributes:
|
|
704
|
+
status: One of ``pending | processing | done | failed``.
|
|
705
|
+
Coverage-based: reflects whether a durable learning job has
|
|
706
|
+
processed through the request's creation timestamp.
|
|
707
|
+
"""
|
|
708
|
+
|
|
709
|
+
status: Literal["pending", "processing", "done", "failed"]
|
|
692
710
|
|
|
693
711
|
|
|
694
712
|
# whoami response — caller identity + resolved storage routing (masked)
|
|
@@ -345,6 +345,9 @@ def create_app(
|
|
|
345
345
|
)
|
|
346
346
|
from reflexio.server.extensions import AppContext
|
|
347
347
|
from reflexio.server.llm.model_defaults import validate_llm_availability
|
|
348
|
+
from reflexio.server.services.durable_learning import (
|
|
349
|
+
maybe_start_durable_learning,
|
|
350
|
+
)
|
|
348
351
|
from reflexio.server.services.extraction.resume_scheduler import (
|
|
349
352
|
maybe_start_resume_scheduler,
|
|
350
353
|
)
|
|
@@ -356,6 +359,7 @@ def create_app(
|
|
|
356
359
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001
|
|
357
360
|
scheduler = None
|
|
358
361
|
gc_scheduler = None
|
|
362
|
+
durable_learning_scheduler = None
|
|
359
363
|
started_caps: list = []
|
|
360
364
|
if mounts_data_plane:
|
|
361
365
|
log_publish_hardware_capacity()
|
|
@@ -376,6 +380,14 @@ def create_app(
|
|
|
376
380
|
lambda org_id: RequestContext(org_id=org_id),
|
|
377
381
|
bootstrap_org_id=bootstrap_org_id,
|
|
378
382
|
)
|
|
383
|
+
# Durable learning drains the learning_jobs queue per org. Gated on
|
|
384
|
+
# REFLEXIO_DURABLE_LEARNING_QUEUE; the default provider discovers
|
|
385
|
+
# orgs-with-work via the bootstrap storage (single-ref). Enterprise
|
|
386
|
+
# cross-ref fan-out is a deferred follow-up.
|
|
387
|
+
durable_learning_scheduler = maybe_start_durable_learning(
|
|
388
|
+
lambda org_id: RequestContext(org_id=org_id),
|
|
389
|
+
bootstrap_org_id=bootstrap_org_id,
|
|
390
|
+
)
|
|
379
391
|
try:
|
|
380
392
|
if capabilities is not None:
|
|
381
393
|
ctx = (
|
|
@@ -397,10 +409,9 @@ def create_app(
|
|
|
397
409
|
cap,
|
|
398
410
|
exc_info=True,
|
|
399
411
|
)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
gc_scheduler.stop()
|
|
412
|
+
for sched in (scheduler, gc_scheduler, durable_learning_scheduler):
|
|
413
|
+
if sched is not None:
|
|
414
|
+
sched.stop()
|
|
404
415
|
from reflexio.server.services.publish_learning_worker import (
|
|
405
416
|
stop_publish_learning_worker,
|
|
406
417
|
)
|
|
@@ -523,7 +523,7 @@ class TextGenerationMixin:
|
|
|
523
523
|
|
|
524
524
|
# The loop only exits with a drained result (every no-result path
|
|
525
525
|
# above raises); the assert makes that invariant explicit for pyright.
|
|
526
|
-
assert result is not None
|
|
526
|
+
assert result is not None # noqa: S101
|
|
527
527
|
status, payload = result
|
|
528
528
|
# The payload is drained, so the feeder is unblocked and the child can
|
|
529
529
|
# exit. Reap it (terminating if it lingers) to avoid a zombie.
|
|
@@ -18,9 +18,23 @@ from __future__ import annotations
|
|
|
18
18
|
import importlib.util
|
|
19
19
|
import logging
|
|
20
20
|
import os
|
|
21
|
+
import shutil
|
|
21
22
|
import threading
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from pathlib import Path
|
|
22
26
|
from typing import Any
|
|
23
27
|
|
|
28
|
+
try:
|
|
29
|
+
import fcntl
|
|
30
|
+
except ImportError: # pragma: no cover - Windows only
|
|
31
|
+
fcntl = None # type: ignore[assignment]
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import msvcrt
|
|
35
|
+
except ImportError: # pragma: no cover - POSIX only
|
|
36
|
+
msvcrt = None # type: ignore[assignment]
|
|
37
|
+
|
|
24
38
|
_LOGGER = logging.getLogger(__name__)
|
|
25
39
|
|
|
26
40
|
_ENV_ENABLE = "CLAUDE_SMART_USE_LOCAL_EMBEDDING"
|
|
@@ -36,6 +50,17 @@ _TARGET_DIM = 512
|
|
|
36
50
|
# ValueError that ONNXMiniLM_L6_V2 throws on over-length input.
|
|
37
51
|
_MAX_CHARS = 800
|
|
38
52
|
|
|
53
|
+
# Chroma keeps this file list private inside ONNXMiniLM_L6_V2's download helper.
|
|
54
|
+
# Keep this in sync with chromadb's all-MiniLM-L6-v2 extracted archive layout.
|
|
55
|
+
_MINILM_EXPECTED_FILES = (
|
|
56
|
+
"config.json",
|
|
57
|
+
"model.onnx",
|
|
58
|
+
"special_tokens_map.json",
|
|
59
|
+
"tokenizer_config.json",
|
|
60
|
+
"tokenizer.json",
|
|
61
|
+
"vocab.txt",
|
|
62
|
+
)
|
|
63
|
+
|
|
39
64
|
|
|
40
65
|
class LocalEmbedderError(RuntimeError):
|
|
41
66
|
"""Raised when the local embedder is called without chromadb installed."""
|
|
@@ -98,9 +123,54 @@ class LocalEmbedder:
|
|
|
98
123
|
"""
|
|
99
124
|
ef = self._load()
|
|
100
125
|
safe_inputs = [(text or "")[:_MAX_CHARS] for text in texts]
|
|
101
|
-
|
|
126
|
+
try:
|
|
127
|
+
raw = ef(safe_inputs)
|
|
128
|
+
except Exception as exc: # noqa: BLE001 - Chroma raises varied cache errors.
|
|
129
|
+
raw = self._retry_embed_after_cache_clear(ef, exc, safe_inputs)
|
|
102
130
|
return [_pad(vec) for vec in raw]
|
|
103
131
|
|
|
132
|
+
def _retry_embed_after_cache_clear(
|
|
133
|
+
self, failed_ef: Any, exc: Exception, safe_inputs: list[str]
|
|
134
|
+
) -> Any:
|
|
135
|
+
with self._ef_lock:
|
|
136
|
+
if self._ef is not None and self._ef is not failed_ef:
|
|
137
|
+
return self._ef(safe_inputs)
|
|
138
|
+
|
|
139
|
+
embedding_cls = type(failed_ef)
|
|
140
|
+
recovery = _recoverable_minilm_cache(embedding_cls, exc)
|
|
141
|
+
if recovery is None:
|
|
142
|
+
raise exc
|
|
143
|
+
cache_path, cache_was_complete = recovery
|
|
144
|
+
|
|
145
|
+
with _exclusive_file_lock(_minilm_cache_lock_path(cache_path)):
|
|
146
|
+
if _should_clear_minilm_cache(
|
|
147
|
+
embedding_cls, cache_path, exc, cache_was_complete
|
|
148
|
+
):
|
|
149
|
+
shutil.rmtree(cache_path, ignore_errors=True)
|
|
150
|
+
recovery_action = "clearing"
|
|
151
|
+
_LOGGER.warning(
|
|
152
|
+
"Failed to use local MiniLM embedder; cleared cache %s "
|
|
153
|
+
"and retrying once",
|
|
154
|
+
cache_path,
|
|
155
|
+
exc_info=True,
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
recovery_action = "waiting for another process to refresh"
|
|
159
|
+
_LOGGER.info(
|
|
160
|
+
"MiniLM cache %s was refreshed by another process; retrying",
|
|
161
|
+
cache_path,
|
|
162
|
+
)
|
|
163
|
+
try:
|
|
164
|
+
fresh_ef = embedding_cls()
|
|
165
|
+
self._ef = fresh_ef
|
|
166
|
+
return fresh_ef(safe_inputs)
|
|
167
|
+
except Exception as retry_exc:
|
|
168
|
+
raise LocalEmbedderError(
|
|
169
|
+
"Local MiniLM cache recovery failed after "
|
|
170
|
+
f"{recovery_action} {cache_path}. Delete this cache "
|
|
171
|
+
"directory, restart Reflexio, and retry local embedding."
|
|
172
|
+
) from retry_exc
|
|
173
|
+
|
|
104
174
|
|
|
105
175
|
def _pad(vec: Any) -> list[float]:
|
|
106
176
|
"""Zero-pad a 384-dim vector to ``_TARGET_DIM`` as a plain list[float]."""
|
|
@@ -113,6 +183,118 @@ def _pad(vec: Any) -> list[float]:
|
|
|
113
183
|
return floats + [0.0] * (_TARGET_DIM - len(floats))
|
|
114
184
|
|
|
115
185
|
|
|
186
|
+
def _recoverable_minilm_cache(
|
|
187
|
+
embedding_cls: Any, exc: Exception
|
|
188
|
+
) -> tuple[Path, bool] | None:
|
|
189
|
+
cache_path = _minilm_cache_path(embedding_cls)
|
|
190
|
+
if cache_path is None:
|
|
191
|
+
return None
|
|
192
|
+
cache_was_complete = _minilm_cache_complete(embedding_cls, cache_path)
|
|
193
|
+
if cache_was_complete and not _complete_minilm_cache_error_identifies_cache(
|
|
194
|
+
embedding_cls, cache_path, exc
|
|
195
|
+
):
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
return cache_path, cache_was_complete
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _minilm_cache_path(embedding_cls: Any) -> Path | None:
|
|
202
|
+
download_path = getattr(embedding_cls, "DOWNLOAD_PATH", None)
|
|
203
|
+
if not download_path:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
model_name = getattr(embedding_cls, "MODEL_NAME", None)
|
|
207
|
+
cache_path = Path(str(download_path)).expanduser()
|
|
208
|
+
if not model_name or cache_path.name != model_name:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
return cache_path
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _minilm_cache_lock_path(cache_path: Path) -> Path:
|
|
215
|
+
return cache_path.parent / f".{cache_path.name}.lock"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@contextmanager
|
|
219
|
+
def _exclusive_file_lock(lock_path: Path) -> Iterator[None]:
|
|
220
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
with lock_path.open("a+b") as lock_file:
|
|
222
|
+
lock_file.seek(0)
|
|
223
|
+
if lock_file.read(1) == b"":
|
|
224
|
+
lock_file.write(b"\0")
|
|
225
|
+
lock_file.flush()
|
|
226
|
+
lock_file.seek(0)
|
|
227
|
+
|
|
228
|
+
if fcntl is not None:
|
|
229
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
230
|
+
try:
|
|
231
|
+
yield
|
|
232
|
+
finally:
|
|
233
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
234
|
+
elif msvcrt is not None:
|
|
235
|
+
msvcrt.locking( # type: ignore[reportAttributeAccessIssue]
|
|
236
|
+
lock_file.fileno(),
|
|
237
|
+
msvcrt.LK_LOCK, # type: ignore[reportAttributeAccessIssue]
|
|
238
|
+
1,
|
|
239
|
+
)
|
|
240
|
+
try:
|
|
241
|
+
yield
|
|
242
|
+
finally:
|
|
243
|
+
lock_file.seek(0)
|
|
244
|
+
msvcrt.locking( # type: ignore[reportAttributeAccessIssue]
|
|
245
|
+
lock_file.fileno(),
|
|
246
|
+
msvcrt.LK_UNLCK, # type: ignore[reportAttributeAccessIssue]
|
|
247
|
+
1,
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
yield
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _should_clear_minilm_cache(
|
|
254
|
+
embedding_cls: Any, cache_path: Path, exc: Exception, cache_was_complete: bool
|
|
255
|
+
) -> bool:
|
|
256
|
+
cache_is_complete = _minilm_cache_complete(embedding_cls, cache_path)
|
|
257
|
+
if not cache_is_complete:
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
if not cache_was_complete:
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
return _complete_minilm_cache_error_identifies_cache(embedding_cls, cache_path, exc)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _complete_minilm_cache_error_identifies_cache(
|
|
267
|
+
embedding_cls: Any, cache_path: Path, exc: Exception
|
|
268
|
+
) -> bool:
|
|
269
|
+
chain = _exception_chain(exc)
|
|
270
|
+
archive_name = str(getattr(embedding_cls, "ARCHIVE_FILENAME", ""))
|
|
271
|
+
messages = "\n".join(str(chain_exc) for chain_exc in chain)
|
|
272
|
+
return (
|
|
273
|
+
str(cache_path) in messages
|
|
274
|
+
or bool(archive_name and archive_name in messages)
|
|
275
|
+
or "does not match expected SHA256" in messages
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _minilm_cache_complete(embedding_cls: Any, cache_path: Path) -> bool:
|
|
280
|
+
extracted_folder = str(getattr(embedding_cls, "EXTRACTED_FOLDER_NAME", "onnx"))
|
|
281
|
+
return all(
|
|
282
|
+
(cache_path / extracted_folder / file_name).exists()
|
|
283
|
+
for file_name in _MINILM_EXPECTED_FILES
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _exception_chain(exc: Exception) -> list[BaseException]:
|
|
288
|
+
chain: list[BaseException] = []
|
|
289
|
+
seen: set[int] = set()
|
|
290
|
+
current: BaseException | None = exc
|
|
291
|
+
while current is not None and id(current) not in seen:
|
|
292
|
+
seen.add(id(current))
|
|
293
|
+
chain.append(current)
|
|
294
|
+
current = current.__cause__ or current.__context__
|
|
295
|
+
return chain
|
|
296
|
+
|
|
297
|
+
|
|
116
298
|
_REGISTERED = False
|
|
117
299
|
|
|
118
300
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Interaction route handlers (extracted from api.py, Tier3 A2)."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import uuid
|
|
4
5
|
from typing import TYPE_CHECKING
|
|
5
6
|
|
|
6
7
|
if TYPE_CHECKING:
|
|
@@ -10,6 +11,7 @@ from fastapi import (
|
|
|
10
11
|
APIRouter,
|
|
11
12
|
BackgroundTasks,
|
|
12
13
|
Depends,
|
|
14
|
+
HTTPException,
|
|
13
15
|
Request,
|
|
14
16
|
)
|
|
15
17
|
|
|
@@ -30,6 +32,7 @@ from reflexio.models.api_schema.service_schemas import (
|
|
|
30
32
|
DeleteSessionResponse,
|
|
31
33
|
DeleteUserInteractionRequest,
|
|
32
34
|
DeleteUserInteractionResponse,
|
|
35
|
+
LearningStatusResponse,
|
|
33
36
|
PublishUserInteractionRequest,
|
|
34
37
|
PublishUserInteractionResponse,
|
|
35
38
|
)
|
|
@@ -79,6 +82,14 @@ def publish_user_interaction(
|
|
|
79
82
|
),
|
|
80
83
|
)
|
|
81
84
|
|
|
85
|
+
# Resolve the request_id BEFORE backgrounding so we can return it to the
|
|
86
|
+
# caller for polling. GenerationService uses a caller-supplied request_id
|
|
87
|
+
# verbatim (and generates one only when absent), so pinning it on the
|
|
88
|
+
# payload guarantees the background task stores its status under the same
|
|
89
|
+
# id we hand back here.
|
|
90
|
+
request_id = payload.request_id or str(uuid.uuid4())
|
|
91
|
+
payload.request_id = request_id
|
|
92
|
+
|
|
82
93
|
def _publish_task() -> None:
|
|
83
94
|
try:
|
|
84
95
|
publisher_api.add_user_interaction(
|
|
@@ -89,10 +100,16 @@ def publish_user_interaction(
|
|
|
89
100
|
except Exception:
|
|
90
101
|
logger.exception("Background publish failed for org %s", org_id)
|
|
91
102
|
|
|
92
|
-
# Run in background — caller gets immediate acknowledgement
|
|
103
|
+
# Run in background — caller gets immediate acknowledgement.
|
|
104
|
+
# learning_status="deferred" tells the caller that extraction has not yet
|
|
105
|
+
# run; they can poll GET /api/learning_status?request_id=... (using the
|
|
106
|
+
# request_id returned here) to track it.
|
|
93
107
|
background_tasks.add_task(_publish_task)
|
|
94
108
|
return PublishUserInteractionResponse(
|
|
95
|
-
success=True,
|
|
109
|
+
success=True,
|
|
110
|
+
message="Interaction queued for processing",
|
|
111
|
+
request_id=request_id,
|
|
112
|
+
learning_status="deferred",
|
|
96
113
|
)
|
|
97
114
|
|
|
98
115
|
|
|
@@ -257,3 +274,47 @@ def get_requests_endpoint(
|
|
|
257
274
|
has_more=internal_response.has_more,
|
|
258
275
|
msg=internal_response.msg,
|
|
259
276
|
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@router.get(
|
|
280
|
+
"/api/learning_status",
|
|
281
|
+
response_model=LearningStatusResponse,
|
|
282
|
+
response_model_exclude_none=True,
|
|
283
|
+
)
|
|
284
|
+
@limiter.limit("120/minute")
|
|
285
|
+
def get_learning_status(
|
|
286
|
+
request: Request,
|
|
287
|
+
request_id: str,
|
|
288
|
+
org_id: str = Depends(default_get_org_id),
|
|
289
|
+
) -> LearningStatusResponse:
|
|
290
|
+
"""Return the coverage-based learning status for a published request.
|
|
291
|
+
|
|
292
|
+
The status reflects whether a durable learning job has processed through
|
|
293
|
+
the request's creation timestamp:
|
|
294
|
+
|
|
295
|
+
- ``pending``: not yet picked up.
|
|
296
|
+
- ``processing``: a worker currently holds the job.
|
|
297
|
+
- ``done``: at least one completed job covers this request.
|
|
298
|
+
- ``failed``: a dead job covers this request and no done job does.
|
|
299
|
+
|
|
300
|
+
Note: this endpoint reads ``learning_jobs`` rows written by the durable
|
|
301
|
+
queue. When the durable queue is OFF (in-memory deferred path) it returns
|
|
302
|
+
absence-based status (``pending`` for recent requests, ``done`` for old
|
|
303
|
+
ones) — acceptable for v1; the poll contract is tied to the durable queue.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
HTTPException: 404 when ``request_id`` is not found for this org.
|
|
307
|
+
Never reports ``done`` for a request that never existed.
|
|
308
|
+
"""
|
|
309
|
+
reflexio = reflexio_cache.get_reflexio(org_id=org_id)
|
|
310
|
+
storage = reflexio.request_context.storage
|
|
311
|
+
if storage is None:
|
|
312
|
+
raise HTTPException(status_code=503, detail="Storage not configured")
|
|
313
|
+
req = storage.get_request(request_id)
|
|
314
|
+
if req is None:
|
|
315
|
+
raise HTTPException(status_code=404, detail="request not found")
|
|
316
|
+
status = storage.get_learning_status_for_request(
|
|
317
|
+
user_id=req.user_id,
|
|
318
|
+
request_created_at=float(req.created_at),
|
|
319
|
+
)
|
|
320
|
+
return LearningStatusResponse(status=status)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Durable learning worker: claim → process in commit_scope → fenced completion."""
|
|
2
|
+
|
|
3
|
+
from reflexio.server.services.durable_learning.scheduler import (
|
|
4
|
+
DurableLearningScheduler,
|
|
5
|
+
maybe_start_durable_learning,
|
|
6
|
+
)
|
|
7
|
+
from reflexio.server.services.durable_learning.worker import DurableLearningWorker
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"DurableLearningScheduler",
|
|
11
|
+
"DurableLearningWorker",
|
|
12
|
+
"maybe_start_durable_learning",
|
|
13
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Process-local scheduler for the durable learning-job queue (Task 6).
|
|
2
|
+
|
|
3
|
+
Each tick discovers the orgs with actionable learning jobs (via an injected
|
|
4
|
+
``org_ids_provider``) and drains each through a :class:`DurableLearningWorker`.
|
|
5
|
+
The worker's claims are org-scoped and the fenced complete/fail transitions make
|
|
6
|
+
the drain exactly-once, so racing scheduler instances across processes are safe.
|
|
7
|
+
|
|
8
|
+
Gating: startup is gated on ``REFLEXIO_DURABLE_LEARNING_QUEUE`` (a rollout flag,
|
|
9
|
+
scalability design 2026-07-04 §8 stage 2). When off, :meth:`start` is a no-op.
|
|
10
|
+
|
|
11
|
+
Multi-ref capability (design §3.1, per-ref polling): the mechanism supports
|
|
12
|
+
per-data-ref fan-out — inject an ``org_ids_provider`` that yields orgs across
|
|
13
|
+
refs plus a ``request_context_factory`` that routes each org to its ref's
|
|
14
|
+
storage. The default provider (single-ref: bootstrap-storage discovery) is wired
|
|
15
|
+
in :func:`maybe_start_durable_learning`; the enterprise cross-ref enumerator is a
|
|
16
|
+
deferred follow-up (tracked with the pre-Task-8 gates).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from collections.abc import Callable, Iterable
|
|
23
|
+
|
|
24
|
+
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
25
|
+
from reflexio.server.env_utils import env_str, env_truthy
|
|
26
|
+
from reflexio.server.scheduling import ThreadedScheduler
|
|
27
|
+
from reflexio.server.services.durable_learning.worker import DurableLearningWorker
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DurableLearningScheduler(ThreadedScheduler):
|
|
33
|
+
"""Polling daemon that drains the durable learning queue per org.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
request_context_factory: Builds an org-scoped :class:`RequestContext`.
|
|
37
|
+
Passed straight to the worker; a multi-ref deployment routes each org
|
|
38
|
+
to its data ref's storage here.
|
|
39
|
+
org_ids_provider: Called once per tick; yields the org_ids to drain. The
|
|
40
|
+
single-ref default (see :func:`maybe_start_durable_learning`) reads
|
|
41
|
+
them from the bootstrap storage's cross-org discovery query.
|
|
42
|
+
instance_id: Stable label written to ``claimed_by`` on each claim.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
request_context_factory: Callable[[str], RequestContext],
|
|
49
|
+
org_ids_provider: Callable[[], Iterable[str]],
|
|
50
|
+
instance_id: str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(thread_name="reflexio-durable-learning-scheduler")
|
|
53
|
+
self._worker = DurableLearningWorker(
|
|
54
|
+
request_context_factory, instance_id=instance_id
|
|
55
|
+
)
|
|
56
|
+
self._org_ids_provider = org_ids_provider
|
|
57
|
+
# Read env INSIDE __init__ so tests can set the values before construction.
|
|
58
|
+
self._poll = float(env_str("REFLEXIO_DURABLE_LEARNING_POLL_SECONDS", "2.0"))
|
|
59
|
+
self._batch = int(env_str("REFLEXIO_DURABLE_LEARNING_BATCH", "16"))
|
|
60
|
+
self._lease = int(env_str("REFLEXIO_DURABLE_LEARNING_LEASE_SECONDS", "300"))
|
|
61
|
+
|
|
62
|
+
def _should_start(self) -> bool:
|
|
63
|
+
if env_truthy(env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")):
|
|
64
|
+
return True
|
|
65
|
+
logger.info(
|
|
66
|
+
"event=durable_learning_scheduler_disabled "
|
|
67
|
+
"REFLEXIO_DURABLE_LEARNING_QUEUE not set — not starting"
|
|
68
|
+
)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def _on_started(self) -> None:
|
|
72
|
+
logger.info("event=durable_learning_scheduler_started")
|
|
73
|
+
|
|
74
|
+
def _on_stopped(self) -> None:
|
|
75
|
+
logger.info("event=durable_learning_scheduler_stopped")
|
|
76
|
+
|
|
77
|
+
def _run_once(self) -> float:
|
|
78
|
+
"""Drain every discovered org once; per-org error isolation.
|
|
79
|
+
|
|
80
|
+
A single org raising never aborts the tick, and a failure to enumerate
|
|
81
|
+
orgs never kills the daemon thread. Returns the poll interval.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
for org_id in self._org_ids_provider():
|
|
85
|
+
if self._stop_event.is_set():
|
|
86
|
+
break
|
|
87
|
+
try:
|
|
88
|
+
self._worker.drain_org(org_id, self._batch, self._lease)
|
|
89
|
+
except Exception:
|
|
90
|
+
logger.exception(
|
|
91
|
+
"event=durable_learning_drain_failed org_id=%s", org_id
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
logger.exception("event=durable_learning_tick_failed")
|
|
95
|
+
return self._poll
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def maybe_start_durable_learning(
|
|
99
|
+
request_context_factory: Callable[[str], RequestContext],
|
|
100
|
+
*,
|
|
101
|
+
bootstrap_org_id: str,
|
|
102
|
+
org_ids_provider: Callable[[], Iterable[str]] | None = None,
|
|
103
|
+
) -> DurableLearningScheduler | None:
|
|
104
|
+
"""Start the durable-learning scheduler when the rollout flag is on.
|
|
105
|
+
|
|
106
|
+
Returns ``None`` when ``REFLEXIO_DURABLE_LEARNING_QUEUE`` is off (mirrors
|
|
107
|
+
``maybe_start_resume_scheduler``). The default ``org_ids_provider`` performs
|
|
108
|
+
single-ref discovery: it queries the bootstrap org's storage for every org
|
|
109
|
+
with actionable work (covers OSS/local and the shared managed DATA ref). The
|
|
110
|
+
enterprise cross-ref enumerator is a deferred follow-up.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
request_context_factory: Builds an org-scoped :class:`RequestContext`.
|
|
114
|
+
bootstrap_org_id: Org used to reach a storage for cross-org discovery.
|
|
115
|
+
org_ids_provider: Optional explicit provider (e.g. a future enterprise
|
|
116
|
+
cross-ref enumerator); when ``None`` the single-ref default is used.
|
|
117
|
+
"""
|
|
118
|
+
if not env_truthy(env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")):
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def _default_provider() -> list[str]:
|
|
122
|
+
try:
|
|
123
|
+
ctx = request_context_factory(bootstrap_org_id)
|
|
124
|
+
storage = getattr(ctx, "storage", None)
|
|
125
|
+
if storage is None:
|
|
126
|
+
return []
|
|
127
|
+
return list(storage.list_org_ids_with_pending_learning_jobs())
|
|
128
|
+
except NotImplementedError:
|
|
129
|
+
return []
|
|
130
|
+
except Exception:
|
|
131
|
+
logger.exception(
|
|
132
|
+
"event=durable_learning_discovery_failed bootstrap_org_id=%s",
|
|
133
|
+
bootstrap_org_id,
|
|
134
|
+
)
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
scheduler = DurableLearningScheduler(
|
|
138
|
+
request_context_factory=request_context_factory,
|
|
139
|
+
org_ids_provider=org_ids_provider or _default_provider,
|
|
140
|
+
)
|
|
141
|
+
scheduler.start()
|
|
142
|
+
return scheduler
|