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.
Files changed (177) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +10 -43
  3. package/bin/claude-smart.js +105 -0
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/pyproject.toml +1 -1
  27. package/plugin/scripts/_lib.sh +197 -1
  28. package/plugin/scripts/backend-python-runner.py +46 -0
  29. package/plugin/scripts/backend-service.sh +757 -119
  30. package/plugin/scripts/codex-hook.js +63 -225
  31. package/plugin/scripts/dashboard-open.sh +6 -4
  32. package/plugin/scripts/dashboard-service.sh +117 -136
  33. package/plugin/scripts/hook_entry.sh +3 -0
  34. package/plugin/scripts/smart-install.sh +15 -1
  35. package/plugin/src/claude_smart/cli.py +14 -0
  36. package/plugin/src/claude_smart/context_inject.py +3 -0
  37. package/plugin/src/claude_smart/env_config.py +4 -1
  38. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  39. package/plugin/src/claude_smart/events/session_end.py +2 -1
  40. package/plugin/src/claude_smart/events/stop.py +3 -0
  41. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  42. package/plugin/src/claude_smart/internal_call.py +5 -2
  43. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  44. package/plugin/src/claude_smart/publish.py +59 -7
  45. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  46. package/plugin/src/claude_smart/runtime.py +15 -6
  47. package/plugin/src/claude_smart/state.py +211 -52
  48. package/plugin/uv.lock +1 -1
  49. package/plugin/vendor/reflexio/.env.example +13 -0
  50. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  51. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  52. package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
  53. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  54. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  55. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  56. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
  57. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  58. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  59. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  60. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  61. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  62. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  63. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  64. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  65. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
  67. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  68. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  69. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  70. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  71. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
  72. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  73. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  74. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  75. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  76. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  77. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
  78. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  79. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  80. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  81. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  82. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  83. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  84. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  85. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  86. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  90. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  91. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  92. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  93. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  94. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  95. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  96. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  99. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  100. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  101. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  103. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  104. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  105. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  106. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
  109. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  110. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  111. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  112. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  113. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  114. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  115. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  116. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
  118. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  120. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  122. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  124. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  125. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  126. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  127. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  128. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  129. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  130. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  131. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  132. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  133. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
  134. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  135. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  139. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  172. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  173. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  174. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  175. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  176. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  177. 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 regen worker
86
- skip this turn and continue with the rest of the session — one
87
- rate-limit blip should not abort an entire batch.
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): The user turn this interaction responds
102
- to. Optional empty string is rendered into the prompt
103
- if the caller does not provide a value.
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
+ )