claude-smart 0.2.47 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
- package/plugin/dashboard/package-lock.json +61 -390
- package/plugin/dashboard/package.json +2 -2
- package/plugin/pyproject.toml +1 -1
- package/plugin/uv.lock +1 -1
- package/plugin/vendor/reflexio/.env.example +7 -0
- package/plugin/vendor/reflexio/pyproject.toml +2 -1
- package/plugin/vendor/reflexio/reflexio/README.md +8 -5
- package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
- package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
- package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
- package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
- package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
- package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
- package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
- package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
- package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py
ADDED
|
@@ -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)
|