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
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ import numpy as np
9
+
10
+ from reflexio.models.api_schema.service_schemas import UserPlaybook
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Threshold for switching between clustering algorithms
15
+ # Below this, use Agglomerative (works better with small datasets)
16
+ # Above this, use HDBSCAN (scales better, handles noise)
17
+ CLUSTERING_ALGORITHM_THRESHOLD = 50
18
+
19
+
20
+ def compute_cluster_fingerprint(cluster_playbooks: list[UserPlaybook]) -> str:
21
+ """
22
+ Compute a fingerprint for a cluster based on its user_playbook_ids.
23
+ The fingerprint is deterministic and order-independent.
24
+
25
+ Args:
26
+ cluster_playbooks: List of raw playbooks in this cluster
27
+
28
+ Returns:
29
+ str: SHA-256 hash (truncated to 16 hex chars) of sorted user_playbook_ids
30
+ """
31
+ sorted_ids = sorted(fb.user_playbook_id for fb in cluster_playbooks)
32
+ id_str = ",".join(str(id) for id in sorted_ids)
33
+ return hashlib.sha256(id_str.encode()).hexdigest()[:16]
34
+
35
+
36
+ def determine_cluster_changes(
37
+ clusters: dict[int, list[UserPlaybook]],
38
+ prev_fingerprints: dict,
39
+ ) -> tuple[dict[int, list[UserPlaybook]], list[int]]:
40
+ """
41
+ Compare current cluster fingerprints against stored fingerprints to determine changes.
42
+
43
+ Args:
44
+ clusters: Current clusters (cluster_id -> list of UserPlaybook)
45
+ prev_fingerprints: Previous fingerprint state
46
+ (fingerprint_hash -> {"agent_playbook_id": int, "user_playbook_ids": list})
47
+
48
+ Returns:
49
+ tuple of:
50
+ - changed_clusters: Only clusters needing new LLM calls
51
+ - playbook_ids_to_archive: Old playbook_ids from changed/disappeared clusters
52
+ """
53
+ # Compute fingerprints for current clusters
54
+ current_fingerprints = {}
55
+ for cluster_id, cluster_playbooks in clusters.items():
56
+ fp = compute_cluster_fingerprint(cluster_playbooks)
57
+ current_fingerprints[cluster_id] = fp
58
+
59
+ current_fp_set = set(current_fingerprints.values())
60
+ prev_fp_set = set(prev_fingerprints.keys())
61
+
62
+ # Changed clusters: fingerprints that are new (not in previous state)
63
+ changed_clusters = {}
64
+ for cluster_id, fp in current_fingerprints.items():
65
+ if fp not in prev_fp_set:
66
+ changed_clusters[cluster_id] = clusters[cluster_id]
67
+
68
+ # Playbook IDs to archive: from fingerprints that disappeared or changed
69
+ playbook_ids_to_archive = []
70
+ for fp, fp_data in prev_fingerprints.items():
71
+ if fp not in current_fp_set:
72
+ playbook_id = fp_data.get("agent_playbook_id")
73
+ if playbook_id is not None:
74
+ playbook_ids_to_archive.append(playbook_id)
75
+
76
+ return changed_clusters, playbook_ids_to_archive
77
+
78
+
79
+ def cluster_by_trigger_mock(
80
+ user_playbooks: list[UserPlaybook], min_cluster_size: int
81
+ ) -> dict[int, list[UserPlaybook]]:
82
+ """
83
+ Simple mock clustering by exact trigger match.
84
+
85
+ Args:
86
+ user_playbooks: List of user playbooks with trigger field
87
+ min_cluster_size: Minimum number of playbooks per cluster
88
+
89
+ Returns:
90
+ dict[int, list[UserPlaybook]]: Clusters grouped by trigger
91
+ """
92
+ # Group by trigger
93
+ condition_groups: dict[str, list[UserPlaybook]] = {}
94
+ for fb in user_playbooks:
95
+ condition = fb.trigger or ""
96
+ if condition not in condition_groups:
97
+ condition_groups[condition] = []
98
+ condition_groups[condition].append(fb)
99
+
100
+ # Convert to cluster format, filtering by min_cluster_size
101
+ clusters: dict[int, list[UserPlaybook]] = {}
102
+ cluster_id = 0
103
+ for playbooks_group in condition_groups.values():
104
+ if len(playbooks_group) >= min_cluster_size:
105
+ clusters[cluster_id] = playbooks_group
106
+ cluster_id += 1
107
+
108
+ logger.info(
109
+ "Mock mode: created %d trigger clusters from %d playbooks",
110
+ len(clusters),
111
+ len(user_playbooks),
112
+ )
113
+ return clusters
114
+
115
+
116
+ def cluster_with_agglomerative(
117
+ distance_matrix: np.ndarray,
118
+ min_cluster_size: int, # noqa: ARG001
119
+ distance_threshold: float,
120
+ ) -> np.ndarray:
121
+ """
122
+ Cluster using Agglomerative Clustering - best for small datasets.
123
+
124
+ Args:
125
+ distance_matrix: Precomputed cosine distance matrix
126
+ min_cluster_size: Minimum cluster size (used for logging only,
127
+ filtering happens in get_clusters)
128
+ distance_threshold: Maximum cosine distance to merge clusters (1 - similarity_threshold)
129
+
130
+ Returns:
131
+ np.ndarray: Cluster labels for each point
132
+ """
133
+ from sklearn.cluster import AgglomerativeClustering
134
+
135
+ logger.info(
136
+ "Using Agglomerative Clustering for %d playbooks (< %d threshold), distance_threshold=%.2f",
137
+ len(distance_matrix),
138
+ CLUSTERING_ALGORITHM_THRESHOLD,
139
+ distance_threshold,
140
+ )
141
+
142
+ clusterer = AgglomerativeClustering(
143
+ n_clusters=None, # type: ignore[reportArgumentType]
144
+ distance_threshold=distance_threshold,
145
+ metric="precomputed",
146
+ linkage="average",
147
+ )
148
+
149
+ return clusterer.fit_predict(distance_matrix)
150
+
151
+
152
+ def cluster_with_hdbscan(
153
+ distance_matrix: np.ndarray,
154
+ min_cluster_size: int,
155
+ distance_threshold: float,
156
+ ) -> np.ndarray:
157
+ """
158
+ Cluster using HDBSCAN - best for large datasets with potential noise.
159
+
160
+ Args:
161
+ distance_matrix: Precomputed cosine distance matrix
162
+ min_cluster_size: Minimum number of points to form a cluster
163
+ distance_threshold: Maximum cosine distance for cluster merging (1 - similarity_threshold)
164
+
165
+ Returns:
166
+ np.ndarray: Cluster labels for each point (-1 indicates noise)
167
+ """
168
+ import hdbscan
169
+
170
+ logger.info(
171
+ "Using HDBSCAN for %d playbooks (>= %d threshold), distance_threshold=%.2f",
172
+ len(distance_matrix),
173
+ CLUSTERING_ALGORITHM_THRESHOLD,
174
+ distance_threshold,
175
+ )
176
+
177
+ clusterer = hdbscan.HDBSCAN(
178
+ min_cluster_size=min_cluster_size,
179
+ min_samples=1,
180
+ metric="precomputed",
181
+ cluster_selection_epsilon=distance_threshold,
182
+ )
183
+
184
+ return clusterer.fit_predict(distance_matrix)
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from reflexio.models.api_schema.service_schemas import UserPlaybook
6
+ from reflexio.server.services.playbook.aggregation_prompt_processing import (
7
+ AggregationPromptProcessingContext,
8
+ AggregationPromptProcessor,
9
+ )
10
+ from reflexio.server.services.playbook.playbook_service_utils import (
11
+ PlaybookAggregationOutput,
12
+ )
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class AggregationPostProcessing:
18
+ """Pre/post-processing for playbook aggregation prompts and LLM output.
19
+
20
+ Groups the aggregation prompt-processor seam (the enterprise redaction
21
+ Protocol integration point). Holds the single injected collaborator,
22
+ ``aggregation_prompt_processor``, by shared reference from the owning
23
+ ``PlaybookAggregator`` — the SAME object resolved from the
24
+ ``AGGREGATION_PROMPT_PROCESSOR`` ServiceKey — so the enterprise
25
+ ``EnterpriseAggregationPromptProcessor`` injection is preserved unchanged.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ aggregation_prompt_processor: AggregationPromptProcessor | None,
31
+ ) -> None:
32
+ self.aggregation_prompt_processor = aggregation_prompt_processor
33
+
34
+ @staticmethod
35
+ def _format_prompt_extra_instructions(instructions: str | None) -> str:
36
+ if not instructions or not instructions.strip():
37
+ return ""
38
+ return f"{instructions.strip()}\n"
39
+
40
+ def _preprocess_prompt_field(
41
+ self,
42
+ text: str | None,
43
+ shared_state: dict[str, object],
44
+ processing_context: AggregationPromptProcessingContext | None = None,
45
+ ) -> str | None:
46
+ if text is None or self.aggregation_prompt_processor is None:
47
+ return text
48
+ result = self.aggregation_prompt_processor.preprocess_prompt_text(
49
+ text,
50
+ shared_state=shared_state,
51
+ context=processing_context,
52
+ )
53
+ if processing_context is not None and (result.changed or result.text != text):
54
+ processing_context.changed = True
55
+ return result.text
56
+
57
+ def _preprocess_user_playbook_for_prompt(
58
+ self,
59
+ playbook: UserPlaybook,
60
+ shared_state: dict[str, object],
61
+ processing_context: AggregationPromptProcessingContext | None = None,
62
+ ) -> UserPlaybook:
63
+ if self.aggregation_prompt_processor is None:
64
+ return playbook
65
+ return playbook.model_copy(
66
+ update={
67
+ "content": self._preprocess_prompt_field(
68
+ playbook.content, shared_state, processing_context
69
+ )
70
+ or "",
71
+ "trigger": self._preprocess_prompt_field(
72
+ playbook.trigger, shared_state, processing_context
73
+ ),
74
+ "rationale": self._preprocess_prompt_field(
75
+ playbook.rationale, shared_state, processing_context
76
+ ),
77
+ }
78
+ )
79
+
80
+ def _aggregation_prompt_extra_instructions_for_context(
81
+ self,
82
+ processing_context: AggregationPromptProcessingContext | None,
83
+ ) -> str:
84
+ if (
85
+ processing_context is None
86
+ or not processing_context.changed
87
+ or self.aggregation_prompt_processor is None
88
+ ):
89
+ return ""
90
+
91
+ context_instructions = self.aggregation_prompt_processor.prompt_instructions(
92
+ processing_context
93
+ )
94
+ if not isinstance(context_instructions, str):
95
+ context_instructions = None
96
+ return self._format_prompt_extra_instructions(context_instructions)
97
+
98
+ def _postprocess_aggregation_output(
99
+ self,
100
+ value: object,
101
+ processing_context: AggregationPromptProcessingContext | None = None,
102
+ ) -> tuple[object, int]:
103
+ if self.aggregation_prompt_processor is None:
104
+ return value, 0
105
+ result = self.aggregation_prompt_processor.postprocess_aggregation_output(
106
+ value,
107
+ context=processing_context,
108
+ )
109
+ return result.value, result.artifacts_removed
110
+
111
+ def _postprocess_aggregation_response(
112
+ self,
113
+ response: PlaybookAggregationOutput,
114
+ processing_context: AggregationPromptProcessingContext | None = None,
115
+ ) -> tuple[PlaybookAggregationOutput, int]:
116
+ processed, artifact_count = self._postprocess_aggregation_output(
117
+ response,
118
+ processing_context,
119
+ )
120
+ if not isinstance(processed, PlaybookAggregationOutput):
121
+ return response, 0
122
+ return processed, artifact_count
123
+
124
+ def _record_postprocessing_artifacts(self, artifact_count: int) -> None:
125
+ if artifact_count <= 0:
126
+ return
127
+ logger.warning(
128
+ "Post-processed %d residual artifacts in aggregated playbook output",
129
+ artifact_count,
130
+ )
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ from reflexio.models.api_schema.service_schemas import UserPlaybook
4
+
5
+
6
+ def get_direction_key(fb: UserPlaybook) -> str:
7
+ """
8
+ Extract a similarity key from a user playbook for grouping.
9
+
10
+ Returns the raw content used for token-overlap comparison. Grouping is
11
+ purely content-similarity based: under Option B a skill may legitimately
12
+ hold mixed-orientation rules (do-rules and avoid-rules for different
13
+ sub-aspects of one task), so whole-content polarity is NOT derived or
14
+ gated here. Preserving distinct do/avoid rules when similar items are
15
+ merged is the aggregation prompt's responsibility.
16
+
17
+ Args:
18
+ fb: A user playbook item
19
+
20
+ Returns:
21
+ str: Content used as the similarity key for grouping
22
+ """
23
+ return fb.content or ""
24
+
25
+
26
+ def token_overlap(str1: str, str2: str, threshold: float = 0.6) -> bool:
27
+ """
28
+ Check if two strings have significant token overlap using asymmetric containment.
29
+
30
+ Computes the ratio of shared tokens to the smaller set, so a short string
31
+ contained in a longer one still counts as a match.
32
+
33
+ Args:
34
+ str1: First string
35
+ str2: Second string
36
+ threshold: Minimum overlap ratio
37
+
38
+ Returns:
39
+ bool: True if overlap ratio >= threshold
40
+ """
41
+ tokens1 = set(str1.lower().split())
42
+ tokens2 = set(str2.lower().split())
43
+ if not tokens1 or not tokens2:
44
+ return False
45
+ intersection = len(tokens1 & tokens2)
46
+ overlap_ratio = max(intersection / len(tokens1), intersection / len(tokens2))
47
+ return overlap_ratio >= threshold
48
+
49
+
50
+ def group_playbooks_by_direction(
51
+ cluster_playbooks: list[UserPlaybook],
52
+ threshold: float = 0.6,
53
+ ) -> list[list[UserPlaybook]]:
54
+ """
55
+ Group playbooks by similarity of their content.
56
+
57
+ Uses greedy single-linkage: each playbook is assigned to the first
58
+ existing group that has any member with sufficient token overlap.
59
+ Groups are returned sorted by size descending (largest first).
60
+
61
+ Grouping is purely content-similarity based and does NOT gate on a
62
+ derived whole-content polarity. Under Option B a skill may hold
63
+ mixed-orientation rules (do-rules and avoid-rules for different
64
+ sub-aspects), and whole-content polarity is undefined for such a skill.
65
+ Keeping a do-rule and an avoid-rule as distinct rules when similar items
66
+ are merged into one skill is the aggregation prompt's responsibility,
67
+ not a mechanical split here.
68
+
69
+ Args:
70
+ cluster_playbooks: List of raw playbooks to group
71
+ threshold: Token overlap threshold for grouping
72
+
73
+ Returns:
74
+ list[list[UserPlaybook]]: Groups sorted by size descending
75
+ """
76
+ groups: list[list[UserPlaybook]] = []
77
+
78
+ for fb in cluster_playbooks:
79
+ key = get_direction_key(fb)
80
+ matched = False
81
+ for group in groups:
82
+ if any(
83
+ token_overlap(
84
+ key,
85
+ get_direction_key(group_fb),
86
+ threshold,
87
+ )
88
+ for group_fb in group
89
+ ):
90
+ group.append(fb)
91
+ matched = True
92
+ break
93
+ if not matched:
94
+ groups.append([fb])
95
+
96
+ # Sort by group size descending (largest first)
97
+ groups.sort(key=len, reverse=True)
98
+ return groups
99
+
100
+
101
+ def format_structured_cluster_input(
102
+ cluster_playbooks: list[UserPlaybook],
103
+ direction_overlap_threshold: float = 0.6,
104
+ ) -> str:
105
+ """
106
+ Format a cluster of playbooks for structured aggregation prompt.
107
+
108
+ When the cluster forms a single similarity group, uses the flat-list
109
+ format. When distinct similarity groups are detected (multiple groups),
110
+ uses a grouped format so the LLM can see which items are similar and
111
+ preserve distinct rules (e.g. a do-rule and an avoid-rule) as separate
112
+ rules in the merged skill rather than collapsing them.
113
+
114
+ Args:
115
+ cluster_playbooks: List of raw playbooks in this cluster
116
+ direction_overlap_threshold: Token overlap threshold for grouping by direction
117
+
118
+ Returns:
119
+ str: Formatted input for the aggregation prompt
120
+ """
121
+ groups = group_playbooks_by_direction(
122
+ cluster_playbooks, threshold=direction_overlap_threshold
123
+ )
124
+
125
+ if len(groups) <= 1:
126
+ return format_flat(cluster_playbooks)
127
+ return format_grouped(groups)
128
+
129
+
130
+ def format_flat(cluster_playbooks: list[UserPlaybook]) -> str:
131
+ """
132
+ Format playbooks as flat bullet lists (original format, used when no conflict).
133
+
134
+ Args:
135
+ cluster_playbooks: List of raw playbooks in this cluster
136
+
137
+ Returns:
138
+ str: Formatted input with separate field lists
139
+ """
140
+ triggers = []
141
+ rationales = []
142
+
143
+ for fb in cluster_playbooks:
144
+ if fb.trigger:
145
+ triggers.append(fb.trigger)
146
+ if fb.rationale:
147
+ rationales.append(fb.rationale)
148
+
149
+ lines: list[str] = []
150
+
151
+ if triggers:
152
+ lines.append("TRIGGER conditions (to be consolidated):")
153
+ lines.extend(f"- {trigger}" for trigger in triggers)
154
+ else:
155
+ lines.append("TRIGGER conditions: (none specified)")
156
+
157
+ if rationales:
158
+ lines.append("RATIONALE summaries:")
159
+ lines.extend(f"- {r}" for r in rationales)
160
+
161
+ append_freeform_observations(lines, cluster_playbooks)
162
+
163
+ return "\n".join(lines)
164
+
165
+
166
+ def format_grouped(
167
+ groups: list[list[UserPlaybook]],
168
+ ) -> str:
169
+ """
170
+ Format playbooks in grouped layout (used when conflicting directions are detected).
171
+
172
+ Args:
173
+ groups: AgentPlaybook groups sorted by size descending
174
+
175
+ Returns:
176
+ str: Formatted input with group headers and per-playbook fields
177
+ """
178
+ lines: list[str] = [
179
+ "The following playbook items are grouped by similarity. "
180
+ "Groups are ordered by size (largest first).",
181
+ "",
182
+ ]
183
+
184
+ for idx, group in enumerate(groups, start=1):
185
+ count_label = "playbook" if len(group) == 1 else "playbooks"
186
+ lines.append(f"Group {idx} ({len(group)} {count_label}):")
187
+ for fb in group:
188
+ parts: list[str] = []
189
+ if fb.trigger:
190
+ parts.append(f'Trigger: "{fb.trigger}"')
191
+ if fb.rationale:
192
+ parts.append(f'Rationale: "{fb.rationale}"')
193
+ if not parts and fb.content:
194
+ parts.append(f'AgentPlaybook: "{fb.content}"')
195
+ if parts:
196
+ lines.append(f" - {parts[0]}")
197
+ lines.extend(f" {p}" for p in parts[1:])
198
+ lines.append("")
199
+
200
+ return "\n".join(lines)
201
+
202
+
203
+ def append_freeform_observations(
204
+ lines: list[str], cluster_playbooks: list[UserPlaybook]
205
+ ) -> None:
206
+ """Append freeform observations from cluster playbooks to output lines."""
207
+ freeform_observations = [
208
+ fb.content for fb in cluster_playbooks if not fb.trigger and fb.content
209
+ ]
210
+ if freeform_observations:
211
+ lines.append("Freeform observations (from freeform cluster members):")
212
+ lines.extend(f"- {obs}" for obs in freeform_observations)