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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""In-memory record of search results already served to a session.
|
|
2
|
+
|
|
3
|
+
When a unified search request carries a ``session_id``, results returned for
|
|
4
|
+
that ``(org_id, session_id)`` are remembered here so later searches in the
|
|
5
|
+
same session can skip items the session has already received and surface the
|
|
6
|
+
next-best matches instead. The session is the dedup scope: concurrent
|
|
7
|
+
sessions never affect each other, and a request without a ``session_id``
|
|
8
|
+
neither reads nor writes this cache.
|
|
9
|
+
|
|
10
|
+
The cache is deliberately process-local and best-effort. Losing it (restart,
|
|
11
|
+
another replica) only means an item may be served to a session twice — the
|
|
12
|
+
pre-dedup behavior. The time- and size-based limits below are memory bounds,
|
|
13
|
+
not dedup semantics: within a live session an item stays suppressed for the
|
|
14
|
+
session's lifetime.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from collections import OrderedDict
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
# Entity key: (entity_kind, entity_id) with entity_kind one of
|
|
26
|
+
# "profile" | "user_playbook" | "agent_playbook" and entity_id stringified.
|
|
27
|
+
EntityKey = tuple[str, str]
|
|
28
|
+
|
|
29
|
+
_MAX_SESSIONS = 1024
|
|
30
|
+
_MAX_ENTRIES_PER_SESSION = 2000
|
|
31
|
+
_IDLE_EVICTION_SECONDS = 12 * 60 * 60
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _SessionEntry:
|
|
36
|
+
last_access: float
|
|
37
|
+
# OrderedDict used as an insertion-ordered set for FIFO eviction.
|
|
38
|
+
entries: OrderedDict[EntityKey, None] = field(default_factory=OrderedDict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SessionSeenCache:
|
|
42
|
+
"""Bounded per-(org, session) set of entity keys already served.
|
|
43
|
+
|
|
44
|
+
Bounds: least-recently-used session eviction beyond ``max_sessions``,
|
|
45
|
+
FIFO entry eviction beyond ``max_entries_per_session``, and lazy removal
|
|
46
|
+
of sessions idle longer than ``idle_eviction_seconds``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
max_sessions: int = _MAX_SESSIONS,
|
|
52
|
+
max_entries_per_session: int = _MAX_ENTRIES_PER_SESSION,
|
|
53
|
+
idle_eviction_seconds: float = _IDLE_EVICTION_SECONDS,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
self._max_sessions = max_sessions
|
|
57
|
+
self._max_entries_per_session = max_entries_per_session
|
|
58
|
+
self._idle_eviction_seconds = idle_eviction_seconds
|
|
59
|
+
self._sessions: OrderedDict[tuple[str, str], _SessionEntry] = OrderedDict()
|
|
60
|
+
|
|
61
|
+
def seen(self, org_id: str, session_id: str) -> frozenset[EntityKey]:
|
|
62
|
+
"""Return the entity keys already served to this org's session.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
org_id (str): Organization the session belongs to
|
|
66
|
+
session_id (str): Session identifier from the search request
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
frozenset[EntityKey]: Keys previously recorded for the session
|
|
70
|
+
"""
|
|
71
|
+
now = time.monotonic()
|
|
72
|
+
key = (org_id, session_id)
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._evict_idle(now)
|
|
75
|
+
entry = self._sessions.get(key)
|
|
76
|
+
if entry is None:
|
|
77
|
+
return frozenset()
|
|
78
|
+
entry.last_access = now
|
|
79
|
+
self._sessions.move_to_end(key)
|
|
80
|
+
return frozenset(entry.entries)
|
|
81
|
+
|
|
82
|
+
def record(self, org_id: str, session_id: str, keys: Iterable[EntityKey]) -> None:
|
|
83
|
+
"""Record entity keys as served to this org's session.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
org_id (str): Organization the session belongs to
|
|
87
|
+
session_id (str): Session identifier from the search request
|
|
88
|
+
keys (Iterable[EntityKey]): Entity keys returned to the caller
|
|
89
|
+
"""
|
|
90
|
+
now = time.monotonic()
|
|
91
|
+
session_key = (org_id, session_id)
|
|
92
|
+
entity_keys = list(keys)
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._evict_idle(now)
|
|
95
|
+
entry = self._sessions.get(session_key)
|
|
96
|
+
if entry is None:
|
|
97
|
+
if not entity_keys:
|
|
98
|
+
# Nothing to remember: don't let empty results consume a
|
|
99
|
+
# session slot and evict a live session's seen-state.
|
|
100
|
+
return
|
|
101
|
+
entry = _SessionEntry(last_access=now)
|
|
102
|
+
self._sessions[session_key] = entry
|
|
103
|
+
entry.last_access = now
|
|
104
|
+
self._sessions.move_to_end(session_key)
|
|
105
|
+
for entity_key in entity_keys:
|
|
106
|
+
entry.entries[entity_key] = None
|
|
107
|
+
while len(entry.entries) > self._max_entries_per_session:
|
|
108
|
+
entry.entries.popitem(last=False)
|
|
109
|
+
while len(self._sessions) > self._max_sessions:
|
|
110
|
+
self._sessions.popitem(last=False)
|
|
111
|
+
|
|
112
|
+
def clear(self) -> None:
|
|
113
|
+
"""Drop all sessions (test hook)."""
|
|
114
|
+
with self._lock:
|
|
115
|
+
self._sessions.clear()
|
|
116
|
+
|
|
117
|
+
def _evict_idle(self, now: float) -> None:
|
|
118
|
+
# Sessions are LRU-ordered, so idle ones cluster at the front.
|
|
119
|
+
while self._sessions:
|
|
120
|
+
_, entry = next(iter(self._sessions.items()))
|
|
121
|
+
if now - entry.last_access <= self._idle_eviction_seconds:
|
|
122
|
+
break
|
|
123
|
+
self._sessions.popitem(last=False)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Process-wide instance used by the unified search service.
|
|
127
|
+
session_seen_cache = SessionSeenCache()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Temporal post-processing for search results.
|
|
2
|
+
|
|
3
|
+
Applies query-derived temporal signals (extracted by the query reformulator
|
|
4
|
+
alongside the rewrite — see ``ReformulationResult``) to ranked entity lists:
|
|
5
|
+
|
|
6
|
+
- ``window_bounds``: relative day offsets → absolute datetimes for the
|
|
7
|
+
per-arm ``start_time``/``end_time`` SQL filters.
|
|
8
|
+
- ``freshness_collapse``: within near-duplicate groups of competing facts,
|
|
9
|
+
the freshest wins — deterministically fixing "stale fact with the same
|
|
10
|
+
wording outranks its fresh update", which LLM ordering alone misses.
|
|
11
|
+
- ``sort_by_recency``: absolute timestamp ordering for "current/latest X"
|
|
12
|
+
questions.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from datetime import UTC, datetime, timedelta
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from reflexio.server.services.profile.profile_generation_service_utils import (
|
|
22
|
+
check_string_token_overlap,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_DUPLICATE_OVERLAP_THRESHOLD = 0.6
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def entity_timestamp(entity: Any) -> int:
|
|
29
|
+
"""The entity's primary timestamp (0 when missing).
|
|
30
|
+
|
|
31
|
+
Profiles carry ``last_modified_timestamp``; playbooks carry
|
|
32
|
+
``created_at`` — same fields the recency decay reads.
|
|
33
|
+
"""
|
|
34
|
+
return int(
|
|
35
|
+
getattr(entity, "last_modified_timestamp", None)
|
|
36
|
+
or getattr(entity, "created_at", None)
|
|
37
|
+
or 0
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def window_bounds(
|
|
42
|
+
start_days_ago: float | None,
|
|
43
|
+
end_days_ago: float | None,
|
|
44
|
+
now: datetime | None = None,
|
|
45
|
+
) -> tuple[datetime | None, datetime | None]:
|
|
46
|
+
"""Convert relative day offsets into absolute (start, end) datetimes.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
start_days_ago: Older bound ("in the last 7 days" → 7).
|
|
50
|
+
end_days_ago: Newer bound ("before this month" → ~30, no start).
|
|
51
|
+
now: Reference time (real now when omitted).
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
tuple: (start_time, end_time), either side None when unbounded.
|
|
55
|
+
"""
|
|
56
|
+
now = now or datetime.now(UTC)
|
|
57
|
+
start = now - timedelta(days=start_days_ago) if start_days_ago is not None else None
|
|
58
|
+
end = now - timedelta(days=end_days_ago) if end_days_ago is not None else None
|
|
59
|
+
if start and end and start > end:
|
|
60
|
+
start, end = end, start
|
|
61
|
+
return start, end
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def freshness_collapse(entities: list[Any]) -> list[Any]:
|
|
65
|
+
"""Within near-duplicate groups of competing facts, freshest wins.
|
|
66
|
+
|
|
67
|
+
Greedy grouping in ranked order by token overlap of (trigger + content);
|
|
68
|
+
each group stays anchored at its best-ranked member's position but is
|
|
69
|
+
internally ordered newest-first. Relevance order across unrelated
|
|
70
|
+
entities is untouched.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
entities: Ranked entities of one arm.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
list: Entities with near-duplicate groups collapsed newest-first.
|
|
77
|
+
"""
|
|
78
|
+
groups: list[list[Any]] = []
|
|
79
|
+
for entity in entities:
|
|
80
|
+
text = _compare_text(entity)
|
|
81
|
+
for group in groups:
|
|
82
|
+
if check_string_token_overlap(
|
|
83
|
+
text, _compare_text(group[0]), _DUPLICATE_OVERLAP_THRESHOLD
|
|
84
|
+
):
|
|
85
|
+
group.append(entity)
|
|
86
|
+
break
|
|
87
|
+
else:
|
|
88
|
+
groups.append([entity])
|
|
89
|
+
collapsed: list[Any] = []
|
|
90
|
+
for group in groups:
|
|
91
|
+
collapsed.extend(sorted(group, key=entity_timestamp, reverse=True))
|
|
92
|
+
return collapsed
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sort_by_recency(entities: list[Any]) -> list[Any]:
|
|
96
|
+
"""Order entities newest-first (for explicit current/latest questions)."""
|
|
97
|
+
return sorted(entities, key=entity_timestamp, reverse=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _compare_text(entity: Any) -> str:
|
|
101
|
+
"""Trigger + content, punctuation-normalized for token-overlap grouping."""
|
|
102
|
+
trigger = getattr(entity, "trigger", None) or ""
|
|
103
|
+
content = getattr(entity, "content", "") or ""
|
|
104
|
+
return re.sub(r"[^\w\s]", " ", f"{trigger} {content}").strip()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Publish-time dispatch for per-turn shadow comparisons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
from reflexio.models.api_schema.domain.entities import Interaction
|
|
9
|
+
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
10
|
+
from reflexio.server.llm.litellm_client import LiteLLMClient
|
|
11
|
+
from reflexio.server.services.shadow_comparison.judge import ShadowComparisonJudge
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_ASSISTANT_ROLES = {"agent", "assistant"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def dispatch_shadow_comparison_judge(
|
|
19
|
+
*,
|
|
20
|
+
storage, # noqa: ANN001 - BaseStorage; concrete type would create import cycles
|
|
21
|
+
interactions: list[Interaction],
|
|
22
|
+
session_id: str,
|
|
23
|
+
agent_version: str,
|
|
24
|
+
request_context: RequestContext,
|
|
25
|
+
llm_client: LiteLLMClient,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Judge each shadow-bearing assistant turn in a single publish request."""
|
|
28
|
+
if not interactions:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
supports_verdicts = getattr(
|
|
32
|
+
storage, "supports_shadow_comparison_verdicts", lambda: False
|
|
33
|
+
)
|
|
34
|
+
if not supports_verdicts():
|
|
35
|
+
logger.info(
|
|
36
|
+
"Skipping shadow comparison for session=%s because %s does not support verdict storage",
|
|
37
|
+
session_id,
|
|
38
|
+
type(storage).__name__,
|
|
39
|
+
)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
sorted_interactions = sorted(interactions, key=lambda i: i.created_at)
|
|
43
|
+
shadow_interactions = [
|
|
44
|
+
interaction
|
|
45
|
+
for interaction in sorted_interactions
|
|
46
|
+
if interaction.shadow_content
|
|
47
|
+
and interaction.role.strip().lower() in _ASSISTANT_ROLES
|
|
48
|
+
]
|
|
49
|
+
if not shadow_interactions:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
config = request_context.configurator.get_config() # type: ignore[reportOptionalMemberAccess]
|
|
53
|
+
judge = ShadowComparisonJudge(
|
|
54
|
+
llm_client=llm_client,
|
|
55
|
+
prompt_manager=request_context.prompt_manager, # type: ignore[reportOptionalMemberAccess]
|
|
56
|
+
prompt_version=config.shadow_comparison_judge_prompt_version,
|
|
57
|
+
)
|
|
58
|
+
rng = random.Random() # noqa: S311 - position randomization, not crypto
|
|
59
|
+
saved_count = 0
|
|
60
|
+
|
|
61
|
+
for interaction in shadow_interactions:
|
|
62
|
+
conversation_context = _format_request_transcript_before(
|
|
63
|
+
sorted_interactions=sorted_interactions,
|
|
64
|
+
target=interaction,
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
verdict = judge.judge_turn(
|
|
68
|
+
interaction=interaction,
|
|
69
|
+
session_id=session_id,
|
|
70
|
+
agent_version=agent_version,
|
|
71
|
+
rng=rng,
|
|
72
|
+
conversation_context=conversation_context,
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc: # noqa: BLE001 - one judge failure must not abort the batch
|
|
75
|
+
logger.warning(
|
|
76
|
+
"shadow_comparison dispatch failed for interaction %s: %s",
|
|
77
|
+
interaction.interaction_id,
|
|
78
|
+
exc,
|
|
79
|
+
)
|
|
80
|
+
continue
|
|
81
|
+
if verdict is None:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
storage.save_shadow_comparison_verdict(verdict)
|
|
85
|
+
saved_count += 1
|
|
86
|
+
except NotImplementedError:
|
|
87
|
+
logger.info(
|
|
88
|
+
"Stopping shadow comparison for session=%s because %s does not support verdict storage",
|
|
89
|
+
session_id,
|
|
90
|
+
type(storage).__name__,
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
except Exception as exc: # noqa: BLE001 - single-row save failure must not abort batch
|
|
94
|
+
logger.warning(
|
|
95
|
+
"shadow_comparison verdict save failed for interaction %s: %s",
|
|
96
|
+
interaction.interaction_id,
|
|
97
|
+
exc,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if saved_count:
|
|
101
|
+
logger.info(
|
|
102
|
+
"Saved %d shadow_comparison verdict(s) for session=%s",
|
|
103
|
+
saved_count,
|
|
104
|
+
session_id,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _format_request_transcript_before(
|
|
109
|
+
*, sorted_interactions: list[Interaction], target: Interaction
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Format prior turns in the same publish request as judge context.
|
|
112
|
+
|
|
113
|
+
``sorted_interactions`` must already be ordered by ``created_at`` — the
|
|
114
|
+
caller sorts once and passes the shared list so this runs O(n) per turn.
|
|
115
|
+
"""
|
|
116
|
+
prior_turns: list[Interaction] = []
|
|
117
|
+
for interaction in sorted_interactions:
|
|
118
|
+
if interaction is target or (
|
|
119
|
+
interaction.interaction_id
|
|
120
|
+
and interaction.interaction_id == target.interaction_id
|
|
121
|
+
):
|
|
122
|
+
break
|
|
123
|
+
if interaction.content:
|
|
124
|
+
prior_turns.append(interaction)
|
|
125
|
+
if not prior_turns:
|
|
126
|
+
return ""
|
|
127
|
+
return "\n\n".join(
|
|
128
|
+
f"{_display_role(interaction.role)}:\n{interaction.content}"
|
|
129
|
+
for interaction in prior_turns
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _display_role(role: str) -> str:
|
|
134
|
+
normalized = role.strip().lower()
|
|
135
|
+
if normalized in {"user", "human"}:
|
|
136
|
+
return "User"
|
|
137
|
+
if normalized in _ASSISTANT_ROLES:
|
|
138
|
+
return "Assistant"
|
|
139
|
+
return role.strip() or "Unknown"
|
|
@@ -77,14 +77,16 @@ class ShadowComparisonJudge:
|
|
|
77
77
|
agent_version: str,
|
|
78
78
|
rng: random.Random,
|
|
79
79
|
user_message: str = "",
|
|
80
|
+
conversation_context: str | None = None,
|
|
80
81
|
) -> ShadowComparisonVerdict | None:
|
|
81
82
|
"""
|
|
82
83
|
Grade one interaction; return ``None`` when the LLM fails or the
|
|
83
84
|
interaction has no shadow response to compare against.
|
|
84
85
|
|
|
85
|
-
Returning ``None`` (rather than raising) lets the
|
|
86
|
-
skip this turn and continue with the rest of the
|
|
87
|
-
rate-limit blip should not
|
|
86
|
+
Returning ``None`` (rather than raising) lets the publish-time
|
|
87
|
+
dispatcher skip this turn and continue with the rest of the
|
|
88
|
+
request's shadow-bearing turns — one rate-limit blip should not
|
|
89
|
+
abort an entire batch.
|
|
88
90
|
|
|
89
91
|
Args:
|
|
90
92
|
interaction (Interaction): The agent-side interaction. Its
|
|
@@ -98,9 +100,11 @@ class ShadowComparisonJudge:
|
|
|
98
100
|
Production callers pass a fresh ``random.Random()`` per
|
|
99
101
|
judge call; tests inject a seeded ``Random`` for
|
|
100
102
|
reproducibility.
|
|
101
|
-
user_message (str):
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
user_message (str): Backward-compatible single-turn context used
|
|
104
|
+
by the v1.0 prompt.
|
|
105
|
+
conversation_context (str | None): Request-local transcript context
|
|
106
|
+
used by v1.1+ prompts. Defaults to ``user_message`` so explicit
|
|
107
|
+
v1.0 prompt-version overrides keep working.
|
|
104
108
|
|
|
105
109
|
Returns:
|
|
106
110
|
ShadowComparisonVerdict | None: The constructed verdict, or
|
|
@@ -118,7 +122,13 @@ class ShadowComparisonJudge:
|
|
|
118
122
|
prompt = self._prompt_manager.render_prompt(
|
|
119
123
|
prompt_id=_PROMPT_ID,
|
|
120
124
|
variables={
|
|
125
|
+
# ``user_message`` is retained only for explicit v1.0.0
|
|
126
|
+
# prompt-version overrides; the active v1.1+ template reads
|
|
127
|
+
# ``conversation_context`` instead. Do not drop it as "unused".
|
|
121
128
|
"user_message": user_message,
|
|
129
|
+
"conversation_context": conversation_context
|
|
130
|
+
if conversation_context is not None
|
|
131
|
+
else user_message,
|
|
122
132
|
"request_1_response": request_1,
|
|
123
133
|
"request_2_response": request_2,
|
|
124
134
|
},
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Bounded background worker for publish-time shadow comparison."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import queue
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from reflexio.models.api_schema.domain.entities import Interaction
|
|
11
|
+
from reflexio.server.services.shadow_comparison.dispatcher import (
|
|
12
|
+
dispatch_shadow_comparison_judge,
|
|
13
|
+
)
|
|
14
|
+
from reflexio.server.usage_metrics import record_usage_event
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_DEFAULT_WORKER_COUNT = 2
|
|
19
|
+
_DEFAULT_QUEUE_SIZE = 1000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ShadowComparisonJob:
|
|
24
|
+
"""Metadata needed to judge one publish request's shadow-bearing turns.
|
|
25
|
+
|
|
26
|
+
Carries only ``org_id`` plus request-scoped data — never the live
|
|
27
|
+
``storage`` / ``request_context`` / ``llm_client`` objects. Those are
|
|
28
|
+
re-resolved via ``get_reflexio(org_id)`` inside the worker so a job that
|
|
29
|
+
waits in the queue across a config reload / cache eviction runs against
|
|
30
|
+
the *current* per-org instance rather than a stale one.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
org_id: str
|
|
34
|
+
interactions: list[Interaction]
|
|
35
|
+
session_id: str
|
|
36
|
+
agent_version: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ShadowComparisonWorker:
|
|
40
|
+
"""Small process-local daemon worker pool for shadow comparison jobs."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
worker_count: int = _DEFAULT_WORKER_COUNT,
|
|
46
|
+
queue_size: int = _DEFAULT_QUEUE_SIZE,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.worker_count = max(1, worker_count)
|
|
49
|
+
self._queue: queue.Queue[ShadowComparisonJob] = queue.Queue(
|
|
50
|
+
maxsize=max(1, queue_size)
|
|
51
|
+
)
|
|
52
|
+
self._threads: list[threading.Thread] = []
|
|
53
|
+
self._start_lock = threading.Lock()
|
|
54
|
+
self._started = False
|
|
55
|
+
|
|
56
|
+
def start(self) -> None:
|
|
57
|
+
with self._start_lock:
|
|
58
|
+
if self._started:
|
|
59
|
+
return
|
|
60
|
+
self._threads = [
|
|
61
|
+
threading.Thread(
|
|
62
|
+
target=self._worker_loop,
|
|
63
|
+
name=f"shadow-comparison-worker-{idx}",
|
|
64
|
+
daemon=True,
|
|
65
|
+
)
|
|
66
|
+
for idx in range(self.worker_count)
|
|
67
|
+
]
|
|
68
|
+
for thread in self._threads:
|
|
69
|
+
thread.start()
|
|
70
|
+
self._started = True
|
|
71
|
+
logger.info(
|
|
72
|
+
"event=shadow_comparison_worker_started workers=%d queue_size=%d",
|
|
73
|
+
self.worker_count,
|
|
74
|
+
self._queue.maxsize,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def enqueue(self, job: ShadowComparisonJob) -> bool:
|
|
78
|
+
self.start()
|
|
79
|
+
try:
|
|
80
|
+
self._queue.put_nowait(job)
|
|
81
|
+
except queue.Full:
|
|
82
|
+
logger.warning(
|
|
83
|
+
"event=shadow_comparison_queue_full org_id=%s session_id=%s queue_size=%d",
|
|
84
|
+
job.org_id,
|
|
85
|
+
job.session_id,
|
|
86
|
+
self._queue.maxsize,
|
|
87
|
+
)
|
|
88
|
+
record_usage_event(
|
|
89
|
+
org_id=job.org_id,
|
|
90
|
+
user_id=None,
|
|
91
|
+
request_id=None,
|
|
92
|
+
session_id=job.session_id,
|
|
93
|
+
source=None,
|
|
94
|
+
agent_version=job.agent_version,
|
|
95
|
+
event_name="shadow_comparison_dropped",
|
|
96
|
+
event_category="shadow_comparison",
|
|
97
|
+
outcome="dropped",
|
|
98
|
+
metadata={"queue_size": self._queue.maxsize},
|
|
99
|
+
)
|
|
100
|
+
return False
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
def _worker_loop(self) -> None:
|
|
104
|
+
# Imported lazily to break the import cycle generation_service ->
|
|
105
|
+
# shadow_comparison.worker -> reflexio_cache -> reflexio_lib ->
|
|
106
|
+
# generation_service (the publish_learning_worker does the same).
|
|
107
|
+
from reflexio.server.cache.reflexio_cache import get_reflexio
|
|
108
|
+
|
|
109
|
+
while True:
|
|
110
|
+
job = self._queue.get()
|
|
111
|
+
try:
|
|
112
|
+
reflexio = get_reflexio(org_id=job.org_id)
|
|
113
|
+
request_context = reflexio.request_context
|
|
114
|
+
dispatch_shadow_comparison_judge(
|
|
115
|
+
storage=request_context.storage,
|
|
116
|
+
interactions=job.interactions,
|
|
117
|
+
session_id=job.session_id,
|
|
118
|
+
agent_version=job.agent_version,
|
|
119
|
+
request_context=request_context,
|
|
120
|
+
llm_client=reflexio.llm_client,
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
logger.exception(
|
|
124
|
+
"event=shadow_comparison_job_failed org_id=%s session_id=%s",
|
|
125
|
+
job.org_id,
|
|
126
|
+
job.session_id,
|
|
127
|
+
)
|
|
128
|
+
finally:
|
|
129
|
+
self._queue.task_done()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_worker = ShadowComparisonWorker()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def enqueue_shadow_comparison(job: ShadowComparisonJob) -> bool:
|
|
136
|
+
"""Enqueue a shadow comparison job, dropping it when the local queue is full."""
|
|
137
|
+
return _worker.enqueue(job)
|
|
@@ -34,6 +34,10 @@ _CANONICAL_DELETE_TARGET_NAMES = (
|
|
|
34
34
|
"profile",
|
|
35
35
|
"user_playbook",
|
|
36
36
|
"agent_success_evaluation_result",
|
|
37
|
+
"retrieved_learning_evaluation_result",
|
|
38
|
+
"evaluation_operation_state",
|
|
39
|
+
"offline_tuner_reward_label",
|
|
40
|
+
"offline_tuner_reward_label_target_by_target_owner",
|
|
37
41
|
"profile_purge",
|
|
38
42
|
"user_playbook_purge",
|
|
39
43
|
)
|
|
@@ -52,6 +56,10 @@ _ALLOWED_PURGE_TARGET_NAMES = frozenset(
|
|
|
52
56
|
"profile",
|
|
53
57
|
"user_playbook",
|
|
54
58
|
"agent_success_evaluation_result",
|
|
59
|
+
"retrieved_learning_evaluation_result",
|
|
60
|
+
"evaluation_operation_state",
|
|
61
|
+
"offline_tuner_reward_label",
|
|
62
|
+
"offline_tuner_reward_label_target_by_target_owner",
|
|
55
63
|
"agent_playbook",
|
|
56
64
|
"profile_purge",
|
|
57
65
|
"user_playbook_purge",
|
|
@@ -157,6 +165,10 @@ _ALLOWED_DELETED_COUNTS_KEYS = frozenset(
|
|
|
157
165
|
"profiles",
|
|
158
166
|
"requests",
|
|
159
167
|
"agent_success_evaluation_results",
|
|
168
|
+
"retrieved_learning_evaluation_results",
|
|
169
|
+
"evaluation_operation_states",
|
|
170
|
+
"offline_tuner_reward_labels",
|
|
171
|
+
"offline_tuner_reward_label_targets_by_target_owner",
|
|
160
172
|
"purged_profiles",
|
|
161
173
|
"purged_user_playbooks",
|
|
162
174
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Shared validation for the lifecycle filters on bulk by-id getters.
|
|
2
|
+
|
|
3
|
+
``include_inactive=True`` returns every owned row regardless of lifecycle
|
|
4
|
+
status, expiry, or approval — it is the historical-resolution mode used by the
|
|
5
|
+
retrieved-learning evaluator. Combining it with an explicit status filter is
|
|
6
|
+
contradictory: the filter would be silently discarded and the caller would get
|
|
7
|
+
back rows it asked to exclude. Fail loud instead.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from reflexio.models.api_schema.domain import PlaybookStatus, Status
|
|
13
|
+
from reflexio.server.services.storage.error import StorageError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_include_inactive(
|
|
17
|
+
*,
|
|
18
|
+
include_inactive: bool,
|
|
19
|
+
status_filter: list[Status | None] | None = None,
|
|
20
|
+
playbook_status_filter: list[PlaybookStatus] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Reject ``include_inactive=True`` combined with an explicit status filter.
|
|
23
|
+
|
|
24
|
+
Raises ``StorageError`` rather than ``ValueError`` so the behavior is uniform
|
|
25
|
+
across backends: both ``handle_exceptions`` decorators re-raise ``StorageError``
|
|
26
|
+
untouched, whereas any other exception type is swallowed, wrapped, and (on
|
|
27
|
+
Supabase) reported to Sentry — turning a caller bug into noise.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
include_inactive (bool): Whether the caller asked for all rows regardless
|
|
31
|
+
of lifecycle status.
|
|
32
|
+
status_filter (list[Status | None] | None): Explicit lifecycle statuses
|
|
33
|
+
the caller asked for, if any.
|
|
34
|
+
playbook_status_filter (list[PlaybookStatus] | None): Explicit approval
|
|
35
|
+
statuses the caller asked for, if any.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
StorageError: If ``include_inactive`` is True and either filter is set.
|
|
39
|
+
"""
|
|
40
|
+
if not include_inactive:
|
|
41
|
+
return
|
|
42
|
+
conflicting = [
|
|
43
|
+
name
|
|
44
|
+
for name, value in (
|
|
45
|
+
("status_filter", status_filter),
|
|
46
|
+
("playbook_status_filter", playbook_status_filter),
|
|
47
|
+
)
|
|
48
|
+
if value is not None
|
|
49
|
+
]
|
|
50
|
+
if conflicting:
|
|
51
|
+
raise StorageError(
|
|
52
|
+
f"include_inactive=True cannot be combined with {' and '.join(conflicting)}"
|
|
53
|
+
" — include_inactive ignores lifecycle filters. Pass one or the other."
|
|
54
|
+
)
|