claude-smart 0.2.46 → 0.2.48

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 (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -11
  3. package/bin/claude-smart.js +290 -68
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +11 -10
  8. package/plugin/dashboard/app/layout.tsx +20 -0
  9. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  10. package/plugin/dashboard/package-lock.json +61 -390
  11. package/plugin/dashboard/package.json +2 -2
  12. package/plugin/opencode/dist/server.mjs +76 -2
  13. package/plugin/opencode/server.mts +79 -2
  14. package/plugin/pyproject.toml +6 -2
  15. package/plugin/scripts/smart-install.sh +7 -1
  16. package/plugin/src/claude_smart/cli.py +210 -22
  17. package/plugin/src/claude_smart/context_format.py +9 -9
  18. package/plugin/src/claude_smart/cs_cite.py +66 -19
  19. package/plugin/uv.lock +5 -5
  20. package/plugin/vendor/reflexio/.env.example +7 -0
  21. package/plugin/vendor/reflexio/README.md +3 -3
  22. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  23. package/plugin/vendor/reflexio/reflexio/README.md +11 -6
  24. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  25. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  26. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  27. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  28. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  29. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  30. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  31. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  32. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  33. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  34. package/plugin/vendor/reflexio/reflexio/lib/_search.py +27 -5
  35. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  36. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  37. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  38. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  39. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  40. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +37 -0
  41. package/plugin/vendor/reflexio/reflexio/server/README.md +38 -9
  42. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  43. package/plugin/vendor/reflexio/reflexio/server/api.py +274 -3267
  44. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  45. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  46. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  47. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  48. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  49. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  50. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  51. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  52. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  53. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  54. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  55. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  56. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  57. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  58. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  59. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +57 -5
  60. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  61. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  62. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  63. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  64. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  67. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  68. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  69. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  70. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  71. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  72. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  73. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  74. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  75. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  76. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  77. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/README.md +5 -2
  79. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  87. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  88. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  89. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  91. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +385 -78
  95. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  96. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +121 -525
  98. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  102. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  103. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +43 -6
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +58 -351
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +452 -0
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -4
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1126
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +955 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +844 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -9
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +57 -86
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  169. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  170. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import hashlib
4
3
  import logging
5
4
  import os
6
5
  import time
@@ -24,6 +23,20 @@ from reflexio.models.config_schema import (
24
23
  from reflexio.server.api_endpoints.request_context import RequestContext
25
24
  from reflexio.server.llm.litellm_client import LiteLLMClient
26
25
  from reflexio.server.services.operation_state_utils import OperationStateManager
26
+ from reflexio.server.services.playbook.aggregation_prompt_processing import (
27
+ AggregationPromptProcessingContext,
28
+ AggregationPromptProcessor,
29
+ )
30
+ from reflexio.server.services.playbook.components import (
31
+ aggregator_clustering,
32
+ aggregator_prompt_formatting,
33
+ )
34
+ from reflexio.server.services.playbook.components.aggregator_clustering import (
35
+ CLUSTERING_ALGORITHM_THRESHOLD,
36
+ )
37
+ from reflexio.server.services.playbook.components.aggregator_postprocessing import (
38
+ AggregationPostProcessing,
39
+ )
27
40
  from reflexio.server.services.playbook.playbook_service_constants import (
28
41
  PlaybookServiceConstants,
29
42
  )
@@ -33,18 +46,12 @@ from reflexio.server.services.playbook.playbook_service_utils import (
33
46
  StructuredPlaybookContent,
34
47
  ensure_playbook_content,
35
48
  )
36
- from reflexio.server.services.playbook.user_detail_stripping import UserDetailStripper
37
49
  from reflexio.server.services.service_utils import log_model_response
38
50
  from reflexio.server.tracing import capture_anomaly, sentry_tags
39
51
  from reflexio.server.usage_metrics import record_usage_event
40
52
 
41
53
  logger = logging.getLogger(__name__)
42
54
 
43
- # Threshold for switching between clustering algorithms
44
- # Below this, use Agglomerative (works better with small datasets)
45
- # Above this, use HDBSCAN (scales better, handles noise)
46
- CLUSTERING_ALGORITHM_THRESHOLD = 50
47
-
48
55
 
49
56
  class PlaybookAggregator:
50
57
  def __init__(
@@ -52,33 +59,23 @@ class PlaybookAggregator:
52
59
  llm_client: LiteLLMClient,
53
60
  request_context: RequestContext,
54
61
  agent_version: str,
55
- user_detail_stripper: UserDetailStripper | None = None,
62
+ aggregation_prompt_processor: AggregationPromptProcessor | None = None,
56
63
  ) -> None:
57
64
  self.client = llm_client
58
65
  self.storage = request_context.storage
59
66
  self.configurator = request_context.configurator
60
67
  self.request_context = request_context
61
68
  self.agent_version = agent_version
62
- self.user_detail_stripper = user_detail_stripper
63
- prompt_extra_instructions = getattr(
64
- user_detail_stripper, "prompt_extra_instructions", None
65
- )
66
- if not isinstance(prompt_extra_instructions, str):
67
- prompt_extra_instructions = None
68
- self.aggregation_prompt_extra_instructions = (
69
- self._format_prompt_extra_instructions(prompt_extra_instructions)
70
- )
69
+ self.aggregation_prompt_processor = aggregation_prompt_processor
70
+ # Cohesive pre/post-processing component (the enterprise redaction
71
+ # Protocol seam). Constructed from the SAME injected instance stored
72
+ # above — do NOT re-resolve the AGGREGATION_PROMPT_PROCESSOR ServiceKey.
73
+ self._postproc = AggregationPostProcessing(aggregation_prompt_processor)
71
74
 
72
75
  # ===============================
73
76
  # private methods - operation state
74
77
  # ===============================
75
78
 
76
- @staticmethod
77
- def _format_prompt_extra_instructions(instructions: str | None) -> str:
78
- if not instructions or not instructions.strip():
79
- return ""
80
- return f"{instructions.strip()}\n"
81
-
82
79
  def _create_state_manager(self) -> OperationStateManager:
83
80
  """
84
81
  Create an OperationStateManager for the playbook aggregator.
@@ -185,403 +182,74 @@ class PlaybookAggregator:
185
182
  last_processed_id=max_id,
186
183
  )
187
184
 
188
- def _format_cluster_input(self, cluster_playbooks: list[UserPlaybook]) -> str:
189
- """
190
- Format a cluster of playbooks for the aggregation prompt using per-item format.
191
-
192
- Each playbook is shown as a self-contained unit with content as the
193
- primary content, followed by optional structured fields as supplementary metadata.
194
-
195
- Args:
196
- cluster_playbooks: List of raw playbooks in this cluster
185
+ # ===============================
186
+ # private methods - aggregation pre/post-processing
187
+ # ===============================
188
+ # Bodies live on the AggregationPostProcessing component (self._postproc).
189
+ # These thin delegators are kept for OSS test call-sites that invoke them by
190
+ # name (test_playbook_aggregator.py). Internal callers use self._postproc.
197
191
 
198
- Returns:
199
- str: Formatted input for the aggregation prompt
200
- """
201
- blocks = []
202
- for idx, fb in enumerate(cluster_playbooks, 1):
203
- lines = [f"[{idx}]"]
204
- if fb.content:
205
- lines.append(f'Content: "{fb.content}"')
206
- if fb.trigger:
207
- lines.append(f'Trigger: "{fb.trigger}"')
208
- if fb.rationale:
209
- lines.append(f'Rationale: "{fb.rationale}"')
210
- blocks.append("\n".join(lines))
211
- return "\n\n".join(blocks) if blocks else "(No playbook items)"
212
-
213
- def _strip_prompt_field(
192
+ def _postprocess_aggregation_output(
214
193
  self,
215
- text: str | None,
216
- shared_mapping: dict[str, int],
217
- ) -> str | None:
218
- if text is None or self.user_detail_stripper is None:
219
- return text
220
- return self.user_detail_stripper.strip_user_details(
221
- text, shared_mapping=shared_mapping
222
- ).text
223
-
224
- def _strip_user_playbook_for_prompt(
225
- self,
226
- playbook: UserPlaybook,
227
- shared_mapping: dict[str, int],
228
- ) -> UserPlaybook:
229
- if self.user_detail_stripper is None:
230
- return playbook
231
- return playbook.model_copy(
232
- update={
233
- "content": self._strip_prompt_field(playbook.content, shared_mapping)
234
- or "",
235
- "trigger": self._strip_prompt_field(playbook.trigger, shared_mapping),
236
- "rationale": self._strip_prompt_field(
237
- playbook.rationale, shared_mapping
238
- ),
239
- }
240
- )
194
+ value: object,
195
+ processing_context: AggregationPromptProcessingContext | None = None,
196
+ ) -> tuple[object, int]:
197
+ return self._postproc._postprocess_aggregation_output(value, processing_context)
241
198
 
242
- def _sanitize_aggregation_output_text(
199
+ def _aggregation_prompt_extra_instructions_for_context(
243
200
  self,
244
- text: str | None,
245
- ) -> tuple[str | None, int]:
246
- if self.user_detail_stripper is None:
247
- return text, 0
248
- return self.user_detail_stripper.sanitize_aggregation_output_text(text)
249
-
250
- def _sanitize_aggregation_response(
251
- self, response: PlaybookAggregationOutput
252
- ) -> tuple[PlaybookAggregationOutput, int]:
253
- structured = response.playbook
254
- if structured is None:
255
- return response, 0
256
-
257
- updates: dict[str, str | None] = {}
258
- placeholder_count = 0
259
- for field_name, value in structured.model_dump().items():
260
- if not isinstance(value, str):
261
- continue
262
- sanitized, field_count = self._sanitize_aggregation_output_text(value)
263
- placeholder_count += field_count
264
- if sanitized != value:
265
- updates[field_name] = sanitized
266
-
267
- if not updates:
268
- return response, 0
269
- return response.model_copy(
270
- update={"playbook": structured.model_copy(update=updates)}
271
- ), placeholder_count
272
-
273
- def _sanitize_aggregation_log_value(self, value: object) -> tuple[object, int]:
274
- if isinstance(value, str):
275
- return self._sanitize_aggregation_output_text(value)
276
-
277
- if isinstance(value, dict):
278
- sanitized: dict[object, object] = {}
279
- placeholder_count = 0
280
- for key, item in value.items():
281
- sanitized_key, key_count = self._sanitize_aggregation_log_value(key)
282
- sanitized_item, item_count = self._sanitize_aggregation_log_value(item)
283
- sanitized[sanitized_key] = sanitized_item
284
- placeholder_count += key_count + item_count
285
- return sanitized, placeholder_count
286
-
287
- if isinstance(value, list):
288
- sanitized_items: list[object] = []
289
- placeholder_count = 0
290
- for item in value:
291
- sanitized_item, item_count = self._sanitize_aggregation_log_value(item)
292
- sanitized_items.append(sanitized_item)
293
- placeholder_count += item_count
294
- return sanitized_items, placeholder_count
295
-
296
- if isinstance(value, tuple):
297
- sanitized_items: list[object] = []
298
- placeholder_count = 0
299
- for item in value:
300
- sanitized_item, item_count = self._sanitize_aggregation_log_value(item)
301
- sanitized_items.append(sanitized_item)
302
- placeholder_count += item_count
303
- return tuple(sanitized_items), placeholder_count
304
-
305
- return value, 0
306
-
307
- def _record_placeholder_leakage(self, placeholder_count: int) -> None:
308
- if placeholder_count <= 0:
309
- return
310
- logger.warning(
311
- "Replaced %d residual user-detail placeholders in aggregated playbook output",
312
- placeholder_count,
201
+ processing_context: AggregationPromptProcessingContext | None,
202
+ ) -> str:
203
+ return self._postproc._aggregation_prompt_extra_instructions_for_context(
204
+ processing_context
313
205
  )
314
206
 
207
+ def _record_postprocessing_artifacts(self, artifact_count: int) -> None:
208
+ self._postproc._record_postprocessing_artifacts(artifact_count)
209
+
315
210
  @staticmethod
316
211
  def _get_direction_key(fb: UserPlaybook) -> str:
317
- """
318
- Extract a similarity key from a user playbook for grouping.
319
-
320
- Returns the raw content used for token-overlap comparison. Grouping is
321
- purely content-similarity based: under Option B a skill may legitimately
322
- hold mixed-orientation rules (do-rules and avoid-rules for different
323
- sub-aspects of one task), so whole-content polarity is NOT derived or
324
- gated here. Preserving distinct do/avoid rules when similar items are
325
- merged is the aggregation prompt's responsibility.
326
-
327
- Args:
328
- fb: A user playbook item
329
-
330
- Returns:
331
- str: Content used as the similarity key for grouping
332
- """
333
- return fb.content or ""
212
+ return aggregator_prompt_formatting.get_direction_key(fb)
334
213
 
335
214
  @staticmethod
336
215
  def _token_overlap(str1: str, str2: str, threshold: float = 0.6) -> bool:
337
- """
338
- Check if two strings have significant token overlap using asymmetric containment.
339
-
340
- Computes the ratio of shared tokens to the smaller set, so a short string
341
- contained in a longer one still counts as a match.
342
-
343
- Args:
344
- str1: First string
345
- str2: Second string
346
- threshold: Minimum overlap ratio
347
-
348
- Returns:
349
- bool: True if overlap ratio >= threshold
350
- """
351
- tokens1 = set(str1.lower().split())
352
- tokens2 = set(str2.lower().split())
353
- if not tokens1 or not tokens2:
354
- return False
355
- intersection = len(tokens1 & tokens2)
356
- overlap_ratio = max(intersection / len(tokens1), intersection / len(tokens2))
357
- return overlap_ratio >= threshold
216
+ return aggregator_prompt_formatting.token_overlap(str1, str2, threshold)
358
217
 
359
218
  @staticmethod
360
219
  def _group_playbooks_by_direction(
361
220
  cluster_playbooks: list[UserPlaybook],
362
221
  threshold: float = 0.6,
363
222
  ) -> list[list[UserPlaybook]]:
364
- """
365
- Group playbooks by similarity of their content.
366
-
367
- Uses greedy single-linkage: each playbook is assigned to the first
368
- existing group that has any member with sufficient token overlap.
369
- Groups are returned sorted by size descending (largest first).
370
-
371
- Grouping is purely content-similarity based and does NOT gate on a
372
- derived whole-content polarity. Under Option B a skill may hold
373
- mixed-orientation rules (do-rules and avoid-rules for different
374
- sub-aspects), and whole-content polarity is undefined for such a skill.
375
- Keeping a do-rule and an avoid-rule as distinct rules when similar items
376
- are merged into one skill is the aggregation prompt's responsibility,
377
- not a mechanical split here.
378
-
379
- Args:
380
- cluster_playbooks: List of raw playbooks to group
381
- threshold: Token overlap threshold for grouping
382
-
383
- Returns:
384
- list[list[UserPlaybook]]: Groups sorted by size descending
385
- """
386
- groups: list[list[UserPlaybook]] = []
387
-
388
- for fb in cluster_playbooks:
389
- key = PlaybookAggregator._get_direction_key(fb)
390
- matched = False
391
- for group in groups:
392
- if any(
393
- PlaybookAggregator._token_overlap(
394
- key,
395
- PlaybookAggregator._get_direction_key(group_fb),
396
- threshold,
397
- )
398
- for group_fb in group
399
- ):
400
- group.append(fb)
401
- matched = True
402
- break
403
- if not matched:
404
- groups.append([fb])
405
-
406
- # Sort by group size descending (largest first)
407
- groups.sort(key=len, reverse=True)
408
- return groups
223
+ return aggregator_prompt_formatting.group_playbooks_by_direction(
224
+ cluster_playbooks, threshold
225
+ )
409
226
 
410
227
  def _format_structured_cluster_input(
411
228
  self,
412
229
  cluster_playbooks: list[UserPlaybook],
413
230
  direction_overlap_threshold: float = 0.6,
414
231
  ) -> str:
415
- """
416
- Format a cluster of playbooks for structured aggregation prompt.
417
-
418
- When the cluster forms a single similarity group, uses the flat-list
419
- format. When distinct similarity groups are detected (multiple groups),
420
- uses a grouped format so the LLM can see which items are similar and
421
- preserve distinct rules (e.g. a do-rule and an avoid-rule) as separate
422
- rules in the merged skill rather than collapsing them.
423
-
424
- Args:
425
- cluster_playbooks: List of raw playbooks in this cluster
426
- direction_overlap_threshold: Token overlap threshold for grouping by direction
427
-
428
- Returns:
429
- str: Formatted input for the aggregation prompt
430
- """
431
- groups = self._group_playbooks_by_direction(
432
- cluster_playbooks, threshold=direction_overlap_threshold
232
+ return aggregator_prompt_formatting.format_structured_cluster_input(
233
+ cluster_playbooks,
234
+ direction_overlap_threshold=direction_overlap_threshold,
433
235
  )
434
236
 
435
- if len(groups) <= 1:
436
- return self._format_flat(cluster_playbooks)
437
- return self._format_grouped(groups)
438
-
439
- def _format_flat(self, cluster_playbooks: list[UserPlaybook]) -> str:
440
- """
441
- Format playbooks as flat bullet lists (original format, used when no conflict).
442
-
443
- Args:
444
- cluster_playbooks: List of raw playbooks in this cluster
445
-
446
- Returns:
447
- str: Formatted input with separate field lists
448
- """
449
- triggers = []
450
- rationales = []
451
-
452
- for fb in cluster_playbooks:
453
- if fb.trigger:
454
- triggers.append(fb.trigger)
455
- if fb.rationale:
456
- rationales.append(fb.rationale)
457
-
458
- lines: list[str] = []
459
-
460
- if triggers:
461
- lines.append("TRIGGER conditions (to be consolidated):")
462
- lines.extend(f"- {trigger}" for trigger in triggers)
463
- else:
464
- lines.append("TRIGGER conditions: (none specified)")
465
-
466
- if rationales:
467
- lines.append("RATIONALE summaries:")
468
- lines.extend(f"- {r}" for r in rationales)
469
-
470
- self._append_freeform_observations(lines, cluster_playbooks)
471
-
472
- return "\n".join(lines)
473
-
474
- def _format_grouped(
475
- self,
476
- groups: list[list[UserPlaybook]],
477
- ) -> str:
478
- """
479
- Format playbooks in grouped layout (used when conflicting directions are detected).
480
-
481
- Args:
482
- groups: AgentPlaybook groups sorted by size descending
483
-
484
- Returns:
485
- str: Formatted input with group headers and per-playbook fields
486
- """
487
- lines: list[str] = [
488
- "The following playbook items are grouped by similarity. "
489
- "Groups are ordered by size (largest first).",
490
- "",
491
- ]
492
-
493
- for idx, group in enumerate(groups, start=1):
494
- count_label = "playbook" if len(group) == 1 else "playbooks"
495
- lines.append(f"Group {idx} ({len(group)} {count_label}):")
496
- for fb in group:
497
- parts: list[str] = []
498
- if fb.trigger:
499
- parts.append(f'Trigger: "{fb.trigger}"')
500
- if fb.rationale:
501
- parts.append(f'Rationale: "{fb.rationale}"')
502
- if not parts and fb.content:
503
- parts.append(f'AgentPlaybook: "{fb.content}"')
504
- if parts:
505
- lines.append(f" - {parts[0]}")
506
- lines.extend(f" {p}" for p in parts[1:])
507
- lines.append("")
508
-
509
- return "\n".join(lines)
510
-
511
- @staticmethod
512
- def _append_freeform_observations(
513
- lines: list[str], cluster_playbooks: list[UserPlaybook]
514
- ) -> None:
515
- """Append freeform observations from cluster playbooks to output lines."""
516
- freeform_observations = [
517
- fb.content for fb in cluster_playbooks if not fb.trigger and fb.content
518
- ]
519
- if freeform_observations:
520
- lines.append("Freeform observations (from freeform cluster members):")
521
- lines.extend(f"- {obs}" for obs in freeform_observations)
522
-
523
237
  # ===============================
524
238
  # private methods - cluster change detection
525
239
  # ===============================
526
240
 
527
241
  @staticmethod
528
242
  def _compute_cluster_fingerprint(cluster_playbooks: list[UserPlaybook]) -> str:
529
- """
530
- Compute a fingerprint for a cluster based on its user_playbook_ids.
531
- The fingerprint is deterministic and order-independent.
532
-
533
- Args:
534
- cluster_playbooks: List of raw playbooks in this cluster
535
-
536
- Returns:
537
- str: SHA-256 hash (truncated to 16 hex chars) of sorted user_playbook_ids
538
- """
539
- sorted_ids = sorted(fb.user_playbook_id for fb in cluster_playbooks)
540
- id_str = ",".join(str(id) for id in sorted_ids)
541
- return hashlib.sha256(id_str.encode()).hexdigest()[:16]
243
+ return aggregator_clustering.compute_cluster_fingerprint(cluster_playbooks)
542
244
 
543
245
  def _determine_cluster_changes(
544
246
  self,
545
247
  clusters: dict[int, list[UserPlaybook]],
546
248
  prev_fingerprints: dict,
547
249
  ) -> tuple[dict[int, list[UserPlaybook]], list[int]]:
548
- """
549
- Compare current cluster fingerprints against stored fingerprints to determine changes.
550
-
551
- Args:
552
- clusters: Current clusters (cluster_id -> list of UserPlaybook)
553
- prev_fingerprints: Previous fingerprint state
554
- (fingerprint_hash -> {"agent_playbook_id": int, "user_playbook_ids": list})
555
-
556
- Returns:
557
- tuple of:
558
- - changed_clusters: Only clusters needing new LLM calls
559
- - playbook_ids_to_archive: Old playbook_ids from changed/disappeared clusters
560
- """
561
- # Compute fingerprints for current clusters
562
- current_fingerprints = {}
563
- for cluster_id, cluster_playbooks in clusters.items():
564
- fp = self._compute_cluster_fingerprint(cluster_playbooks)
565
- current_fingerprints[cluster_id] = fp
566
-
567
- current_fp_set = set(current_fingerprints.values())
568
- prev_fp_set = set(prev_fingerprints.keys())
569
-
570
- # Changed clusters: fingerprints that are new (not in previous state)
571
- changed_clusters = {}
572
- for cluster_id, fp in current_fingerprints.items():
573
- if fp not in prev_fp_set:
574
- changed_clusters[cluster_id] = clusters[cluster_id]
575
-
576
- # Playbook IDs to archive: from fingerprints that disappeared or changed
577
- playbook_ids_to_archive = []
578
- for fp, fp_data in prev_fingerprints.items():
579
- if fp not in current_fp_set:
580
- playbook_id = fp_data.get("agent_playbook_id")
581
- if playbook_id is not None:
582
- playbook_ids_to_archive.append(playbook_id)
583
-
584
- return changed_clusters, playbook_ids_to_archive
250
+ return aggregator_clustering.determine_cluster_changes(
251
+ clusters, prev_fingerprints
252
+ )
585
253
 
586
254
  # ===============================
587
255
  # public methods
@@ -692,9 +360,9 @@ class PlaybookAggregator:
692
360
  self.agent_version,
693
361
  )
694
362
  logger.info(
695
- "User detail stripping for aggregation: %s",
696
- type(self.user_detail_stripper).__name__
697
- if self.user_detail_stripper is not None
363
+ "Aggregation prompt processor: %s",
364
+ type(self.aggregation_prompt_processor).__name__
365
+ if self.aggregation_prompt_processor is not None
698
366
  else "disabled",
699
367
  )
700
368
 
@@ -979,6 +647,22 @@ class PlaybookAggregator:
979
647
  # Remove archived playbooks after successful aggregation. ALWAYS soft-supersede
980
648
  # (never hard-delete) so the removal is reconstructable from lineage — mirrors the
981
649
  # profile dedup always-soft path (#206).
650
+ archived_ids_without_overlapping_changed_cluster: set[int] = set()
651
+ if new_playbooks and prev_fingerprints:
652
+ archived_id_set = set(archived_playbook_ids)
653
+ for prev_fp, fp_data in prev_fingerprints.items():
654
+ playbook_id = fp_data.get("agent_playbook_id")
655
+ if (
656
+ playbook_id in archived_id_set
657
+ and prev_fp not in changed_fps_by_previous_fp
658
+ ):
659
+ archived_ids_without_overlapping_changed_cluster.add(
660
+ playbook_id
661
+ )
662
+ ids_to_supersede = {
663
+ *selective_supersede_playbook_ids,
664
+ *archived_ids_without_overlapping_changed_cluster,
665
+ }
982
666
  if not _run_id:
983
667
  # Empty request_id makes the removal unreconstructable (lineage events are keyed
984
668
  # on it). Fail loud and skip removal — never silently hard-delete.
@@ -996,9 +680,9 @@ class PlaybookAggregator:
996
680
  agent_version=self.agent_version,
997
681
  request_id=_run_id,
998
682
  )
999
- elif selective_supersede_playbook_ids:
683
+ elif ids_to_supersede:
1000
684
  self.storage.supersede_agent_playbooks_by_ids( # type: ignore[reportOptionalMemberAccess]
1001
- sorted(selective_supersede_playbook_ids),
685
+ sorted(ids_to_supersede),
1002
686
  request_id=_run_id,
1003
687
  )
1004
688
  elif archived_playbook_ids:
@@ -1107,7 +791,9 @@ class PlaybookAggregator:
1107
791
  # Mock mode: cluster by trigger
1108
792
  if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
1109
793
  logger.info("Mock mode: clustering by trigger")
1110
- return self._cluster_by_trigger_mock(user_playbooks, min_cluster_size)
794
+ return aggregator_clustering.cluster_by_trigger_mock(
795
+ user_playbooks, min_cluster_size
796
+ )
1111
797
 
1112
798
  # Extract embeddings from user playbooks
1113
799
  import numpy as np
@@ -1162,139 +848,26 @@ class PlaybookAggregator:
1162
848
 
1163
849
  return clusters
1164
850
 
1165
- def _cluster_by_trigger_mock(
1166
- self, user_playbooks: list[UserPlaybook], min_cluster_size: int
1167
- ) -> dict[int, list[UserPlaybook]]:
1168
- """
1169
- Simple mock clustering by exact trigger match.
1170
-
1171
- Args:
1172
- user_playbooks: List of user playbooks with trigger field
1173
- min_cluster_size: Minimum number of playbooks per cluster
1174
-
1175
- Returns:
1176
- dict[int, list[UserPlaybook]]: Clusters grouped by trigger
1177
- """
1178
- # Group by trigger
1179
- condition_groups: dict[str, list[UserPlaybook]] = {}
1180
- for fb in user_playbooks:
1181
- condition = fb.trigger or ""
1182
- if condition not in condition_groups:
1183
- condition_groups[condition] = []
1184
- condition_groups[condition].append(fb)
1185
-
1186
- # Convert to cluster format, filtering by min_cluster_size
1187
- clusters: dict[int, list[UserPlaybook]] = {}
1188
- cluster_id = 0
1189
- for playbooks_group in condition_groups.values():
1190
- if len(playbooks_group) >= min_cluster_size:
1191
- clusters[cluster_id] = playbooks_group
1192
- cluster_id += 1
1193
-
1194
- logger.info(
1195
- "Mock mode: created %d trigger clusters from %d playbooks",
1196
- len(clusters),
1197
- len(user_playbooks),
1198
- )
1199
- return clusters
1200
-
1201
851
  def _cluster_with_agglomerative(
1202
852
  self,
1203
853
  distance_matrix: np.ndarray,
1204
- min_cluster_size: int, # noqa: ARG002
854
+ min_cluster_size: int,
1205
855
  distance_threshold: float,
1206
856
  ) -> np.ndarray:
1207
- """
1208
- Cluster using Agglomerative Clustering - best for small datasets.
1209
-
1210
- Args:
1211
- distance_matrix: Precomputed cosine distance matrix
1212
- min_cluster_size: Minimum cluster size (used for logging only,
1213
- filtering happens in get_clusters)
1214
- distance_threshold: Maximum cosine distance to merge clusters (1 - similarity_threshold)
1215
-
1216
- Returns:
1217
- np.ndarray: Cluster labels for each point
1218
- """
1219
- from sklearn.cluster import AgglomerativeClustering
1220
-
1221
- logger.info(
1222
- "Using Agglomerative Clustering for %d playbooks (< %d threshold), distance_threshold=%.2f",
1223
- len(distance_matrix),
1224
- CLUSTERING_ALGORITHM_THRESHOLD,
1225
- distance_threshold,
1226
- )
1227
-
1228
- clusterer = AgglomerativeClustering(
1229
- n_clusters=None, # type: ignore[reportArgumentType]
1230
- distance_threshold=distance_threshold,
1231
- metric="precomputed",
1232
- linkage="average",
857
+ return aggregator_clustering.cluster_with_agglomerative(
858
+ distance_matrix, min_cluster_size, distance_threshold
1233
859
  )
1234
860
 
1235
- return clusterer.fit_predict(distance_matrix)
1236
-
1237
861
  def _cluster_with_hdbscan(
1238
862
  self,
1239
863
  distance_matrix: np.ndarray,
1240
864
  min_cluster_size: int,
1241
865
  distance_threshold: float,
1242
866
  ) -> np.ndarray:
1243
- """
1244
- Cluster using HDBSCAN - best for large datasets with potential noise.
1245
-
1246
- Args:
1247
- distance_matrix: Precomputed cosine distance matrix
1248
- min_cluster_size: Minimum number of points to form a cluster
1249
- distance_threshold: Maximum cosine distance for cluster merging (1 - similarity_threshold)
1250
-
1251
- Returns:
1252
- np.ndarray: Cluster labels for each point (-1 indicates noise)
1253
- """
1254
- import hdbscan
1255
-
1256
- logger.info(
1257
- "Using HDBSCAN for %d playbooks (>= %d threshold), distance_threshold=%.2f",
1258
- len(distance_matrix),
1259
- CLUSTERING_ALGORITHM_THRESHOLD,
1260
- distance_threshold,
1261
- )
1262
-
1263
- clusterer = hdbscan.HDBSCAN(
1264
- min_cluster_size=min_cluster_size,
1265
- min_samples=1,
1266
- metric="precomputed",
1267
- cluster_selection_epsilon=distance_threshold,
867
+ return aggregator_clustering.cluster_with_hdbscan(
868
+ distance_matrix, min_cluster_size, distance_threshold
1268
869
  )
1269
870
 
1270
- return clusterer.fit_predict(distance_matrix)
1271
-
1272
- def _generate_playbooks_from_clusters(
1273
- self,
1274
- clusters: dict[int, list[UserPlaybook]],
1275
- existing_approved_playbooks: list[AgentPlaybook],
1276
- direction_overlap_threshold: float = 0.6,
1277
- ) -> list[AgentPlaybook]:
1278
- """
1279
- Generate playbooks from clusters, considering existing approved playbooks.
1280
-
1281
- Args:
1282
- clusters: Dictionary mapping cluster IDs to lists of raw playbooks
1283
- existing_approved_playbooks: List of existing approved playbooks to avoid duplication
1284
- direction_overlap_threshold: Token overlap threshold for grouping by direction
1285
-
1286
- Returns:
1287
- list[AgentPlaybook]: List of newly generated playbooks (excludes duplicates)
1288
- """
1289
- return [
1290
- playbook
1291
- for playbook, _ in self._generate_playbooks_with_source_clusters(
1292
- clusters,
1293
- existing_approved_playbooks,
1294
- direction_overlap_threshold=direction_overlap_threshold,
1295
- )
1296
- ]
1297
-
1298
871
  def _generate_playbooks_with_source_clusters(
1299
872
  self,
1300
873
  clusters: dict[int, list[UserPlaybook]],
@@ -1309,12 +882,20 @@ class PlaybookAggregator:
1309
882
  else "None"
1310
883
  )
1311
884
  for cluster_playbooks in clusters.values():
1312
- shared_mapping: dict[str, int] = {}
1313
- if self.user_detail_stripper is None:
885
+ shared_state: dict[str, object] = {}
886
+ processing_context = AggregationPromptProcessingContext(
887
+ data={
888
+ "agent_version": self.agent_version,
889
+ "org_id": self.request_context.org_id,
890
+ }
891
+ )
892
+ if self.aggregation_prompt_processor is None:
1314
893
  prompt_cluster_playbooks = cluster_playbooks
1315
894
  else:
1316
895
  prompt_cluster_playbooks = [
1317
- self._strip_user_playbook_for_prompt(playbook, shared_mapping)
896
+ self._postproc._preprocess_user_playbook_for_prompt(
897
+ playbook, shared_state, processing_context
898
+ )
1318
899
  for playbook in cluster_playbooks
1319
900
  ]
1320
901
 
@@ -1322,6 +903,7 @@ class PlaybookAggregator:
1322
903
  prompt_cluster_playbooks,
1323
904
  approved_playbooks_str,
1324
905
  direction_overlap_threshold=direction_overlap_threshold,
906
+ processing_context=processing_context,
1325
907
  )
1326
908
  if playbook is not None:
1327
909
  new_playbooks.append((playbook, cluster_playbooks))
@@ -1371,6 +953,7 @@ class PlaybookAggregator:
1371
953
  cluster_playbooks: list[UserPlaybook],
1372
954
  existing_approved_playbooks_str: str,
1373
955
  direction_overlap_threshold: float = 0.6,
956
+ processing_context: AggregationPromptProcessingContext | None = None,
1374
957
  ) -> AgentPlaybook | None:
1375
958
  """
1376
959
  Generate a playbook from a cluster using structured JSON output.
@@ -1407,8 +990,11 @@ class PlaybookAggregator:
1407
990
  trigger=trigger,
1408
991
  )
1409
992
  )
