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,554 @@
1
+ """Retrieved-learning relevance/impact evaluator.
2
+
3
+ Judges every learning that was retrieved and injected into a session
4
+ (``Interaction.retrieved_learnings``) with two LLM judge families:
5
+
6
+ - **relevance**: does the learning apply to the session's task/response?
7
+ - **impact**: relative to the agent's definition of success, did applying it
8
+ move the response toward success, away from it, or not materially change it
9
+ (counterfactual judgment from the observed transcript)?
10
+
11
+ Plain class colocated with the group-evaluation runner (no
12
+ ``BaseGenerationService`` machinery — this is a judge that writes to its own
13
+ table, like the shadow-comparison judge). Consumes the bounded storage
14
+ snapshot; never loads full ``Interaction`` objects.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from dataclasses import dataclass, field
21
+ from typing import TYPE_CHECKING, Any, Literal
22
+
23
+ from pydantic import ConfigDict, Field
24
+
25
+ from reflexio.models.api_schema.domain import RetrievedLearningEvaluationResult
26
+ from reflexio.models.structured_output import StrictStructuredOutput
27
+ from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
28
+ from reflexio.server.services.service_utils import (
29
+ log_llm_messages,
30
+ log_model_response,
31
+ slice_content_by_tokens,
32
+ )
33
+ from reflexio.server.services.storage.storage_base.retrieved_learning_state import (
34
+ CANONICAL_RETRIEVED_KINDS,
35
+ BoundedRetrievedLearningSnapshot,
36
+ )
37
+ from reflexio.server.site_var.site_var_manager import SiteVarManager
38
+
39
+ if TYPE_CHECKING:
40
+ from reflexio.server.api_endpoints.request_context import RequestContext
41
+ from reflexio.server.llm.litellm_client import LiteLLMClient
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ RETRIEVED_LEARNING_RELEVANCE_PROMPT_ID = "retrieved_learning_relevance"
46
+ RETRIEVED_LEARNING_IMPACT_PROMPT_ID = "retrieved_learning_impact"
47
+
48
+ # Bounded-input constants (code constants by design — no env knobs).
49
+ MAX_CANONICAL_CANDIDATES = 1_000
50
+ CANDIDATES_PER_CHUNK = 25
51
+ LEARNING_BODY_TOKEN_LIMIT = 256
52
+ TRANSCRIPT_TOKEN_LIMIT = 8_000
53
+
54
+
55
+ class RetrievedLearningRelevanceVerdict(StrictStructuredOutput):
56
+ """One relevance verdict, keyed by the echoed opaque ``learning_ref``."""
57
+
58
+ learning_ref: str = Field(
59
+ description="Exact echo of the learning_ref shown in the prompt,"
60
+ " e.g. 'profile:abc123'"
61
+ )
62
+ is_relevant: bool = Field(
63
+ description="Whether this learning applies to the user's task and the"
64
+ " agent response in this session"
65
+ )
66
+ relevance_reason: str = Field(
67
+ description="Why this learning is or is not relevant to the session"
68
+ )
69
+ model_config = ConfigDict(
70
+ extra="allow",
71
+ json_schema_extra={"additionalProperties": False},
72
+ )
73
+
74
+
75
+ class RetrievedLearningRelevanceOutput(StrictStructuredOutput):
76
+ """Relevance judge output: exactly one verdict per listed learning."""
77
+
78
+ verdicts: list[RetrievedLearningRelevanceVerdict] = Field(
79
+ description="Exactly one verdict per learning listed in the prompt"
80
+ )
81
+ model_config = ConfigDict(
82
+ extra="allow",
83
+ json_schema_extra={"additionalProperties": False},
84
+ )
85
+
86
+
87
+ class RetrievedLearningImpactVerdict(StrictStructuredOutput):
88
+ """One impact verdict, keyed by the echoed opaque ``learning_ref``."""
89
+
90
+ learning_ref: str = Field(
91
+ description="Exact echo of the learning_ref shown in the prompt,"
92
+ " e.g. 'user_playbook:42'"
93
+ )
94
+ impact: Literal["positive", "negative", "neutral"] = Field(
95
+ description="Whether applying this learning plausibly improved,"
96
+ " harmed, or did not materially change the agent response"
97
+ )
98
+ impact_reason: str = Field(
99
+ description="Counterfactual reasoning for the impact judgment"
100
+ )
101
+ model_config = ConfigDict(
102
+ extra="allow",
103
+ json_schema_extra={"additionalProperties": False},
104
+ )
105
+
106
+
107
+ class RetrievedLearningImpactOutput(StrictStructuredOutput):
108
+ """Impact judge output: exactly one verdict per listed learning."""
109
+
110
+ verdicts: list[RetrievedLearningImpactVerdict] = Field(
111
+ description="Exactly one verdict per learning listed in the prompt"
112
+ )
113
+ model_config = ConfigDict(
114
+ extra="allow",
115
+ json_schema_extra={"additionalProperties": False},
116
+ )
117
+
118
+
119
+ @dataclass
120
+ class LearningCandidate:
121
+ """One resolved historically attached learning.
122
+
123
+ ``title`` comes from the resolved original row (playbook name; empty for
124
+ profiles) and is shown to the judges only — it is never persisted.
125
+ """
126
+
127
+ kind: str
128
+ learning_id: str
129
+ title: str
130
+ content: str
131
+ trigger: str
132
+
133
+ @property
134
+ def learning_ref(self) -> str:
135
+ return f"{self.kind}:{self.learning_id}"
136
+
137
+
138
+ @dataclass
139
+ class RetrievedLearningEvaluationRun:
140
+ """Evaluator output handed to the storage replacement CAS.
141
+
142
+ ``outcome`` is ``"evaluated"`` when at least one usable verdict exists (or
143
+ the candidate set is legitimately empty) and ``"failed"`` when no judge
144
+ produced any usable verdict or a bound was exceeded — the runner then
145
+ preserves the prior snapshot via ``finish_retrieved_learning_evaluation_run``.
146
+ """
147
+
148
+ outcome: Literal["evaluated", "failed"]
149
+ proposed_status: Literal["complete", "degraded"] = "complete"
150
+ rows: list[RetrievedLearningEvaluationResult] = field(default_factory=list)
151
+ diagnostics: dict[str, Any] = field(default_factory=dict)
152
+
153
+
154
+ class RetrievedLearningEvaluator:
155
+ """Judge retrieved learnings for session relevance and response impact."""
156
+
157
+ def __init__(
158
+ self,
159
+ request_context: RequestContext,
160
+ llm_client: LiteLLMClient,
161
+ agent_context: str = "",
162
+ success_definition: str = "",
163
+ ):
164
+ """Initialize the evaluator.
165
+
166
+ Args:
167
+ request_context (RequestContext): Storage + prompt manager.
168
+ llm_client (LiteLLMClient): Unified LLM client.
169
+ agent_context (str): Context about the agent, injected into both
170
+ judge prompts.
171
+ success_definition (str): The agent's definition of success (from
172
+ ``AgentSuccessConfig.success_definition_prompt``), injected into
173
+ the impact judge only so impact is judged relative to what the
174
+ org actually considers success. Empty means the impact judge
175
+ falls back to general task helpfulness.
176
+ """
177
+ self.request_context = request_context
178
+ self.client = llm_client
179
+ self.agent_context = agent_context
180
+ self.success_definition = success_definition
181
+
182
+ config = self.request_context.configurator.get_config()
183
+ llm_config = config.llm_config if config else None
184
+ model_setting = SiteVarManager().get_site_var("llm_model_setting")
185
+ site_var = model_setting if isinstance(model_setting, dict) else {}
186
+ self.model_name = resolve_model_name(
187
+ ModelRole.EVALUATION,
188
+ site_var_value=site_var.get("default_evaluate_model_name"),
189
+ config_override=llm_config.generation_model_name if llm_config else None,
190
+ api_key_config=config.api_key_config if config else None,
191
+ )
192
+
193
+ # ===============================
194
+ # public methods
195
+ # ===============================
196
+
197
+ def evaluate(
198
+ self,
199
+ user_id: str,
200
+ session_id: str,
201
+ agent_version: str,
202
+ snapshot: BoundedRetrievedLearningSnapshot,
203
+ ) -> RetrievedLearningEvaluationRun:
204
+ """Evaluate every attached learning whose original row still exists.
205
+
206
+ Args:
207
+ user_id (str): Session owner.
208
+ session_id (str): Evaluated session.
209
+ agent_version (str): Version requested by the runner.
210
+ snapshot (BoundedRetrievedLearningSnapshot): Bounded projection
211
+ loaded by storage.
212
+
213
+ Returns:
214
+ RetrievedLearningEvaluationRun: Proposed rows + status, or a
215
+ failure with a sanitized reason.
216
+ """
217
+ diagnostics: dict[str, Any] = {
218
+ "invalid_ref_count": 0,
219
+ "failed_relevance_chunks": 0,
220
+ "failed_impact_chunks": 0,
221
+ }
222
+ if snapshot.attachment_limit_exceeded:
223
+ diagnostics["error_type"] = "attachment_limit_exceeded"
224
+ return RetrievedLearningEvaluationRun(
225
+ outcome="failed", diagnostics=diagnostics
226
+ )
227
+
228
+ refs = self._collect_canonical_refs(snapshot, diagnostics)
229
+ if len(refs) > MAX_CANONICAL_CANDIDATES:
230
+ # Never truncate silently: zero LLM calls, preserve prior snapshot.
231
+ diagnostics["error_type"] = "candidate_limit_exceeded"
232
+ return RetrievedLearningEvaluationRun(
233
+ outcome="failed", diagnostics=diagnostics
234
+ )
235
+
236
+ candidates = self._resolve_candidates(user_id, refs, diagnostics)
237
+ diagnostics["candidate_count"] = len(candidates)
238
+ if not candidates:
239
+ # No attached or eligible refs: zero LLM calls; the storage
240
+ # replacement clears prior rows and records not_applicable.
241
+ return RetrievedLearningEvaluationRun(
242
+ outcome="evaluated",
243
+ proposed_status="complete",
244
+ rows=[],
245
+ diagnostics=diagnostics,
246
+ )
247
+
248
+ transcript = self._format_transcript(snapshot)
249
+ relevance: dict[str, RetrievedLearningRelevanceVerdict] = {}
250
+ impact: dict[str, RetrievedLearningImpactVerdict] = {}
251
+ chunks = [
252
+ candidates[i : i + CANDIDATES_PER_CHUNK]
253
+ for i in range(0, len(candidates), CANDIDATES_PER_CHUNK)
254
+ ]
255
+ for chunk in chunks:
256
+ chunk_refs = {c.learning_ref for c in chunk}
257
+ relevance_output = self._run_judge(
258
+ RETRIEVED_LEARNING_RELEVANCE_PROMPT_ID,
259
+ RetrievedLearningRelevanceOutput,
260
+ transcript,
261
+ chunk,
262
+ )
263
+ if relevance_output is None:
264
+ diagnostics["failed_relevance_chunks"] += 1
265
+ else:
266
+ for verdict in relevance_output.verdicts:
267
+ if verdict.learning_ref in chunk_refs:
268
+ relevance[verdict.learning_ref] = verdict
269
+ impact_output = self._run_judge(
270
+ RETRIEVED_LEARNING_IMPACT_PROMPT_ID,
271
+ RetrievedLearningImpactOutput,
272
+ transcript,
273
+ chunk,
274
+ )
275
+ if impact_output is None:
276
+ diagnostics["failed_impact_chunks"] += 1
277
+ else:
278
+ for verdict in impact_output.verdicts:
279
+ if verdict.learning_ref in chunk_refs:
280
+ impact[verdict.learning_ref] = verdict
281
+
282
+ if not relevance and not impact:
283
+ diagnostics["error_type"] = "all_judges_failed"
284
+ return RetrievedLearningEvaluationRun(
285
+ outcome="failed", diagnostics=diagnostics
286
+ )
287
+
288
+ # Build rows by iterating canonical input candidates — never LLM
289
+ # output — so every candidate appears exactly once and unknown output
290
+ # cannot be inserted.
291
+ created_at = snapshot.earliest_request_created_at or 0
292
+ rows: list[RetrievedLearningEvaluationResult] = []
293
+ for candidate in candidates:
294
+ relevance_verdict = relevance.get(candidate.learning_ref)
295
+ impact_verdict = impact.get(candidate.learning_ref)
296
+ rows.append(
297
+ RetrievedLearningEvaluationResult(
298
+ user_id=user_id,
299
+ session_id=session_id,
300
+ agent_version=agent_version,
301
+ kind=candidate.kind, # type: ignore[arg-type]
302
+ learning_id=candidate.learning_id,
303
+ is_relevant=(
304
+ relevance_verdict.is_relevant if relevance_verdict else None
305
+ ),
306
+ relevance_reason=(
307
+ relevance_verdict.relevance_reason if relevance_verdict else ""
308
+ ),
309
+ impact=impact_verdict.impact if impact_verdict else None,
310
+ impact_reason=(
311
+ impact_verdict.impact_reason if impact_verdict else ""
312
+ ),
313
+ created_at=created_at,
314
+ )
315
+ )
316
+ degraded = (
317
+ diagnostics["failed_relevance_chunks"] > 0
318
+ or diagnostics["failed_impact_chunks"] > 0
319
+ )
320
+ return RetrievedLearningEvaluationRun(
321
+ outcome="evaluated",
322
+ proposed_status="degraded" if degraded else "complete",
323
+ rows=rows,
324
+ diagnostics=diagnostics,
325
+ )
326
+
327
+ # ===============================
328
+ # candidate construction
329
+ # ===============================
330
+
331
+ @staticmethod
332
+ def _collect_canonical_refs(
333
+ snapshot: BoundedRetrievedLearningSnapshot,
334
+ diagnostics: dict[str, Any],
335
+ ) -> dict[tuple[str, str], None]:
336
+ """Dedupe ``(kind, learning_id)`` refs in chronological order.
337
+
338
+ Returns:
339
+ dict: ``(kind, learning_id) -> None`` used as an ordered set.
340
+ """
341
+ refs: dict[tuple[str, str], None] = {}
342
+ for interaction in snapshot.interactions:
343
+ for kind, learning_id in interaction.refs:
344
+ if kind not in CANONICAL_RETRIEVED_KINDS:
345
+ diagnostics["invalid_ref_count"] += 1
346
+ continue
347
+ refs.setdefault((kind, learning_id), None)
348
+ return refs
349
+
350
+ def _resolve_candidates(
351
+ self,
352
+ user_id: str,
353
+ refs: dict[tuple[str, str], None],
354
+ diagnostics: dict[str, Any],
355
+ ) -> list[LearningCandidate]:
356
+ """Resolve attached refs to their original rows; skip missing rows.
357
+
358
+ Lifecycle status and agent-playbook approval do not affect historical
359
+ evaluation. Learning content/trigger/title come from the original row;
360
+ unresolvable refs are skipped, never followed to a successor.
361
+
362
+ Resolution is best-effort, not a durability guarantee: row retention
363
+ (``RETENTION_TARGETS``) evicts tombstoned rows first and
364
+ ``gc_expired_tombstones`` hard-deletes aged ones, so a learning that was
365
+ genuinely served can age out and then resolve to nothing. That is a
366
+ silent skip by design — an old session simply judges fewer learnings.
367
+ """
368
+ storage = self.request_context.storage
369
+ if storage is None:
370
+ return []
371
+ profile_ids = [lid for (kind, lid) in refs if kind == "profile"]
372
+ user_playbook_ids: list[int] = []
373
+ agent_playbook_ids: list[int] = []
374
+ for kind, lid in refs:
375
+ if kind not in ("user_playbook", "agent_playbook"):
376
+ continue
377
+ try:
378
+ parsed = int(lid)
379
+ except ValueError:
380
+ diagnostics["invalid_ref_count"] += 1
381
+ continue
382
+ if parsed <= 0:
383
+ diagnostics["invalid_ref_count"] += 1
384
+ continue
385
+ if kind == "user_playbook":
386
+ user_playbook_ids.append(parsed)
387
+ else:
388
+ agent_playbook_ids.append(parsed)
389
+
390
+ resolved: dict[tuple[str, str], LearningCandidate] = {}
391
+ if profile_ids:
392
+ for profile in storage.get_profiles_by_ids(
393
+ user_id, profile_ids, include_inactive=True
394
+ ):
395
+ resolved[("profile", profile.profile_id)] = LearningCandidate(
396
+ kind="profile",
397
+ learning_id=profile.profile_id,
398
+ title="",
399
+ content=profile.content,
400
+ trigger="",
401
+ )
402
+ if user_playbook_ids:
403
+ for playbook in storage.get_user_playbooks_by_ids(
404
+ user_id, user_playbook_ids, include_inactive=True
405
+ ):
406
+ key = ("user_playbook", str(playbook.user_playbook_id))
407
+ resolved[key] = LearningCandidate(
408
+ kind="user_playbook",
409
+ learning_id=str(playbook.user_playbook_id),
410
+ title=playbook.playbook_name,
411
+ content=playbook.content,
412
+ trigger=playbook.trigger or "",
413
+ )
414
+ for playbook in storage.get_agent_playbooks_by_ids(
415
+ agent_playbook_ids,
416
+ include_inactive=True,
417
+ ):
418
+ key = ("agent_playbook", str(playbook.agent_playbook_id))
419
+ resolved[key] = LearningCandidate(
420
+ kind="agent_playbook",
421
+ learning_id=str(playbook.agent_playbook_id),
422
+ title=playbook.playbook_name,
423
+ content=playbook.content,
424
+ trigger=playbook.trigger or "",
425
+ )
426
+ # Preserve first-seen order of the attached refs.
427
+ return [resolved[key] for key in refs if key in resolved]
428
+
429
+ # ===============================
430
+ # judging
431
+ # ===============================
432
+
433
+ @staticmethod
434
+ def _format_transcript(snapshot: BoundedRetrievedLearningSnapshot) -> str:
435
+ lines = [
436
+ f"{interaction.role}: {interaction.content}"
437
+ for interaction in snapshot.interactions
438
+ if interaction.role or interaction.content
439
+ ]
440
+ return slice_content_by_tokens("\n".join(lines), TRANSCRIPT_TOKEN_LIMIT)
441
+
442
+ def _learnings_payload(self, chunk: list[LearningCandidate]) -> str:
443
+ import json
444
+
445
+ return json.dumps(
446
+ [
447
+ {
448
+ "learning_ref": c.learning_ref,
449
+ "kind": c.kind,
450
+ "title": c.title,
451
+ "content": slice_content_by_tokens(
452
+ c.content, LEARNING_BODY_TOKEN_LIMIT
453
+ ),
454
+ "trigger": slice_content_by_tokens(
455
+ c.trigger, LEARNING_BODY_TOKEN_LIMIT
456
+ ),
457
+ }
458
+ for c in chunk
459
+ ],
460
+ indent=2,
461
+ )
462
+
463
+ def _run_judge[
464
+ TJudgeOutput: (RetrievedLearningRelevanceOutput, RetrievedLearningImpactOutput)
465
+ ](
466
+ self,
467
+ prompt_id: str,
468
+ output_model: type[TJudgeOutput],
469
+ transcript: str,
470
+ chunk: list[LearningCandidate],
471
+ ) -> TJudgeOutput | None:
472
+ """Run one judge call for one chunk, with one bounded semantic repair.
473
+
474
+ A response is valid only when its verdict refs exactly equal the
475
+ chunk's input refs (no duplicates, no missing, no unknown). One
476
+ corrective retry naming only the coverage error; after the second
477
+ failure the chunk is marked failed.
478
+ """
479
+ expected_refs = {c.learning_ref for c in chunk}
480
+ variables: dict[str, Any] = {
481
+ "agent_context_prompt": self.agent_context,
482
+ "interactions": transcript,
483
+ "learnings": self._learnings_payload(chunk),
484
+ }
485
+ # Only the impact judge is anchored to the definition of success;
486
+ # relevance stays success-agnostic ("does this learning apply?").
487
+ if prompt_id == RETRIEVED_LEARNING_IMPACT_PROMPT_ID:
488
+ variables["success_definition_prompt"] = self.success_definition
489
+ prompt = self.request_context.prompt_manager.render_prompt(prompt_id, variables)
490
+ messages: list[dict[str, Any]] = [{"role": "user", "content": prompt}]
491
+ for attempt in range(2):
492
+ log_llm_messages(logger, f"Retrieved-learning judge {prompt_id}", messages)
493
+ response = self.client.generate_chat_response(
494
+ messages=messages,
495
+ model=self.model_name,
496
+ response_format=output_model,
497
+ )
498
+ if not isinstance(response, output_model):
499
+ logger.warning(
500
+ "event=retrieved_learning_eval_judge_bad_response prompt=%s"
501
+ " attempt=%d type=%s",
502
+ prompt_id,
503
+ attempt,
504
+ type(response).__name__,
505
+ )
506
+ return None
507
+ log_model_response(logger, f"Judge {prompt_id} response", response)
508
+ coverage_error = _verdict_coverage_error(
509
+ [v.learning_ref for v in response.verdicts], expected_refs
510
+ )
511
+ if coverage_error is None:
512
+ return response
513
+ if attempt == 0:
514
+ logger.info(
515
+ "event=retrieved_learning_eval_chunk_retry prompt=%s reason=%s",
516
+ prompt_id,
517
+ coverage_error,
518
+ )
519
+ messages = [
520
+ *messages,
521
+ {"role": "assistant", "content": response.model_dump_json()},
522
+ {
523
+ "role": "user",
524
+ "content": (
525
+ "Your previous response had a coverage error:"
526
+ f" {coverage_error}. Return exactly one verdict per"
527
+ " learning_ref listed in the original prompt — no"
528
+ " duplicates, no omissions, no other refs."
529
+ ),
530
+ },
531
+ ]
532
+ return None
533
+
534
+
535
+ def _verdict_coverage_error(refs: list[str], expected: set[str]) -> str | None:
536
+ """Name the coverage error for a judge response, or None when exact."""
537
+ seen: set[str] = set()
538
+ duplicates: set[str] = set()
539
+ unknown: set[str] = set()
540
+ for ref in refs:
541
+ if ref in seen:
542
+ duplicates.add(ref)
543
+ seen.add(ref)
544
+ if ref not in expected:
545
+ unknown.add(ref)
546
+ missing = expected - seen
547
+ problems: list[str] = []
548
+ if missing:
549
+ problems.append(f"missing refs {sorted(missing)}")
550
+ if duplicates:
551
+ problems.append(f"duplicate refs {sorted(duplicates)}")
552
+ if unknown:
553
+ problems.append(f"unknown refs {sorted(unknown)}")
554
+ return "; ".join(problems) if problems else None
@@ -19,6 +19,7 @@ from reflexio.models.api_schema.internal_schema import SessionDescriptor
19
19
  from reflexio.server.api_endpoints.request_context import RequestContext
20
20
  from reflexio.server.llm.litellm_client import LiteLLMClient
21
21
  from reflexio.server.services.agent_success_evaluation.runner import (
22
+ GroupEvaluationOutcome,
22
23
  run_group_evaluation,
23
24
  )
24
25
  from reflexio.server.services.evaluation_overview.eval_sampler import (
@@ -253,7 +254,7 @@ def _dispatch_one(
253
254
  """
254
255
  if job.cancel_event.is_set():
255
256
  raise _CancelledError
256
- run_group_evaluation(
257
+ outcome = run_group_evaluation(
257
258
  org_id=job.org_id,
258
259
  user_id=sc.user_id,
259
260
  session_id=sc.session_id,
@@ -263,6 +264,39 @@ def _dispatch_one(
263
264
  llm_client=llm_client,
264
265
  force_regenerate=True,
265
266
  )
267
+ reason = _outcome_failure_reason(outcome)
268
+ if reason is not None:
269
+ raise _OutcomeFailureError(reason)
270
+
271
+
272
+ def _outcome_failure_reason(outcome: GroupEvaluationOutcome) -> str | None:
273
+ """Classify a runner outcome for the regen counters.
274
+
275
+ Completed requires BOTH families in a settled state: agent success in
276
+ {complete, not_applicable, skipped} and retrieved-learning in
277
+ {complete, not_applicable, skipped, superseded}. Everything else counts
278
+ failed with an explicit reason; an unrecognized combination is logged and
279
+ counted failed rather than silently passed.
280
+ """
281
+ agent_ok = outcome.agent_success_status in ("complete", "not_applicable", "skipped")
282
+ retrieved_ok = outcome.retrieved_learning_status in (
283
+ "complete",
284
+ "not_applicable",
285
+ "skipped",
286
+ "superseded",
287
+ )
288
+ if agent_ok and retrieved_ok:
289
+ return None
290
+ if outcome.agent_success_status == "failed":
291
+ return "agent_success_failed"
292
+ if outcome.retrieved_learning_status in ("pending", "stale", "degraded", "failed"):
293
+ return f"retrieved_learning_{outcome.retrieved_learning_status}"
294
+ logger.warning("Unrecognized group evaluation outcome combination: %s", outcome)
295
+ return f"unrecognized_outcome:{outcome.agent_success_status}:{outcome.retrieved_learning_status}"
296
+
297
+
298
+ class _OutcomeFailureError(Exception):
299
+ """A settled-but-unsuccessful runner outcome, counted as a job failure."""
266
300
 
267
301
 
268
302
  class _CancelledError(Exception):