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
|
@@ -29,7 +29,7 @@ import logging
|
|
|
29
29
|
import uuid
|
|
30
30
|
from dataclasses import dataclass
|
|
31
31
|
from datetime import UTC, datetime
|
|
32
|
-
from typing import TYPE_CHECKING
|
|
32
|
+
from typing import TYPE_CHECKING, Literal
|
|
33
33
|
|
|
34
34
|
from reflexio.models.api_schema.domain.entities import (
|
|
35
35
|
Citation,
|
|
@@ -39,7 +39,11 @@ from reflexio.models.api_schema.domain.entities import (
|
|
|
39
39
|
UserProfile,
|
|
40
40
|
)
|
|
41
41
|
from reflexio.server.llm.litellm_client import LiteLLMClient
|
|
42
|
+
from reflexio.server.services.deferred_learning_plan import ReflectionWritePlan
|
|
42
43
|
from reflexio.server.services.operation_state_utils import OperationStateManager
|
|
44
|
+
from reflexio.server.services.playbook.aggregation_trigger import (
|
|
45
|
+
maybe_trigger_user_playbook_aggregation,
|
|
46
|
+
)
|
|
43
47
|
from reflexio.server.services.playbook.playbook_edit_apply import apply_playbook_edit
|
|
44
48
|
from reflexio.server.services.reflection.components.extractor import (
|
|
45
49
|
ReflectionExtractor,
|
|
@@ -81,23 +85,63 @@ class ReflectionService:
|
|
|
81
85
|
):
|
|
82
86
|
self.request_context = request_context
|
|
83
87
|
self.client = llm_client
|
|
88
|
+
# Per-persist state threaded from the write-plan into the (verbatim)
|
|
89
|
+
# apply loop's ``_replace_*`` helpers without changing their signatures.
|
|
90
|
+
# Empty on the direct-call / ``.run()``-without-compute paths, which then
|
|
91
|
+
# build+embed the replacement row inline exactly as before.
|
|
92
|
+
self._pending_replacement_profiles: dict[str, UserProfile] = {}
|
|
93
|
+
self._pending_replacement_playbooks: dict[int, UserPlaybook] = {}
|
|
94
|
+
self._pending_aggregation_successor_ids: list[int] = []
|
|
95
|
+
# Partial result threaded out of compute's four no-write paths to run().
|
|
96
|
+
self._pending_partial_result: ReflectionResult | None = None
|
|
84
97
|
|
|
85
98
|
def run(self, request: ReflectionServiceRequest) -> ReflectionResult:
|
|
86
|
-
"""Execute one reflection pass.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
"""Execute one reflection pass (compute → persist → side-effects).
|
|
100
|
+
|
|
101
|
+
Behavior-identical wrapper over the split: ``compute`` does all the
|
|
102
|
+
read + LLM work (no learning write), ``persist`` applies the resolved
|
|
103
|
+
write-plan (rows + bookmark) and ``emit_side_effects`` fires the
|
|
104
|
+
post-write billing + aggregation triggers. Always returns a
|
|
105
|
+
``ReflectionResult`` describing what happened. Routine failure modes
|
|
106
|
+
(gate closed, no citations, missing rows, LLM error) do not raise.
|
|
107
|
+
Storage exceptions propagate.
|
|
108
|
+
"""
|
|
109
|
+
self._pending_partial_result = None
|
|
110
|
+
plan = self.compute(request)
|
|
111
|
+
if plan is None:
|
|
112
|
+
# The four no-write paths (config disabled, storage None, gate
|
|
113
|
+
# closed, extractor failure) thread their partial result out here.
|
|
114
|
+
return self._pending_partial_result or ReflectionResult()
|
|
115
|
+
self.persist(plan)
|
|
116
|
+
self.emit_side_effects(plan)
|
|
117
|
+
return plan.result
|
|
118
|
+
|
|
119
|
+
def compute(self, request: ReflectionServiceRequest) -> ReflectionWritePlan | None:
|
|
120
|
+
"""Resolve one reflection pass into a write-plan, issuing NO write.
|
|
121
|
+
|
|
122
|
+
Runs the gate, window pull, citation filter, cited-row resolve and the
|
|
123
|
+
reflection LLM call, then precomputes the replacement rows' embeddings
|
|
124
|
+
(V2) so persist embeds nothing inside the fence.
|
|
125
|
+
|
|
126
|
+
Returns ``None`` (nothing to persist) on the four no-write paths —
|
|
127
|
+
config disabled, storage ``None``, gate closed, extractor failure —
|
|
128
|
+
stashing the partial ``ReflectionResult`` on ``self`` for ``run()``.
|
|
129
|
+
Returns a ``ReflectionWritePlan`` on all five bookmark-advance paths
|
|
130
|
+
(the four early-advance returns carry ``decisions=[]`` +
|
|
131
|
+
``advance_bookmark=True``; the normal path carries the resolved
|
|
132
|
+
decisions/rows + ``record_learnings=True``).
|
|
91
133
|
"""
|
|
92
134
|
result = ReflectionResult()
|
|
93
135
|
config = self.request_context.configurator.get_config()
|
|
94
136
|
reflection_config = config.reflection_config if config else None
|
|
95
137
|
if reflection_config is not None and not reflection_config.enabled:
|
|
96
|
-
|
|
138
|
+
self._pending_partial_result = result
|
|
139
|
+
return None
|
|
97
140
|
|
|
98
141
|
storage = self.request_context.storage
|
|
99
142
|
if storage is None:
|
|
100
|
-
|
|
143
|
+
self._pending_partial_result = result
|
|
144
|
+
return None
|
|
101
145
|
|
|
102
146
|
window_size = config.window_size if config else 10
|
|
103
147
|
stride_size = config.stride_size if config else 5
|
|
@@ -117,7 +161,8 @@ class ReflectionService:
|
|
|
117
161
|
)
|
|
118
162
|
new_count = sum(len(m.interactions) for m in new_models)
|
|
119
163
|
if new_count < stride_size:
|
|
120
|
-
|
|
164
|
+
self._pending_partial_result = result
|
|
165
|
+
return None
|
|
121
166
|
result.gate_open = True
|
|
122
167
|
|
|
123
168
|
# Pull window of last window_size interactions for the user.
|
|
@@ -128,23 +173,16 @@ class ReflectionService:
|
|
|
128
173
|
)
|
|
129
174
|
window_interactions = _flatten(window_models)
|
|
130
175
|
if not window_interactions:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
processed_interactions=[],
|
|
134
|
-
user_id=request.user_id,
|
|
135
|
-
)
|
|
136
|
-
return result
|
|
176
|
+
# Empty window: advance the bookmark over nothing (persist).
|
|
177
|
+
return self._advance_only_plan(request, result, bookmark_interactions=[])
|
|
137
178
|
|
|
138
179
|
citations = _collect_citations(window_interactions)
|
|
139
180
|
result.cited_count = len(citations)
|
|
140
181
|
if not citations:
|
|
141
182
|
# Advance the bookmark — we did examine this window.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
processed_interactions=window_interactions,
|
|
145
|
-
user_id=request.user_id,
|
|
183
|
+
return self._advance_only_plan(
|
|
184
|
+
request, result, bookmark_interactions=window_interactions
|
|
146
185
|
)
|
|
147
|
-
return result
|
|
148
186
|
|
|
149
187
|
# Post-horizon filter: only send citations with enough follow-up context.
|
|
150
188
|
post_horizon_size = (
|
|
@@ -159,16 +197,15 @@ class ReflectionService:
|
|
|
159
197
|
deferred_count = len(citations) - len(eligible)
|
|
160
198
|
result.skipped_count += deferred_count
|
|
161
199
|
if not eligible:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
processed_interactions=window_interactions,
|
|
165
|
-
user_id=request.user_id,
|
|
200
|
+
return self._advance_only_plan(
|
|
201
|
+
request, result, bookmark_interactions=window_interactions
|
|
166
202
|
)
|
|
167
|
-
return result
|
|
168
203
|
|
|
169
204
|
eligible_citations = [e.citation for e in eligible]
|
|
170
|
-
horizon_by_key = {
|
|
171
|
-
|
|
205
|
+
horizon_by_key: dict[tuple[str, str], bool] = {
|
|
206
|
+
key: e.has_full_horizon
|
|
207
|
+
for e in eligible
|
|
208
|
+
if (key := _reflection_citation_key(e.citation)) is not None
|
|
172
209
|
}
|
|
173
210
|
cited_profiles, cited_playbooks, missing = self._resolve_cited_rows(
|
|
174
211
|
user_id=request.user_id,
|
|
@@ -177,12 +214,9 @@ class ReflectionService:
|
|
|
177
214
|
result.skipped_count += missing
|
|
178
215
|
result.considered_count = len(cited_profiles) + len(cited_playbooks)
|
|
179
216
|
if result.considered_count == 0:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
processed_interactions=window_interactions,
|
|
183
|
-
user_id=request.user_id,
|
|
217
|
+
return self._advance_only_plan(
|
|
218
|
+
request, result, bookmark_interactions=window_interactions
|
|
184
219
|
)
|
|
185
|
-
return result
|
|
186
220
|
|
|
187
221
|
agent_context = (config.agent_context_prompt or "") if config else ""
|
|
188
222
|
model_override = reflection_config.model if reflection_config else None
|
|
@@ -213,7 +247,8 @@ class ReflectionService:
|
|
|
213
247
|
request.user_id,
|
|
214
248
|
)
|
|
215
249
|
# Don't advance bookmark — let the next publish retry.
|
|
216
|
-
|
|
250
|
+
self._pending_partial_result = result
|
|
251
|
+
return None
|
|
217
252
|
|
|
218
253
|
result.ran = True
|
|
219
254
|
profiles_by_id = {p.profile_id: p for p in cited_profiles}
|
|
@@ -225,22 +260,76 @@ class ReflectionService:
|
|
|
225
260
|
else _DEFAULT_MAX_REVISIONS_PER_PASS
|
|
226
261
|
)
|
|
227
262
|
|
|
228
|
-
|
|
263
|
+
# Collapse ≥2 decisions on the SAME target to the first (the LLM emits one
|
|
264
|
+
# decision per cited row, but nothing constrains it to one per target).
|
|
265
|
+
# Two same-target revisions produce torn state in persist: D1 supersedes
|
|
266
|
+
# the incumbent, D2 reuses the SAME successor uuid (keyed by cited id),
|
|
267
|
+
# finds the incumbent already superseded, its CAS misses, and its cleanup
|
|
268
|
+
# deletes the live successor — a dangling ``superseded_by`` + double-count.
|
|
269
|
+
# Deduplicating at the source keeps _precompute_replacement_rows and the
|
|
270
|
+
# persist apply loop in lockstep and makes that state unreachable.
|
|
271
|
+
decisions = _dedup_decisions_by_target(output.decisions)
|
|
272
|
+
|
|
273
|
+
# V2: precompute the replacement rows' embeddings here (outside any
|
|
274
|
+
# writer transaction) so persist inserts them with skip_embedding=True.
|
|
275
|
+
replacement_profiles, replacement_playbooks = self._precompute_replacement_rows(
|
|
276
|
+
request=request,
|
|
277
|
+
decisions=decisions,
|
|
278
|
+
profiles_by_id=profiles_by_id,
|
|
279
|
+
playbooks_by_id=playbooks_by_id,
|
|
280
|
+
max_revisions_per_pass=max_revisions_per_pass,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return ReflectionWritePlan(
|
|
284
|
+
request=request,
|
|
285
|
+
result=result,
|
|
286
|
+
decisions=decisions,
|
|
287
|
+
profiles_by_id=profiles_by_id,
|
|
288
|
+
playbooks_by_id=playbooks_by_id,
|
|
289
|
+
max_revisions_per_pass=max_revisions_per_pass,
|
|
290
|
+
bookmark_interactions=window_interactions,
|
|
291
|
+
advance_bookmark=True,
|
|
292
|
+
record_learnings=True,
|
|
293
|
+
replacement_profiles=replacement_profiles,
|
|
294
|
+
replacement_playbooks=replacement_playbooks,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def persist(self, plan: ReflectionWritePlan) -> None:
|
|
298
|
+
"""Apply the resolved write-plan inside the caller's fence.
|
|
299
|
+
|
|
300
|
+
Runs the cap/validate/CAS apply loop verbatim (mutating ``plan.result``
|
|
301
|
+
in place), inserting replacement rows with ``skip_embedding=True`` (their
|
|
302
|
+
embeddings were precomputed in ``compute``), then advances the reflection
|
|
303
|
+
stride-bookmark iff ``plan.advance_bookmark``. Playbook aggregation
|
|
304
|
+
triggers are only *collected* here (dispatched post-commit in
|
|
305
|
+
``emit_side_effects``) so they never fire on a rolled-back job.
|
|
306
|
+
"""
|
|
307
|
+
request = plan.request
|
|
308
|
+
result = plan.result
|
|
309
|
+
# Thread the precomputed replacement rows + aggregation-trigger sink into
|
|
310
|
+
# the verbatim apply loop's _replace_* helpers.
|
|
311
|
+
self._pending_replacement_profiles = plan.replacement_profiles
|
|
312
|
+
self._pending_replacement_playbooks = plan.replacement_playbooks
|
|
313
|
+
self._pending_aggregation_successor_ids = plan.aggregation_successor_ids
|
|
314
|
+
|
|
315
|
+
for decision in plan.decisions:
|
|
229
316
|
if not _is_revision(decision):
|
|
230
317
|
result.no_change_count += 1
|
|
231
318
|
continue
|
|
232
319
|
# Per-pass cap: once we've applied max_revisions_per_pass
|
|
233
320
|
# revisions, skip any further revision-intent decisions.
|
|
234
|
-
if result.revised_count >= max_revisions_per_pass:
|
|
321
|
+
if result.revised_count >= plan.max_revisions_per_pass:
|
|
235
322
|
result.capped_count += 1
|
|
236
323
|
continue
|
|
237
324
|
try:
|
|
238
|
-
self._validate_decision(
|
|
325
|
+
self._validate_decision(
|
|
326
|
+
decision, plan.profiles_by_id, plan.playbooks_by_id
|
|
327
|
+
)
|
|
239
328
|
applied = self._apply_revision(
|
|
240
329
|
request=request,
|
|
241
330
|
decision=decision,
|
|
242
|
-
profiles_by_id=profiles_by_id,
|
|
243
|
-
playbooks_by_id=playbooks_by_id,
|
|
331
|
+
profiles_by_id=plan.profiles_by_id,
|
|
332
|
+
playbooks_by_id=plan.playbooks_by_id,
|
|
244
333
|
)
|
|
245
334
|
except Exception as exc: # noqa: BLE001 — per-decision isolation
|
|
246
335
|
result.failed_count += 1
|
|
@@ -271,37 +360,244 @@ class ReflectionService:
|
|
|
271
360
|
else:
|
|
272
361
|
result.skipped_count += 1
|
|
273
362
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
363
|
+
if plan.advance_bookmark:
|
|
364
|
+
mgr = OperationStateManager(
|
|
365
|
+
self.request_context.storage, # type: ignore[arg-type]
|
|
366
|
+
self.request_context.org_id,
|
|
367
|
+
REFLECTION_OPERATION_NAME,
|
|
368
|
+
)
|
|
369
|
+
mgr.update_extractor_bookmark(
|
|
370
|
+
REFLECTION_OPERATION_NAME,
|
|
371
|
+
processed_interactions=plan.bookmark_interactions,
|
|
372
|
+
user_id=request.user_id,
|
|
373
|
+
)
|
|
279
374
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
375
|
+
def emit_side_effects(self, plan: ReflectionWritePlan) -> None:
|
|
376
|
+
"""Fire post-commit billing + aggregation triggers for a winning job.
|
|
377
|
+
|
|
378
|
+
Only invoked after the persist fence commits, so nothing here runs for
|
|
379
|
+
a superseded / rolled-back job (phantom-billing gate).
|
|
380
|
+
"""
|
|
381
|
+
request = plan.request
|
|
382
|
+
result = plan.result
|
|
383
|
+
if plan.record_learnings:
|
|
384
|
+
self._record_learnings_generated(request, result)
|
|
385
|
+
logger.info(
|
|
386
|
+
"event=reflection_done user_id=%s gate_open=%s ran=%s "
|
|
387
|
+
"cited=%d considered=%d no_change=%d revised=%d "
|
|
388
|
+
"trigger_revised=%d content_revised=%d ttl_changed=%d capped=%d "
|
|
389
|
+
"skipped=%d failed=%d",
|
|
390
|
+
request.user_id,
|
|
391
|
+
result.gate_open,
|
|
392
|
+
result.ran,
|
|
393
|
+
result.cited_count,
|
|
394
|
+
result.considered_count,
|
|
395
|
+
result.no_change_count,
|
|
396
|
+
result.revised_count,
|
|
397
|
+
result.trigger_revised_count,
|
|
398
|
+
result.content_revised_count,
|
|
399
|
+
result.ttl_changed_count,
|
|
400
|
+
result.capped_count,
|
|
401
|
+
result.skipped_count,
|
|
402
|
+
result.failed_count,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
self._dispatch_aggregation_triggers(plan.aggregation_successor_ids)
|
|
406
|
+
|
|
407
|
+
def _advance_only_plan(
|
|
408
|
+
self,
|
|
409
|
+
request: ReflectionServiceRequest,
|
|
410
|
+
result: ReflectionResult,
|
|
411
|
+
*,
|
|
412
|
+
bookmark_interactions: list[Interaction],
|
|
413
|
+
) -> ReflectionWritePlan:
|
|
414
|
+
"""Build a no-decision plan that only advances the bookmark in persist."""
|
|
415
|
+
return ReflectionWritePlan(
|
|
416
|
+
request=request,
|
|
417
|
+
result=result,
|
|
418
|
+
decisions=[],
|
|
419
|
+
profiles_by_id={},
|
|
420
|
+
playbooks_by_id={},
|
|
421
|
+
max_revisions_per_pass=0,
|
|
422
|
+
bookmark_interactions=bookmark_interactions,
|
|
423
|
+
advance_bookmark=True,
|
|
424
|
+
record_learnings=False,
|
|
298
425
|
)
|
|
299
|
-
|
|
426
|
+
|
|
427
|
+
def _dispatch_aggregation_triggers(self, successor_ids: list[int]) -> None:
|
|
428
|
+
"""Best-effort user-playbook aggregation trigger for each successor.
|
|
429
|
+
|
|
430
|
+
Runs post-commit (outside the persist fence). Reloads each successor to
|
|
431
|
+
read its ``agent_version``; a reload/trigger failure is logged but never
|
|
432
|
+
propagates, exactly as the pre-split inline trigger behaved.
|
|
433
|
+
"""
|
|
434
|
+
storage = self.request_context.storage
|
|
435
|
+
if storage is None:
|
|
436
|
+
return
|
|
437
|
+
for successor_id in successor_ids:
|
|
438
|
+
try:
|
|
439
|
+
successor = storage.get_user_playbook_by_id(successor_id)
|
|
440
|
+
if successor is not None and successor.agent_version:
|
|
441
|
+
maybe_trigger_user_playbook_aggregation(
|
|
442
|
+
request_context=self.request_context,
|
|
443
|
+
llm_client=self.client,
|
|
444
|
+
agent_version=successor.agent_version,
|
|
445
|
+
reason="reflection",
|
|
446
|
+
)
|
|
447
|
+
except Exception: # noqa: BLE001
|
|
448
|
+
logger.exception(
|
|
449
|
+
"event=reflection_aggregation_trigger_failed kind=playbook "
|
|
450
|
+
"successor_id=%s",
|
|
451
|
+
successor_id,
|
|
452
|
+
)
|
|
300
453
|
|
|
301
454
|
# ------------------------------------------------------------------
|
|
302
455
|
# Helpers
|
|
303
456
|
# ------------------------------------------------------------------
|
|
304
457
|
|
|
458
|
+
def _precompute_replacement_rows(
|
|
459
|
+
self,
|
|
460
|
+
*,
|
|
461
|
+
request: ReflectionServiceRequest,
|
|
462
|
+
decisions: list[ReflectionDecision],
|
|
463
|
+
profiles_by_id: dict[str, UserProfile],
|
|
464
|
+
playbooks_by_id: dict[int, UserPlaybook],
|
|
465
|
+
max_revisions_per_pass: int,
|
|
466
|
+
) -> tuple[dict[str, UserProfile], dict[int, UserPlaybook]]:
|
|
467
|
+
"""Build + embed the replacement rows persist will insert (V2).
|
|
468
|
+
|
|
469
|
+
Mirrors the persist apply loop's cap / validate / resolve so it builds
|
|
470
|
+
exactly the rows persist applies (no more, no fewer), embedding them via
|
|
471
|
+
``precompute_*_embeddings`` outside any writer transaction. Persist then
|
|
472
|
+
inserts each with ``skip_embedding=True``. Keyed by the **cited** row id
|
|
473
|
+
(``profile_id`` / ``user_playbook_id``) so ``_replace_*`` can look up the
|
|
474
|
+
successor for the incumbent it is superseding.
|
|
475
|
+
"""
|
|
476
|
+
storage = self.request_context.storage
|
|
477
|
+
replacement_profiles: dict[str, UserProfile] = {}
|
|
478
|
+
replacement_playbooks: dict[int, UserPlaybook] = {}
|
|
479
|
+
if storage is None:
|
|
480
|
+
return replacement_profiles, replacement_playbooks
|
|
481
|
+
|
|
482
|
+
applied = 0
|
|
483
|
+
for decision in decisions:
|
|
484
|
+
if not _is_revision(decision):
|
|
485
|
+
continue
|
|
486
|
+
if applied >= max_revisions_per_pass:
|
|
487
|
+
# Cap reached — every remaining revision is capped in persist too.
|
|
488
|
+
break
|
|
489
|
+
try:
|
|
490
|
+
self._validate_decision(decision, profiles_by_id, playbooks_by_id)
|
|
491
|
+
except Exception: # noqa: BLE001, S112 — mirrors persist per-decision isolation
|
|
492
|
+
# A validation failure never applies a revision (persist counts
|
|
493
|
+
# it as failed, not revised) so it consumes no cap and builds no
|
|
494
|
+
# row — skip it here too.
|
|
495
|
+
continue
|
|
496
|
+
if decision.target_kind == "profile":
|
|
497
|
+
cited_p = profiles_by_id.get(decision.target_id)
|
|
498
|
+
if cited_p is None:
|
|
499
|
+
continue
|
|
500
|
+
new_profile = self._build_replacement_profile(
|
|
501
|
+
request, decision, cited_p
|
|
502
|
+
)
|
|
503
|
+
storage.precompute_profile_embeddings([new_profile])
|
|
504
|
+
replacement_profiles[cited_p.profile_id] = new_profile
|
|
505
|
+
applied += 1
|
|
506
|
+
else:
|
|
507
|
+
try:
|
|
508
|
+
target_id = int(decision.target_id)
|
|
509
|
+
except (TypeError, ValueError):
|
|
510
|
+
continue
|
|
511
|
+
cited_pb = playbooks_by_id.get(target_id)
|
|
512
|
+
if cited_pb is None:
|
|
513
|
+
continue
|
|
514
|
+
new_playbook = self._build_replacement_playbook(
|
|
515
|
+
request, decision, cited_pb
|
|
516
|
+
)
|
|
517
|
+
storage.precompute_user_playbook_embeddings([new_playbook])
|
|
518
|
+
replacement_playbooks[cited_pb.user_playbook_id] = new_playbook
|
|
519
|
+
applied += 1
|
|
520
|
+
return replacement_profiles, replacement_playbooks
|
|
521
|
+
|
|
522
|
+
def _build_replacement_profile(
|
|
523
|
+
self,
|
|
524
|
+
request: ReflectionServiceRequest, # noqa: ARG002 — parity with _build_replacement_playbook
|
|
525
|
+
decision: ReflectionDecision,
|
|
526
|
+
cited: UserProfile,
|
|
527
|
+
) -> UserProfile:
|
|
528
|
+
"""Construct the replacement profile row for a revision decision."""
|
|
529
|
+
now_ts = int(datetime.now(UTC).timestamp())
|
|
530
|
+
return UserProfile(
|
|
531
|
+
profile_id=str(uuid.uuid4()),
|
|
532
|
+
user_id=cited.user_id,
|
|
533
|
+
content=decision.new_content or cited.content,
|
|
534
|
+
last_modified_timestamp=now_ts,
|
|
535
|
+
generated_from_request_id=cited.generated_from_request_id,
|
|
536
|
+
profile_time_to_live=(
|
|
537
|
+
decision.new_profile_time_to_live or cited.profile_time_to_live
|
|
538
|
+
),
|
|
539
|
+
custom_features=cited.custom_features,
|
|
540
|
+
source=cited.source,
|
|
541
|
+
status=None,
|
|
542
|
+
extractor_names=cited.extractor_names,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def _build_replacement_playbook(
|
|
546
|
+
self,
|
|
547
|
+
request: ReflectionServiceRequest,
|
|
548
|
+
decision: ReflectionDecision,
|
|
549
|
+
cited: UserPlaybook,
|
|
550
|
+
) -> UserPlaybook:
|
|
551
|
+
"""Construct the replacement playbook row for a revision decision."""
|
|
552
|
+
owning_user_id = cited.user_id or request.user_id
|
|
553
|
+
return UserPlaybook(
|
|
554
|
+
user_playbook_id=0, # auto-assigned by storage
|
|
555
|
+
user_id=owning_user_id,
|
|
556
|
+
agent_version=cited.agent_version or request.agent_version,
|
|
557
|
+
request_id=cited.request_id,
|
|
558
|
+
playbook_name=cited.playbook_name,
|
|
559
|
+
content=decision.new_content or cited.content,
|
|
560
|
+
trigger=(
|
|
561
|
+
decision.new_trigger
|
|
562
|
+
if decision.new_trigger is not None
|
|
563
|
+
else cited.trigger
|
|
564
|
+
),
|
|
565
|
+
rationale=(
|
|
566
|
+
decision.new_rationale
|
|
567
|
+
if decision.new_rationale is not None
|
|
568
|
+
else cited.rationale
|
|
569
|
+
),
|
|
570
|
+
status=None,
|
|
571
|
+
source=cited.source,
|
|
572
|
+
source_interaction_ids=list(cited.source_interaction_ids),
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def _record_learnings_generated(
|
|
576
|
+
self, request: ReflectionServiceRequest, result: ReflectionResult
|
|
577
|
+
) -> None:
|
|
578
|
+
from reflexio.server.billing_meter import emit_learnings_generated
|
|
579
|
+
|
|
580
|
+
emit_learnings_generated(
|
|
581
|
+
org_id=self.request_context.org_id,
|
|
582
|
+
configurator=self.request_context.configurator,
|
|
583
|
+
count=result.revised_count,
|
|
584
|
+
source="reflection",
|
|
585
|
+
pipeline="reflection",
|
|
586
|
+
user_id=request.user_id,
|
|
587
|
+
request_id=request.request_id,
|
|
588
|
+
agent_version=request.agent_version,
|
|
589
|
+
metadata={
|
|
590
|
+
"cited_count": result.cited_count,
|
|
591
|
+
"considered_count": result.considered_count,
|
|
592
|
+
"trigger_revised_count": result.trigger_revised_count,
|
|
593
|
+
"content_revised_count": result.content_revised_count,
|
|
594
|
+
"ttl_changed_count": result.ttl_changed_count,
|
|
595
|
+
"capped_count": result.capped_count,
|
|
596
|
+
"skipped_count": result.skipped_count,
|
|
597
|
+
"failed_count": result.failed_count,
|
|
598
|
+
},
|
|
599
|
+
)
|
|
600
|
+
|
|
305
601
|
def _resolve_cited_rows(
|
|
306
602
|
self,
|
|
307
603
|
*,
|
|
@@ -324,9 +620,10 @@ class ReflectionService:
|
|
|
324
620
|
wanted_profile_ids: set[str] = set()
|
|
325
621
|
wanted_playbook_ids: set[int] = set()
|
|
326
622
|
for c in citations:
|
|
327
|
-
|
|
623
|
+
target_kind = _reflection_target_kind(c)
|
|
624
|
+
if target_kind == "profile":
|
|
328
625
|
wanted_profile_ids.add(c.real_id)
|
|
329
|
-
elif
|
|
626
|
+
elif target_kind == "playbook":
|
|
330
627
|
try:
|
|
331
628
|
wanted_playbook_ids.add(int(c.real_id))
|
|
332
629
|
except (TypeError, ValueError):
|
|
@@ -465,20 +762,15 @@ class ReflectionService:
|
|
|
465
762
|
storage = self.request_context.storage
|
|
466
763
|
if storage is None:
|
|
467
764
|
return False
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
),
|
|
478
|
-
custom_features=cited.custom_features,
|
|
479
|
-
source=cited.source,
|
|
480
|
-
status=None,
|
|
481
|
-
extractor_names=cited.extractor_names,
|
|
765
|
+
# Use the replacement row compute already built + embedded (durable /
|
|
766
|
+
# .run() path → skip_embedding=True), else build + embed inline (direct
|
|
767
|
+
# _replace_profile call with no compute-prepared plan).
|
|
768
|
+
precomputed = self._pending_replacement_profiles.get(cited.profile_id)
|
|
769
|
+
skip_embedding = precomputed is not None
|
|
770
|
+
new_profile = (
|
|
771
|
+
precomputed
|
|
772
|
+
if precomputed is not None
|
|
773
|
+
else self._build_replacement_profile(request, decision, cited)
|
|
482
774
|
)
|
|
483
775
|
_log_edit_magnitude(
|
|
484
776
|
kind="profile",
|
|
@@ -486,7 +778,9 @@ class ReflectionService:
|
|
|
486
778
|
old_content=cited.content,
|
|
487
779
|
new_content=new_profile.content,
|
|
488
780
|
)
|
|
489
|
-
storage.add_user_profile(
|
|
781
|
+
storage.add_user_profile(
|
|
782
|
+
cited.user_id, [new_profile], skip_embedding=skip_embedding
|
|
783
|
+
)
|
|
490
784
|
ctx = LineageContext(
|
|
491
785
|
op_kind="revise",
|
|
492
786
|
actor="reflection",
|
|
@@ -557,26 +851,15 @@ class ReflectionService:
|
|
|
557
851
|
if storage is None:
|
|
558
852
|
return False
|
|
559
853
|
owning_user_id = cited.user_id or request.user_id
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if decision.new_trigger is not None
|
|
570
|
-
else cited.trigger
|
|
571
|
-
),
|
|
572
|
-
rationale=(
|
|
573
|
-
decision.new_rationale
|
|
574
|
-
if decision.new_rationale is not None
|
|
575
|
-
else cited.rationale
|
|
576
|
-
),
|
|
577
|
-
status=None,
|
|
578
|
-
source=cited.source,
|
|
579
|
-
source_interaction_ids=list(cited.source_interaction_ids),
|
|
854
|
+
# Use the replacement row compute already built + embedded (durable /
|
|
855
|
+
# .run() path → skip_embedding=True), else build + embed inline (direct
|
|
856
|
+
# _replace_playbook call with no compute-prepared plan).
|
|
857
|
+
precomputed = self._pending_replacement_playbooks.get(cited.user_playbook_id)
|
|
858
|
+
skip_embedding = precomputed is not None
|
|
859
|
+
new_playbook = (
|
|
860
|
+
precomputed
|
|
861
|
+
if precomputed is not None
|
|
862
|
+
else self._build_replacement_playbook(request, decision, cited)
|
|
580
863
|
)
|
|
581
864
|
_log_edit_magnitude(
|
|
582
865
|
kind="playbook",
|
|
@@ -605,6 +888,7 @@ class ReflectionService:
|
|
|
605
888
|
new_playbook=new_playbook,
|
|
606
889
|
source=new_playbook.source or "reflection",
|
|
607
890
|
request_id=request.request_id,
|
|
891
|
+
skip_embedding=skip_embedding,
|
|
608
892
|
)
|
|
609
893
|
except Exception as exc: # noqa: BLE001
|
|
610
894
|
with sentry_tags(
|
|
@@ -624,6 +908,11 @@ class ReflectionService:
|
|
|
624
908
|
new_playbook.user_playbook_id,
|
|
625
909
|
)
|
|
626
910
|
return True
|
|
911
|
+
if archived > 0:
|
|
912
|
+
# Defer the user-playbook aggregation trigger to emit_side_effects
|
|
913
|
+
# (post-commit): collect the successor id so the trigger never fires
|
|
914
|
+
# on a rolled-back / superseded durable job.
|
|
915
|
+
self._pending_aggregation_successor_ids.append(archived)
|
|
627
916
|
if archived < 0:
|
|
628
917
|
with sentry_tags(
|
|
629
918
|
subsystem="reflection",
|
|
@@ -692,6 +981,37 @@ def _log_edit_magnitude(
|
|
|
692
981
|
)
|
|
693
982
|
|
|
694
983
|
|
|
984
|
+
def _dedup_decisions_by_target(
|
|
985
|
+
decisions: list[ReflectionDecision],
|
|
986
|
+
) -> list[ReflectionDecision]:
|
|
987
|
+
"""Keep only the first decision for each ``(target_kind, target_id)``.
|
|
988
|
+
|
|
989
|
+
The reflection LLM is prompted for one decision per cited row, but nothing
|
|
990
|
+
(schema or code) forbids it from emitting two decisions on the same target.
|
|
991
|
+
A second same-target revision is not just redundant — it corrupts state: the
|
|
992
|
+
replacement row is keyed by the cited id, so the second decision reuses the
|
|
993
|
+
first's successor uuid, re-inserts it, then supersede CAS-misses (the
|
|
994
|
+
incumbent is already superseded) and deletes the live successor, leaving a
|
|
995
|
+
dangling ``superseded_by`` pointer. Collapsing duplicates here — before both
|
|
996
|
+
``_precompute_replacement_rows`` and the persist apply loop consume the
|
|
997
|
+
decisions — makes that torn state unreachable and keeps the two loops (which
|
|
998
|
+
must build/apply exactly the same rows) in lockstep.
|
|
999
|
+
|
|
1000
|
+
Ids are compared as the canonical strings the prompt is fed
|
|
1001
|
+
(``profile_id`` / ``str(user_playbook_id)``), which is what the LLM echoes
|
|
1002
|
+
back as ``target_id``.
|
|
1003
|
+
"""
|
|
1004
|
+
seen: set[tuple[str, str]] = set()
|
|
1005
|
+
out: list[ReflectionDecision] = []
|
|
1006
|
+
for decision in decisions:
|
|
1007
|
+
key = (decision.target_kind, decision.target_id)
|
|
1008
|
+
if key in seen:
|
|
1009
|
+
continue
|
|
1010
|
+
seen.add(key)
|
|
1011
|
+
out.append(decision)
|
|
1012
|
+
return out
|
|
1013
|
+
|
|
1014
|
+
|
|
695
1015
|
def _is_revision(decision: ReflectionDecision) -> bool:
|
|
696
1016
|
"""Return True iff a revision field relevant to the target kind is set.
|
|
697
1017
|
|
|
@@ -725,14 +1045,16 @@ def _flatten(
|
|
|
725
1045
|
|
|
726
1046
|
|
|
727
1047
|
def _collect_citations(interactions: list[Interaction]) -> list[Citation]:
|
|
728
|
-
"""Pull distinct citations off Assistant interactions."""
|
|
729
|
-
seen: set[tuple[
|
|
1048
|
+
"""Pull distinct reflection-eligible citations off Assistant interactions."""
|
|
1049
|
+
seen: set[tuple[Literal["playbook", "profile"], str]] = set()
|
|
730
1050
|
out: list[Citation] = []
|
|
731
1051
|
for i in interactions:
|
|
732
1052
|
if i.role != "Assistant":
|
|
733
1053
|
continue
|
|
734
1054
|
for c in i.citations:
|
|
735
|
-
key = (c
|
|
1055
|
+
key = _reflection_citation_key(c)
|
|
1056
|
+
if key is None:
|
|
1057
|
+
continue
|
|
736
1058
|
if key in seen:
|
|
737
1059
|
continue
|
|
738
1060
|
seen.add(key)
|
|
@@ -740,6 +1062,27 @@ def _collect_citations(interactions: list[Interaction]) -> list[Citation]:
|
|
|
740
1062
|
return out
|
|
741
1063
|
|
|
742
1064
|
|
|
1065
|
+
def _reflection_target_kind(
|
|
1066
|
+
citation: Citation,
|
|
1067
|
+
) -> Literal["playbook", "profile"] | None:
|
|
1068
|
+
if citation.kind == "user_playbook":
|
|
1069
|
+
return "playbook"
|
|
1070
|
+
if citation.kind == "playbook":
|
|
1071
|
+
return "playbook"
|
|
1072
|
+
if citation.kind == "profile":
|
|
1073
|
+
return "profile"
|
|
1074
|
+
return None
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _reflection_citation_key(
|
|
1078
|
+
citation: Citation,
|
|
1079
|
+
) -> tuple[Literal["playbook", "profile"], str] | None:
|
|
1080
|
+
target_kind = _reflection_target_kind(citation)
|
|
1081
|
+
if target_kind is None:
|
|
1082
|
+
return None
|
|
1083
|
+
return (target_kind, citation.real_id)
|
|
1084
|
+
|
|
1085
|
+
|
|
743
1086
|
@dataclass(frozen=True)
|
|
744
1087
|
class _EligibleCitation:
|
|
745
1088
|
"""A citation that passed the post-horizon filter.
|
|
@@ -768,8 +1111,8 @@ def _filter_citations_by_horizon(
|
|
|
768
1111
|
) -> list[_EligibleCitation]:
|
|
769
1112
|
"""Filter citations to those that have enough post-citation context.
|
|
770
1113
|
|
|
771
|
-
For each unique
|
|
772
|
-
in the window (maximizes follow-up turns) and decides:
|
|
1114
|
+
For each unique reflection citation key, finds the **earliest**
|
|
1115
|
+
occurrence in the window (maximizes follow-up turns) and decides:
|
|
773
1116
|
|
|
774
1117
|
- ``after_count >= post_horizon_size`` → eligible, ``has_full_horizon=True``.
|
|
775
1118
|
- ``position < stride_size`` → eligible (last-chance, about to fall
|
|
@@ -792,15 +1135,22 @@ def _filter_citations_by_horizon(
|
|
|
792
1135
|
list[_EligibleCitation]: Citations to send to the LLM, each
|
|
793
1136
|
paired with its window position and horizon flag.
|
|
794
1137
|
"""
|
|
795
|
-
seen: dict[tuple[
|
|
1138
|
+
seen: dict[tuple[Literal["playbook", "profile"], str], int] = {}
|
|
796
1139
|
for idx, interaction in enumerate(window):
|
|
797
1140
|
if interaction.role != "Assistant":
|
|
798
1141
|
continue
|
|
799
1142
|
for c in interaction.citations:
|
|
800
|
-
key = (c
|
|
1143
|
+
key = _reflection_citation_key(c)
|
|
1144
|
+
if key is None:
|
|
1145
|
+
continue
|
|
801
1146
|
seen.setdefault(key, idx) # earliest occurrence only
|
|
802
1147
|
|
|
803
|
-
citation_by_key
|
|
1148
|
+
citation_by_key: dict[tuple[Literal["playbook", "profile"], str], Citation] = {}
|
|
1149
|
+
for citation in citations:
|
|
1150
|
+
key = _reflection_citation_key(citation)
|
|
1151
|
+
if key is None:
|
|
1152
|
+
continue
|
|
1153
|
+
citation_by_key.setdefault(key, citation)
|
|
804
1154
|
|
|
805
1155
|
out: list[_EligibleCitation] = []
|
|
806
1156
|
for key, position in seen.items():
|