1410
- response, placeholder_count = self._sanitize_aggregation_response(response)
1411
- self._record_placeholder_leakage(placeholder_count)
993
+ response, artifact_count = self._postproc._postprocess_aggregation_response(
994
+ response,
995
+ processing_context,
996
+ )
997
+ self._postproc._record_postprocessing_artifacts(artifact_count)
1412
998
  playbook = self._process_aggregation_response(response, cluster_playbooks)
1413
999
  if playbook is None:
1414
1000
  return None
@@ -1428,7 +1014,9 @@ class PlaybookAggregator:
1428
1014
  {
1429
1015
  "user_playbooks": raw_playbooks_str,
1430
1016
  "existing_approved_playbooks": existing_approved_playbooks_str,
1431
- "aggregation_prompt_extra_instructions": self.aggregation_prompt_extra_instructions,
1017
+ "aggregation_prompt_extra_instructions": self._postproc._aggregation_prompt_extra_instructions_for_context(
1018
+ processing_context
1019
+ ),
1432
1020
  },
1433
1021
  ),
1434
1022
  }
@@ -1442,15 +1030,21 @@ class PlaybookAggregator:
1442
1030
  parse_structured_output=True,
1443
1031
  )
1444
1032
  if isinstance(response, PlaybookAggregationOutput):
1445
- response, placeholder_count = self._sanitize_aggregation_response(
1446
- response
1033
+ response, artifact_count = (
1034
+ self._postproc._postprocess_aggregation_response(
1035
+ response,
1036
+ processing_context,
1037
+ )
1447
1038
  )
1448
- self._record_placeholder_leakage(placeholder_count)
1039
+ self._postproc._record_postprocessing_artifacts(artifact_count)
1449
1040
  else:
1450
- response, placeholder_count = self._sanitize_aggregation_log_value(
1451
- response
1041
+ response, artifact_count = (
1042
+ self._postproc._postprocess_aggregation_output(
1043
+ response,
1044
+ processing_context,
1045
+ )
1452
1046
  )
1453
- self._record_placeholder_leakage(placeholder_count)
1047
+ self._postproc._record_postprocessing_artifacts(artifact_count)
1454
1048
  log_model_response(logger, "Aggregation structured response", response)
1455
1049
 
1456
1050
  if not isinstance(response, PlaybookAggregationOutput):
@@ -1462,13 +1056,16 @@ class PlaybookAggregator:
1462
1056
 
1463
1057
  return self._process_aggregation_response(response, cluster_playbooks)
1464
1058
  except Exception as exc:
1465
- sanitized_error, placeholder_count = self._sanitize_aggregation_log_value(
1466
- str(exc)
1059
+ processed_error, artifact_count = (
1060
+ self._postproc._postprocess_aggregation_output(
1061
+ str(exc),
1062
+ processing_context,
1063
+ )
1467
1064
  )
1468
- self._record_placeholder_leakage(placeholder_count)
1065
+ self._postproc._record_postprocessing_artifacts(artifact_count)
1469
1066
  logger.error(
1470
1067
  "AgentPlaybook aggregation failed due to %s, returning None.",
1471
- sanitized_error,
1068
+ processed_error,
1472
1069
  )
1473
1070
  return None
1474
1071
 
@@ -1482,7 +1079,7 @@ class PlaybookAggregator:
1482
1079
  response: Parsed PlaybookAggregationOutput from LLM
1483
1080
  cluster_playbooks: Cluster playbooks used only for non-user metadata
1484
1081
  such as playbook name and agent version. Callers may pass
1485
- prompt-sanitized copies here, so this method must not read
1082
+ prompt-preprocessed copies here, so this method must not read
1486
1083
  user-authored fields from them.
1487
1084
 
1488
1085
  Returns:
@@ -1490,7 +1087,6 @@ class PlaybookAggregator:
1490
1087
  """
1491
1088
  if not response:
1492
1089
  return None
1493
- response, _placeholder_count = self._sanitize_aggregation_response(response)
1494
1090
 
1495
1091
  structured = response.playbook
1496
1092
  if structured is None: