claude-smart 0.2.42 → 0.2.44
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 +3 -3
- package/README.md +1 -1
- package/bin/claude-smart.js +2 -2
- package/package.json +9 -3
- package/plugin/.claude-plugin/plugin.json +9 -3
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/README.md +23 -3
- package/plugin/pyproject.toml +3 -3
- package/plugin/scripts/_lib.sh +91 -0
- package/plugin/scripts/backend-service.sh +51 -4
- package/plugin/scripts/cli.sh +3 -1
- package/plugin/scripts/codex-hook.js +72 -4
- package/plugin/scripts/dashboard-build.sh +1 -0
- package/plugin/scripts/dashboard-service.sh +1 -0
- package/plugin/scripts/ensure-plugin-root.sh +1 -0
- package/plugin/scripts/hook_entry.sh +6 -3
- package/plugin/scripts/smart-install.sh +3 -2
- package/plugin/src/README.md +57 -0
- package/plugin/src/claude_smart/context_format.py +11 -12
- package/plugin/src/claude_smart/cs_cite.py +26 -12
- package/plugin/src/claude_smart/ids.py +13 -5
- package/plugin/uv.lock +126 -5
- package/plugin/vendor/reflexio/.env.example +62 -0
- package/plugin/vendor/reflexio/LICENSE +201 -0
- package/plugin/vendor/reflexio/README.md +338 -0
- package/plugin/vendor/reflexio/pyproject.toml +274 -0
- package/plugin/vendor/reflexio/reflexio/README.md +184 -0
- package/plugin/vendor/reflexio/reflexio/__init__.py +166 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/__init__.py +1 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/README.md +109 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/__init__.py +1 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/backends.py +175 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/bench.py +642 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/embed_cache.py +330 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/report.py +317 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/report.md +43 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/results.json +4478 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/scenarios.py +134 -0
- package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/seed.py +255 -0
- package/plugin/vendor/reflexio/reflexio/cli/README.md +287 -0
- package/plugin/vendor/reflexio/reflexio/cli/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/cli/__main__.py +56 -0
- package/plugin/vendor/reflexio/reflexio/cli/_client.py +86 -0
- package/plugin/vendor/reflexio/reflexio/cli/app.py +127 -0
- package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +265 -0
- package/plugin/vendor/reflexio/reflexio/cli/codex_auth.py +503 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/admin_cmd.py +65 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/agent_playbooks.py +503 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/api.py +114 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/auth.py +109 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/config_cmd.py +511 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/doctor.py +127 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/embeddings.py +53 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/interactions.py +478 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/profiles.py +303 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/services.py +289 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +964 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/shortcuts.py +285 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/status_cmd.py +143 -0
- package/plugin/vendor/reflexio/reflexio/cli/commands/user_playbooks.py +373 -0
- package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +284 -0
- package/plugin/vendor/reflexio/reflexio/cli/errors.py +217 -0
- package/plugin/vendor/reflexio/reflexio/cli/log_format.py +247 -0
- package/plugin/vendor/reflexio/reflexio/cli/output.py +867 -0
- package/plugin/vendor/reflexio/reflexio/cli/paths.py +41 -0
- package/plugin/vendor/reflexio/reflexio/cli/run_services.py +391 -0
- package/plugin/vendor/reflexio/reflexio/cli/state.py +204 -0
- package/plugin/vendor/reflexio/reflexio/cli/stop_services.py +96 -0
- package/plugin/vendor/reflexio/reflexio/cli/utils.py +329 -0
- package/plugin/vendor/reflexio/reflexio/client/__init__.py +3 -0
- package/plugin/vendor/reflexio/reflexio/client/cache.py +150 -0
- package/plugin/vendor/reflexio/reflexio/client/client.py +2613 -0
- package/plugin/vendor/reflexio/reflexio/defaults.py +23 -0
- package/plugin/vendor/reflexio/reflexio/integrations/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/.clawhubignore +7 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/README.md +274 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/TESTING.md +517 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/hook/handler.js +473 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package-lock.json +2156 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package.json +18 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/handler.ts +241 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/setup.ts +140 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/index.ts +130 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/publish.ts +113 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/search.ts +52 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/server.ts +103 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/sqlite-buffer.ts +156 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/user-id.ts +134 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/openclaw.plugin.json +41 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/package.json +17 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/rules/reflexio.md +24 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/reflexio/SKILL.md +48 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/publish_clawhub.sh +278 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/references/HOOK.md +164 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/install.sh +36 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/uninstall.sh +35 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/publish.test.ts +27 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/search.test.ts +31 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/server.test.ts +42 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/setup.test.ts +49 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/sqlite-buffer.test.ts +91 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/user-id.test.ts +50 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tsconfig.json +16 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/types/openclaw.d.ts +230 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/vitest.config.ts +13 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/README.md +120 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/TESTING.md +168 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package-lock.json +1657 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package.json +16 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/HEARTBEAT.md +6 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/README.md +84 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/SKILL.md +194 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/_meta.json +6 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/agents/reflexio-extractor.md +45 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/handler.ts +214 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/setup.ts +55 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/index.ts +327 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/consolidate.ts +233 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/dedup.ts +80 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/io.ts +155 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/openclaw-cli.ts +67 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/search.ts +33 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-playbook.ts +76 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-profile.ts +79 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/openclaw.plugin.json +46 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/package.json +18 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/README.md +36 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/full_consolidation.md +56 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/playbook_extraction.md +217 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/profile_extraction.md +132 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-consolidate/SKILL.md +33 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-embedded/SKILL.md +194 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/HOOK.md +18 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/architecture.md +49 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/comparison.md +31 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/future-work.md +47 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/porting-notes.md +52 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/install.sh +52 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/uninstall.sh +36 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/consolidate.test.ts +135 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/dedup.test.ts +104 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/io.test.ts +175 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/search.test.ts +66 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/smoke-test.ts +140 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-playbook.test.ts +93 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-profile.test.ts +174 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tsconfig.json +16 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/types/openclaw.d.ts +230 -0
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/vitest.config.ts +7 -0
- package/plugin/vendor/reflexio/reflexio/lib/__init__.py +23 -0
- package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +310 -0
- package/plugin/vendor/reflexio/reflexio/lib/_base.py +225 -0
- package/plugin/vendor/reflexio/reflexio/lib/_config.py +83 -0
- package/plugin/vendor/reflexio/reflexio/lib/_dashboard.py +266 -0
- package/plugin/vendor/reflexio/reflexio/lib/_generation.py +176 -0
- package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +334 -0
- package/plugin/vendor/reflexio/reflexio/lib/_operations.py +153 -0
- package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +545 -0
- package/plugin/vendor/reflexio/reflexio/lib/_reflection.py +52 -0
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +167 -0
- package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +103 -0
- package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +288 -0
- package/plugin/vendor/reflexio/reflexio/lib/reflexio_lib.py +27 -0
- package/plugin/vendor/reflexio/reflexio/models/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/braintrust_schema.py +141 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/common.py +41 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +3 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +1112 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +63 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +487 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +28 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/pending_tool_call_schema.py +83 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +768 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/service_schemas.py +9 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/stall_state_schema.py +32 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/__init__.py +3 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +177 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +129 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/enums.py +25 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +333 -0
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +908 -0
- package/plugin/vendor/reflexio/reflexio/models/py.typed +0 -0
- package/plugin/vendor/reflexio/reflexio/server/OVERVIEW.md +90 -0
- package/plugin/vendor/reflexio/reflexio/server/README.md +622 -0
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +210 -0
- package/plugin/vendor/reflexio/reflexio/server/__main__.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/_auth.py +25 -0
- package/plugin/vendor/reflexio/reflexio/server/api.py +2868 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +34 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/account_api.py +143 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/health_api.py +91 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/pending_tool_call_api.py +572 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/precondition_checks.py +66 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +562 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +50 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/stall_state_api.py +100 -0
- package/plugin/vendor/reflexio/reflexio/server/cache/__init__.py +15 -0
- package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +208 -0
- package/plugin/vendor/reflexio/reflexio/server/correlation.py +46 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/__init__.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +359 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/image_utils.py +55 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1871 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +140 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +479 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/__init__.py +1 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1122 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_stream_parser.py +197 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +338 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +213 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +288 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/rerank/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +187 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/rerank/llm_reranker.py +148 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +716 -0
- package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +179 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/_dispatchers.py +54 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/README.md +121 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation/v1.0.0.prompt.md +58 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation_with_comparison/v1.0.0.prompt.md +76 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/answer_synthesis/v1.5.2.prompt.md +88 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/compress_session_for_query/v1.3.0.prompt.md +31 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +20 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.0.0.prompt.md +53 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.1.0.prompt.md +57 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.2.0.prompt.md +68 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.3.0.prompt.md +70 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.4.0.prompt.md +77 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.5.0.prompt.md +82 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.6.0.prompt.md +83 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.1.0.prompt.md +193 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.2.0.prompt.md +206 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0-deprecated.prompt.md +66 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0.prompt.md +43 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.1.0.prompt.md +46 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0-deprecated.prompt.md +64 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0.prompt.md +39 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.1.0.prompt.md +39 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.2.0.prompt.md +47 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +58 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.1.prompt.md +69 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +71 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.0.2.prompt.md +254 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.1.0.prompt.md +274 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +283 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.2.prompt.md +234 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +244 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v1.0.0.prompt.md +73 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v2.0.0.prompt.md +86 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.0.0.prompt.md +97 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.1.0.prompt.md +119 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.2.0.prompt.md +123 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +137 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.0.0.prompt.md +14 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.1.0.prompt.md +24 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.2.0.prompt.md +29 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.0.0.prompt.md +11 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.1.0.prompt.md +21 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.2.0.prompt.md +25 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.0.0.prompt.md +37 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.1.0.prompt.md +40 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.2.0.prompt.md +36 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v1.0.0.prompt.md +45 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v2.0.0.prompt.md +81 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v3.0.0.prompt.md +80 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate_expert/v1.0.0.prompt.md +34 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_deduplication/v1.0.0.prompt.md +116 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate/v1.0.0.prompt.md +33 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate_override/v1.0.0.prompt.md +16 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.0.0.prompt.md +140 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.1.0.prompt.md +160 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_main/v1.0.0.prompt.md +14 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +19 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/rerank_relevance/v1.1.0.prompt.md +44 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +43 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_content_evaluation/v1.0.0.prompt.md +33 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/feedback_extraction_main_v1.jsonl +10 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/profile_update_main_v1.jsonl +10 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_manager.py +280 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_schema.py +11 -0
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +58 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +131 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_constants.py +60 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_service.py +228 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_utils.py +87 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluator.py +372 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/delayed_group_evaluator.py +156 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +336 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +471 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +1668 -0
- package/plugin/vendor/reflexio/reflexio/server/services/braintrust/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_cron.py +196 -0
- package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_encryption.py +101 -0
- package/plugin/vendor/reflexio/reflexio/server/services/braintrust/client.py +167 -0
- package/plugin/vendor/reflexio/reflexio/server/services/braintrust/service.py +281 -0
- package/plugin/vendor/reflexio/reflexio/server/services/configurator/base_configurator.py +179 -0
- package/plugin/vendor/reflexio/reflexio/server/services/configurator/config_storage.py +62 -0
- package/plugin/vendor/reflexio/reflexio/server/services/configurator/configurator.py +87 -0
- package/plugin/vendor/reflexio/reflexio/server/services/configurator/local_file_config_storage.py +187 -0
- package/plugin/vendor/reflexio/reflexio/server/services/configurator/test_config_storage.py +162 -0
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +112 -0
- package/plugin/vendor/reflexio/reflexio/server/services/embedding_text.py +62 -0
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/distribution.py +33 -0
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/eval_sampler.py +126 -0
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/group_aggregation.py +192 -0
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/hero_state.py +75 -0
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/rule_attribution.py +97 -0
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +515 -0
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/shadow_aggregation.py +90 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +91 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/invariants.py +303 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +25 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +358 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/plan.py +138 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +217 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +535 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +171 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +779 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/tools.py +1125 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +94 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +251 -0
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +702 -0
- package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +835 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +89 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_aggregator.py +1388 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +1045 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_extractor.py +436 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_generation_service.py +808 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_constants.py +28 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_utils.py +362 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/__init__.py +24 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/assistant_webhook.py +246 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/gepa_adapter.py +291 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/judge.py +97 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/models.py +96 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +645 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/rollout.py +35 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scenario_resolver.py +93 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +174 -0
- package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/__init__.py +26 -0
- package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_document_expander.py +179 -0
- package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +297 -0
- package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +772 -0
- package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_extractor.py +462 -0
- package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +737 -0
- package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service_utils.py +290 -0
- package/plugin/vendor/reflexio/reflexio/server/services/reflection/__init__.py +17 -0
- package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_extractor.py +247 -0
- package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +803 -0
- package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service_utils.py +146 -0
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +80 -0
- package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
- package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +756 -0
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/__init__.py +1 -0
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/outcome.py +81 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/constants.py +2 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +11 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +154 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +155 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +59 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +1298 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +1945 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +600 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_operations.py +346 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1378 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +747 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +193 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +166 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_stall_state.py +217 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +153 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +384 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_base.py +71 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +235 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +170 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +677 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +250 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +154 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +130 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +93 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_stall_state.py +76 -0
- package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +572 -0
- package/plugin/vendor/reflexio/reflexio/server/site_var/README.md +77 -0
- package/plugin/vendor/reflexio/reflexio/server/site_var/feature_flags.py +116 -0
- package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_manager.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/feature_flags.json +13 -0
- package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/llm_model_setting.json +7 -0
- package/plugin/vendor/reflexio/reflexio/server/tracing.py +158 -0
- package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +113 -0
- package/plugin/vendor/reflexio/reflexio/server/uvicorn_logging.py +76 -0
- package/plugin/vendor/reflexio/reflexio/test_support/__init__.py +1 -0
- package/plugin/vendor/reflexio/reflexio/test_support/llm_fixtures.py +62 -0
- package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +242 -0
- package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +129 -0
- package/plugin/vendor/reflexio/reflexio/test_support/skip_decorators.py +43 -0
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from reflexio.models.api_schema.service_schemas import (
|
|
14
|
+
AgentPlaybook,
|
|
15
|
+
AgentPlaybookSnapshot,
|
|
16
|
+
AgentPlaybookSourceWindow,
|
|
17
|
+
AgentPlaybookUpdateEntry,
|
|
18
|
+
PlaybookAggregationChangeLog,
|
|
19
|
+
PlaybookStatus,
|
|
20
|
+
UserPlaybook,
|
|
21
|
+
agent_playbook_to_snapshot,
|
|
22
|
+
)
|
|
23
|
+
from reflexio.models.config_schema import (
|
|
24
|
+
SINGLETON_USER_PLAYBOOK_NAME,
|
|
25
|
+
PlaybookAggregatorConfig,
|
|
26
|
+
)
|
|
27
|
+
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
28
|
+
from reflexio.server.llm.litellm_client import LiteLLMClient
|
|
29
|
+
from reflexio.server.services.operation_state_utils import OperationStateManager
|
|
30
|
+
from reflexio.server.services.playbook.playbook_service_constants import (
|
|
31
|
+
PlaybookServiceConstants,
|
|
32
|
+
)
|
|
33
|
+
from reflexio.server.services.playbook.playbook_service_utils import (
|
|
34
|
+
PlaybookAggregationOutput,
|
|
35
|
+
PlaybookAggregatorRequest,
|
|
36
|
+
ensure_playbook_content,
|
|
37
|
+
)
|
|
38
|
+
from reflexio.server.services.service_utils import log_model_response
|
|
39
|
+
from reflexio.server.usage_metrics import record_usage_event
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
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
|
+
|
|
49
|
+
class PlaybookAggregator:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
llm_client: LiteLLMClient,
|
|
53
|
+
request_context: RequestContext,
|
|
54
|
+
agent_version: str,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.client = llm_client
|
|
57
|
+
self.storage = request_context.storage
|
|
58
|
+
self.configurator = request_context.configurator
|
|
59
|
+
self.request_context = request_context
|
|
60
|
+
self.agent_version = agent_version
|
|
61
|
+
|
|
62
|
+
# ===============================
|
|
63
|
+
# private methods - operation state
|
|
64
|
+
# ===============================
|
|
65
|
+
|
|
66
|
+
def _create_state_manager(self) -> OperationStateManager:
|
|
67
|
+
"""
|
|
68
|
+
Create an OperationStateManager for the playbook aggregator.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
OperationStateManager configured for playbook_aggregator
|
|
72
|
+
"""
|
|
73
|
+
return OperationStateManager(
|
|
74
|
+
self.storage, # type: ignore[reportArgumentType]
|
|
75
|
+
self.request_context.org_id,
|
|
76
|
+
"playbook_aggregator",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _get_new_user_playbooks_count(
|
|
80
|
+
self, playbook_name: str, rerun: bool = False
|
|
81
|
+
) -> int:
|
|
82
|
+
"""
|
|
83
|
+
Count how many new user playbooks exist since last aggregation.
|
|
84
|
+
Uses efficient SQL COUNT query instead of fetching all user playbooks.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
playbook_name: Name of the playbook type
|
|
88
|
+
rerun: If True, count all user playbooks (use last_processed_id=0)
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
int: Count of new user playbooks
|
|
92
|
+
"""
|
|
93
|
+
# For rerun, use 0 to process all user playbooks
|
|
94
|
+
if rerun:
|
|
95
|
+
last_processed_id = 0
|
|
96
|
+
else:
|
|
97
|
+
mgr = self._create_state_manager()
|
|
98
|
+
bookmark = mgr.get_aggregator_bookmark(
|
|
99
|
+
name=playbook_name, version=self.agent_version
|
|
100
|
+
)
|
|
101
|
+
last_processed_id = bookmark if bookmark is not None else 0
|
|
102
|
+
|
|
103
|
+
# Count user playbooks with ID greater than last processed using efficient count query
|
|
104
|
+
# Only count current user playbooks (status=None), not archived or pending ones.
|
|
105
|
+
# Singleton aggregation operates on the user's whole playbook set — no name filter.
|
|
106
|
+
new_count = self.storage.count_user_playbooks( # type: ignore[reportOptionalMemberAccess]
|
|
107
|
+
min_user_playbook_id=last_processed_id,
|
|
108
|
+
agent_version=self.agent_version,
|
|
109
|
+
status_filter=[None],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
logger.info(
|
|
113
|
+
"Found %d new user playbooks for '%s' (agent_version=%s, last processed ID: %d)",
|
|
114
|
+
new_count,
|
|
115
|
+
playbook_name,
|
|
116
|
+
self.agent_version,
|
|
117
|
+
last_processed_id,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return new_count
|
|
121
|
+
|
|
122
|
+
def _should_run_aggregation(
|
|
123
|
+
self,
|
|
124
|
+
playbook_name: str,
|
|
125
|
+
playbook_aggregator_config: PlaybookAggregatorConfig,
|
|
126
|
+
rerun: bool = False,
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""
|
|
129
|
+
Check if aggregation should run based on new user playbooks count.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
playbook_name: Name of the playbook type
|
|
133
|
+
playbook_aggregator_config: Configuration for playbook aggregator
|
|
134
|
+
rerun: If True, count all user playbooks to determine if aggregation is needed
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
bool: True if aggregation should run, False otherwise
|
|
138
|
+
"""
|
|
139
|
+
# Get reaggregation_trigger_count, default to 2 if not set or 0
|
|
140
|
+
trigger_count = playbook_aggregator_config.reaggregation_trigger_count
|
|
141
|
+
if trigger_count <= 0:
|
|
142
|
+
trigger_count = 2
|
|
143
|
+
|
|
144
|
+
# Check new user playbooks count (uses all playbooks if rerun=True)
|
|
145
|
+
new_count = self._get_new_user_playbooks_count(playbook_name, rerun=rerun)
|
|
146
|
+
|
|
147
|
+
return new_count >= trigger_count
|
|
148
|
+
|
|
149
|
+
def _update_operation_state(
|
|
150
|
+
self, playbook_name: str, user_playbooks: list[UserPlaybook]
|
|
151
|
+
) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Update operation state with the highest user_playbook_id processed.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
playbook_name: Name of the playbook type
|
|
157
|
+
user_playbooks: List of user playbooks that were processed
|
|
158
|
+
"""
|
|
159
|
+
if not user_playbooks:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# Find max user_playbook_id
|
|
163
|
+
max_id = max(playbook.user_playbook_id for playbook in user_playbooks)
|
|
164
|
+
|
|
165
|
+
mgr = self._create_state_manager()
|
|
166
|
+
mgr.update_aggregator_bookmark(
|
|
167
|
+
name=playbook_name,
|
|
168
|
+
version=self.agent_version,
|
|
169
|
+
last_processed_id=max_id,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _format_cluster_input(self, cluster_playbooks: list[UserPlaybook]) -> str:
|
|
173
|
+
"""
|
|
174
|
+
Format a cluster of playbooks for the aggregation prompt using per-item format.
|
|
175
|
+
|
|
176
|
+
Each playbook is shown as a self-contained unit with content as the
|
|
177
|
+
primary content, followed by optional structured fields as supplementary metadata.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
cluster_playbooks: List of raw playbooks in this cluster
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
str: Formatted input for the aggregation prompt
|
|
184
|
+
"""
|
|
185
|
+
blocks = []
|
|
186
|
+
for idx, fb in enumerate(cluster_playbooks, 1):
|
|
187
|
+
lines = [f"[{idx}]"]
|
|
188
|
+
if fb.content:
|
|
189
|
+
lines.append(f'Content: "{fb.content}"')
|
|
190
|
+
if fb.trigger:
|
|
191
|
+
lines.append(f'Trigger: "{fb.trigger}"')
|
|
192
|
+
if fb.rationale:
|
|
193
|
+
lines.append(f'Rationale: "{fb.rationale}"')
|
|
194
|
+
blocks.append("\n".join(lines))
|
|
195
|
+
return "\n\n".join(blocks) if blocks else "(No playbook items)"
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _get_direction_key(fb: UserPlaybook) -> str:
|
|
199
|
+
"""
|
|
200
|
+
Extract a similarity key from a user playbook for grouping.
|
|
201
|
+
|
|
202
|
+
Returns the raw content used for token-overlap comparison. Grouping is
|
|
203
|
+
purely content-similarity based: under Option B a skill may legitimately
|
|
204
|
+
hold mixed-orientation rules (do-rules and avoid-rules for different
|
|
205
|
+
sub-aspects of one task), so whole-content polarity is NOT derived or
|
|
206
|
+
gated here. Preserving distinct do/avoid rules when similar items are
|
|
207
|
+
merged is the aggregation prompt's responsibility.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
fb: A user playbook item
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
str: Content used as the similarity key for grouping
|
|
214
|
+
"""
|
|
215
|
+
return fb.content or ""
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _token_overlap(str1: str, str2: str, threshold: float = 0.6) -> bool:
|
|
219
|
+
"""
|
|
220
|
+
Check if two strings have significant token overlap using asymmetric containment.
|
|
221
|
+
|
|
222
|
+
Computes the ratio of shared tokens to the smaller set, so a short string
|
|
223
|
+
contained in a longer one still counts as a match.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
str1: First string
|
|
227
|
+
str2: Second string
|
|
228
|
+
threshold: Minimum overlap ratio
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
bool: True if overlap ratio >= threshold
|
|
232
|
+
"""
|
|
233
|
+
tokens1 = set(str1.lower().split())
|
|
234
|
+
tokens2 = set(str2.lower().split())
|
|
235
|
+
if not tokens1 or not tokens2:
|
|
236
|
+
return False
|
|
237
|
+
intersection = len(tokens1 & tokens2)
|
|
238
|
+
overlap_ratio = max(intersection / len(tokens1), intersection / len(tokens2))
|
|
239
|
+
return overlap_ratio >= threshold
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _group_playbooks_by_direction(
|
|
243
|
+
cluster_playbooks: list[UserPlaybook],
|
|
244
|
+
threshold: float = 0.6,
|
|
245
|
+
) -> list[list[UserPlaybook]]:
|
|
246
|
+
"""
|
|
247
|
+
Group playbooks by similarity of their content.
|
|
248
|
+
|
|
249
|
+
Uses greedy single-linkage: each playbook is assigned to the first
|
|
250
|
+
existing group that has any member with sufficient token overlap.
|
|
251
|
+
Groups are returned sorted by size descending (largest first).
|
|
252
|
+
|
|
253
|
+
Grouping is purely content-similarity based and does NOT gate on a
|
|
254
|
+
derived whole-content polarity. Under Option B a skill may hold
|
|
255
|
+
mixed-orientation rules (do-rules and avoid-rules for different
|
|
256
|
+
sub-aspects), and whole-content polarity is undefined for such a skill.
|
|
257
|
+
Keeping a do-rule and an avoid-rule as distinct rules when similar items
|
|
258
|
+
are merged into one skill is the aggregation prompt's responsibility,
|
|
259
|
+
not a mechanical split here.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
cluster_playbooks: List of raw playbooks to group
|
|
263
|
+
threshold: Token overlap threshold for grouping
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
list[list[UserPlaybook]]: Groups sorted by size descending
|
|
267
|
+
"""
|
|
268
|
+
groups: list[list[UserPlaybook]] = []
|
|
269
|
+
|
|
270
|
+
for fb in cluster_playbooks:
|
|
271
|
+
key = PlaybookAggregator._get_direction_key(fb)
|
|
272
|
+
matched = False
|
|
273
|
+
for group in groups:
|
|
274
|
+
if any(
|
|
275
|
+
PlaybookAggregator._token_overlap(
|
|
276
|
+
key,
|
|
277
|
+
PlaybookAggregator._get_direction_key(group_fb),
|
|
278
|
+
threshold,
|
|
279
|
+
)
|
|
280
|
+
for group_fb in group
|
|
281
|
+
):
|
|
282
|
+
group.append(fb)
|
|
283
|
+
matched = True
|
|
284
|
+
break
|
|
285
|
+
if not matched:
|
|
286
|
+
groups.append([fb])
|
|
287
|
+
|
|
288
|
+
# Sort by group size descending (largest first)
|
|
289
|
+
groups.sort(key=len, reverse=True)
|
|
290
|
+
return groups
|
|
291
|
+
|
|
292
|
+
def _format_structured_cluster_input(
|
|
293
|
+
self,
|
|
294
|
+
cluster_playbooks: list[UserPlaybook],
|
|
295
|
+
direction_overlap_threshold: float = 0.6,
|
|
296
|
+
) -> str:
|
|
297
|
+
"""
|
|
298
|
+
Format a cluster of playbooks for structured aggregation prompt.
|
|
299
|
+
|
|
300
|
+
When the cluster forms a single similarity group, uses the flat-list
|
|
301
|
+
format. When distinct similarity groups are detected (multiple groups),
|
|
302
|
+
uses a grouped format so the LLM can see which items are similar and
|
|
303
|
+
preserve distinct rules (e.g. a do-rule and an avoid-rule) as separate
|
|
304
|
+
rules in the merged skill rather than collapsing them.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
cluster_playbooks: List of raw playbooks in this cluster
|
|
308
|
+
direction_overlap_threshold: Token overlap threshold for grouping by direction
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
str: Formatted input for the aggregation prompt
|
|
312
|
+
"""
|
|
313
|
+
groups = self._group_playbooks_by_direction(
|
|
314
|
+
cluster_playbooks, threshold=direction_overlap_threshold
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if len(groups) <= 1:
|
|
318
|
+
return self._format_flat(cluster_playbooks)
|
|
319
|
+
return self._format_grouped(groups)
|
|
320
|
+
|
|
321
|
+
def _format_flat(self, cluster_playbooks: list[UserPlaybook]) -> str:
|
|
322
|
+
"""
|
|
323
|
+
Format playbooks as flat bullet lists (original format, used when no conflict).
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
cluster_playbooks: List of raw playbooks in this cluster
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
str: Formatted input with separate field lists
|
|
330
|
+
"""
|
|
331
|
+
triggers = []
|
|
332
|
+
rationales = []
|
|
333
|
+
|
|
334
|
+
for fb in cluster_playbooks:
|
|
335
|
+
if fb.trigger:
|
|
336
|
+
triggers.append(fb.trigger)
|
|
337
|
+
if fb.rationale:
|
|
338
|
+
rationales.append(fb.rationale)
|
|
339
|
+
|
|
340
|
+
lines: list[str] = []
|
|
341
|
+
|
|
342
|
+
if triggers:
|
|
343
|
+
lines.append("TRIGGER conditions (to be consolidated):")
|
|
344
|
+
lines.extend(f"- {trigger}" for trigger in triggers)
|
|
345
|
+
else:
|
|
346
|
+
lines.append("TRIGGER conditions: (none specified)")
|
|
347
|
+
|
|
348
|
+
if rationales:
|
|
349
|
+
lines.append("RATIONALE summaries:")
|
|
350
|
+
lines.extend(f"- {r}" for r in rationales)
|
|
351
|
+
|
|
352
|
+
self._append_freeform_observations(lines, cluster_playbooks)
|
|
353
|
+
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
def _format_grouped(
|
|
357
|
+
self,
|
|
358
|
+
groups: list[list[UserPlaybook]],
|
|
359
|
+
) -> str:
|
|
360
|
+
"""
|
|
361
|
+
Format playbooks in grouped layout (used when conflicting directions are detected).
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
groups: AgentPlaybook groups sorted by size descending
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
str: Formatted input with group headers and per-playbook fields
|
|
368
|
+
"""
|
|
369
|
+
lines: list[str] = [
|
|
370
|
+
"The following playbook items are grouped by similarity. "
|
|
371
|
+
"Groups are ordered by size (largest first).",
|
|
372
|
+
"",
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
for idx, group in enumerate(groups, start=1):
|
|
376
|
+
count_label = "playbook" if len(group) == 1 else "playbooks"
|
|
377
|
+
lines.append(f"Group {idx} ({len(group)} {count_label}):")
|
|
378
|
+
for fb in group:
|
|
379
|
+
parts: list[str] = []
|
|
380
|
+
if fb.trigger:
|
|
381
|
+
parts.append(f'Trigger: "{fb.trigger}"')
|
|
382
|
+
if fb.rationale:
|
|
383
|
+
parts.append(f'Rationale: "{fb.rationale}"')
|
|
384
|
+
if not parts and fb.content:
|
|
385
|
+
parts.append(f'AgentPlaybook: "{fb.content}"')
|
|
386
|
+
if parts:
|
|
387
|
+
lines.append(f" - {parts[0]}")
|
|
388
|
+
lines.extend(f" {p}" for p in parts[1:])
|
|
389
|
+
lines.append("")
|
|
390
|
+
|
|
391
|
+
return "\n".join(lines)
|
|
392
|
+
|
|
393
|
+
@staticmethod
|
|
394
|
+
def _append_freeform_observations(
|
|
395
|
+
lines: list[str], cluster_playbooks: list[UserPlaybook]
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Append freeform observations from cluster playbooks to output lines."""
|
|
398
|
+
freeform_observations = [
|
|
399
|
+
fb.content for fb in cluster_playbooks if not fb.trigger and fb.content
|
|
400
|
+
]
|
|
401
|
+
if freeform_observations:
|
|
402
|
+
lines.append("Freeform observations (from freeform cluster members):")
|
|
403
|
+
lines.extend(f"- {obs}" for obs in freeform_observations)
|
|
404
|
+
|
|
405
|
+
# ===============================
|
|
406
|
+
# private methods - cluster change detection
|
|
407
|
+
# ===============================
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _compute_cluster_fingerprint(cluster_playbooks: list[UserPlaybook]) -> str:
|
|
411
|
+
"""
|
|
412
|
+
Compute a fingerprint for a cluster based on its user_playbook_ids.
|
|
413
|
+
The fingerprint is deterministic and order-independent.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
cluster_playbooks: List of raw playbooks in this cluster
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
str: SHA-256 hash (truncated to 16 hex chars) of sorted user_playbook_ids
|
|
420
|
+
"""
|
|
421
|
+
sorted_ids = sorted(fb.user_playbook_id for fb in cluster_playbooks)
|
|
422
|
+
id_str = ",".join(str(id) for id in sorted_ids)
|
|
423
|
+
return hashlib.sha256(id_str.encode()).hexdigest()[:16]
|
|
424
|
+
|
|
425
|
+
def _determine_cluster_changes(
|
|
426
|
+
self,
|
|
427
|
+
clusters: dict[int, list[UserPlaybook]],
|
|
428
|
+
prev_fingerprints: dict,
|
|
429
|
+
) -> tuple[dict[int, list[UserPlaybook]], list[int]]:
|
|
430
|
+
"""
|
|
431
|
+
Compare current cluster fingerprints against stored fingerprints to determine changes.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
clusters: Current clusters (cluster_id -> list of UserPlaybook)
|
|
435
|
+
prev_fingerprints: Previous fingerprint state
|
|
436
|
+
(fingerprint_hash -> {"agent_playbook_id": int, "user_playbook_ids": list})
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
tuple of:
|
|
440
|
+
- changed_clusters: Only clusters needing new LLM calls
|
|
441
|
+
- playbook_ids_to_archive: Old playbook_ids from changed/disappeared clusters
|
|
442
|
+
"""
|
|
443
|
+
# Compute fingerprints for current clusters
|
|
444
|
+
current_fingerprints = {}
|
|
445
|
+
for cluster_id, cluster_playbooks in clusters.items():
|
|
446
|
+
fp = self._compute_cluster_fingerprint(cluster_playbooks)
|
|
447
|
+
current_fingerprints[cluster_id] = fp
|
|
448
|
+
|
|
449
|
+
current_fp_set = set(current_fingerprints.values())
|
|
450
|
+
prev_fp_set = set(prev_fingerprints.keys())
|
|
451
|
+
|
|
452
|
+
# Changed clusters: fingerprints that are new (not in previous state)
|
|
453
|
+
changed_clusters = {}
|
|
454
|
+
for cluster_id, fp in current_fingerprints.items():
|
|
455
|
+
if fp not in prev_fp_set:
|
|
456
|
+
changed_clusters[cluster_id] = clusters[cluster_id]
|
|
457
|
+
|
|
458
|
+
# Playbook IDs to archive: from fingerprints that disappeared or changed
|
|
459
|
+
playbook_ids_to_archive = []
|
|
460
|
+
for fp, fp_data in prev_fingerprints.items():
|
|
461
|
+
if fp not in current_fp_set:
|
|
462
|
+
playbook_id = fp_data.get("agent_playbook_id")
|
|
463
|
+
if playbook_id is not None:
|
|
464
|
+
playbook_ids_to_archive.append(playbook_id)
|
|
465
|
+
|
|
466
|
+
return changed_clusters, playbook_ids_to_archive
|
|
467
|
+
|
|
468
|
+
def _build_change_log(
|
|
469
|
+
self,
|
|
470
|
+
playbook_name: str,
|
|
471
|
+
full_archive: bool,
|
|
472
|
+
before_playbooks_by_id: dict[int, AgentPlaybook],
|
|
473
|
+
saved_playbooks: list[AgentPlaybook],
|
|
474
|
+
archived_playbook_ids: list[int],
|
|
475
|
+
prev_fingerprints: dict,
|
|
476
|
+
) -> PlaybookAggregationChangeLog:
|
|
477
|
+
"""Build a PlaybookAggregationChangeLog from the aggregation run results.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
playbook_name: The playbook name being aggregated
|
|
481
|
+
full_archive: Whether this was a full archive (rerun/first run)
|
|
482
|
+
before_playbooks_by_id: Snapshot of playbooks before archiving, keyed by playbook_id
|
|
483
|
+
saved_playbooks: Newly saved playbooks from this run
|
|
484
|
+
archived_playbook_ids: AgentPlaybook IDs that were selectively archived (incremental mode)
|
|
485
|
+
prev_fingerprints: Previous cluster fingerprints (empty for full archive)
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
PlaybookAggregationChangeLog with added/removed/updated lists populated
|
|
489
|
+
"""
|
|
490
|
+
added: list[AgentPlaybookSnapshot] = []
|
|
491
|
+
removed: list[AgentPlaybookSnapshot] = []
|
|
492
|
+
updated: list[AgentPlaybookUpdateEntry] = []
|
|
493
|
+
|
|
494
|
+
if full_archive:
|
|
495
|
+
# No 1:1 mapping — all old playbooks are removed, all new are added
|
|
496
|
+
removed = [
|
|
497
|
+
agent_playbook_to_snapshot(fb) for fb in before_playbooks_by_id.values()
|
|
498
|
+
]
|
|
499
|
+
added = [agent_playbook_to_snapshot(fb) for fb in saved_playbooks if fb]
|
|
500
|
+
else:
|
|
501
|
+
# Incremental mode: map old playbook_ids to new playbooks via fingerprints
|
|
502
|
+
# Build a set of old playbook_ids that were archived
|
|
503
|
+
archived_id_set = set(archived_playbook_ids)
|
|
504
|
+
|
|
505
|
+
# Build mapping: old_playbook_id -> new_playbook_id via fingerprint changes
|
|
506
|
+
# prev_fingerprints maps fp_hash -> {playbook_id, user_playbook_ids}
|
|
507
|
+
# new_fingerprints maps fp_hash -> {playbook_id, user_playbook_ids}
|
|
508
|
+
# If an old fingerprint disappeared and a new one appeared, and
|
|
509
|
+
# the old fp had a playbook_id in archived_id_set, we can try to pair them.
|
|
510
|
+
# However, without a direct cluster-level old->new mapping, we use a simpler approach:
|
|
511
|
+
# archived playbooks that have a corresponding new playbook (by position in saved list) are updates.
|
|
512
|
+
|
|
513
|
+
# Collect old playbook_ids from disappeared fingerprints
|
|
514
|
+
old_fp_playbook_ids = {}
|
|
515
|
+
for fp, fp_data in prev_fingerprints.items():
|
|
516
|
+
fid = fp_data.get("agent_playbook_id")
|
|
517
|
+
if fid is not None and fid in archived_id_set:
|
|
518
|
+
old_fp_playbook_ids[fid] = fp
|
|
519
|
+
|
|
520
|
+
# For each saved playbook, try to match with an archived old playbook
|
|
521
|
+
matched_old_ids: set[int] = set()
|
|
522
|
+
for saved_fb in saved_playbooks:
|
|
523
|
+
if not saved_fb:
|
|
524
|
+
continue
|
|
525
|
+
# Try to find an old playbook from the archived set to pair with
|
|
526
|
+
paired_old_id = None
|
|
527
|
+
for old_id in list(old_fp_playbook_ids.keys()):
|
|
528
|
+
if old_id not in matched_old_ids:
|
|
529
|
+
paired_old_id = old_id
|
|
530
|
+
matched_old_ids.add(old_id)
|
|
531
|
+
break
|
|
532
|
+
|
|
533
|
+
if (
|
|
534
|
+
paired_old_id is not None
|
|
535
|
+
and paired_old_id in before_playbooks_by_id
|
|
536
|
+
):
|
|
537
|
+
updated.append(
|
|
538
|
+
AgentPlaybookUpdateEntry(
|
|
539
|
+
before=agent_playbook_to_snapshot(
|
|
540
|
+
before_playbooks_by_id[paired_old_id]
|
|
541
|
+
),
|
|
542
|
+
after=agent_playbook_to_snapshot(saved_fb),
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
added.append(agent_playbook_to_snapshot(saved_fb))
|
|
547
|
+
|
|
548
|
+
# Remaining archived playbooks that weren't paired are removals
|
|
549
|
+
for old_id in archived_id_set:
|
|
550
|
+
if old_id not in matched_old_ids and old_id in before_playbooks_by_id:
|
|
551
|
+
removed.append(
|
|
552
|
+
agent_playbook_to_snapshot(before_playbooks_by_id[old_id])
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
return PlaybookAggregationChangeLog(
|
|
556
|
+
playbook_name=playbook_name,
|
|
557
|
+
agent_version=self.agent_version,
|
|
558
|
+
run_mode="full_archive" if full_archive else "incremental",
|
|
559
|
+
added_agent_playbooks=added,
|
|
560
|
+
removed_agent_playbooks=removed,
|
|
561
|
+
updated_agent_playbooks=updated,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# ===============================
|
|
565
|
+
# public methods
|
|
566
|
+
# ===============================
|
|
567
|
+
|
|
568
|
+
def run(self, playbook_aggregator_request: PlaybookAggregatorRequest) -> dict: # noqa: C901
|
|
569
|
+
"""Run playbook aggregation.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
dict: Aggregation stats with keys: clusters_found, user_playbooks_processed, playbooks_generated, skipped (optional)
|
|
573
|
+
"""
|
|
574
|
+
aggregation_start = time.perf_counter()
|
|
575
|
+
_empty_stats = {
|
|
576
|
+
"clusters_found": 0,
|
|
577
|
+
"user_playbooks_processed": 0,
|
|
578
|
+
"playbooks_generated": 0,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
# Singleton aggregation: one playbook kind per org. The name is a fixed
|
|
582
|
+
# constant used only for bookmark/archive scoping and telemetry — it is
|
|
583
|
+
# never a selection filter on the read queries below.
|
|
584
|
+
playbook_name = SINGLETON_USER_PLAYBOOK_NAME
|
|
585
|
+
|
|
586
|
+
# get playbook aggregator config
|
|
587
|
+
playbook_aggregator_config = self._get_playbook_aggregator_config()
|
|
588
|
+
if (
|
|
589
|
+
not playbook_aggregator_config
|
|
590
|
+
or playbook_aggregator_config.min_cluster_size < 2
|
|
591
|
+
):
|
|
592
|
+
skip_reason = "no aggregator config or min_cluster_size < 2"
|
|
593
|
+
record_usage_event(
|
|
594
|
+
org_id=self.request_context.org_id,
|
|
595
|
+
event_name="aggregation_gate_evaluated",
|
|
596
|
+
event_category="aggregation",
|
|
597
|
+
pipeline="playbook",
|
|
598
|
+
playbook_name=playbook_name,
|
|
599
|
+
agent_version=self.agent_version,
|
|
600
|
+
outcome="should_skip",
|
|
601
|
+
metadata={"skip_reason": skip_reason},
|
|
602
|
+
)
|
|
603
|
+
logger.info(
|
|
604
|
+
"Skipping user playbook aggregation for '%s' (agent_version=%s): no aggregator config or min_cluster_size < 2, config: %s",
|
|
605
|
+
playbook_name,
|
|
606
|
+
self.agent_version,
|
|
607
|
+
playbook_aggregator_config,
|
|
608
|
+
)
|
|
609
|
+
return {
|
|
610
|
+
**_empty_stats,
|
|
611
|
+
"skipped": skip_reason,
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
# Check if we should run aggregation based on new playbooks count
|
|
615
|
+
# For rerun, use all user playbooks (last_processed_id=0) to determine if aggregation is needed
|
|
616
|
+
if not self._should_run_aggregation(
|
|
617
|
+
playbook_name,
|
|
618
|
+
playbook_aggregator_config,
|
|
619
|
+
rerun=playbook_aggregator_request.rerun,
|
|
620
|
+
):
|
|
621
|
+
new_count = self._get_new_user_playbooks_count(
|
|
622
|
+
playbook_name,
|
|
623
|
+
rerun=playbook_aggregator_request.rerun,
|
|
624
|
+
)
|
|
625
|
+
trigger_count = (
|
|
626
|
+
playbook_aggregator_config.reaggregation_trigger_count
|
|
627
|
+
if playbook_aggregator_config.reaggregation_trigger_count > 0
|
|
628
|
+
else 2
|
|
629
|
+
)
|
|
630
|
+
logger.info(
|
|
631
|
+
"Skipping user playbook aggregation for '%s' (agent_version=%s) - only %d new user playbooks (need %d)",
|
|
632
|
+
playbook_name,
|
|
633
|
+
self.agent_version,
|
|
634
|
+
new_count,
|
|
635
|
+
trigger_count,
|
|
636
|
+
)
|
|
637
|
+
record_usage_event(
|
|
638
|
+
org_id=self.request_context.org_id,
|
|
639
|
+
event_name="aggregation_gate_evaluated",
|
|
640
|
+
event_category="aggregation",
|
|
641
|
+
pipeline="playbook",
|
|
642
|
+
playbook_name=playbook_name,
|
|
643
|
+
agent_version=self.agent_version,
|
|
644
|
+
outcome="should_skip",
|
|
645
|
+
count_value=new_count,
|
|
646
|
+
metadata={
|
|
647
|
+
"new_user_playbooks": new_count,
|
|
648
|
+
"trigger_count": trigger_count,
|
|
649
|
+
},
|
|
650
|
+
)
|
|
651
|
+
return {
|
|
652
|
+
**_empty_stats,
|
|
653
|
+
"skipped": f"not enough new playbooks ({new_count} < {trigger_count})",
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
record_usage_event(
|
|
657
|
+
org_id=self.request_context.org_id,
|
|
658
|
+
event_name="aggregation_gate_evaluated",
|
|
659
|
+
event_category="aggregation",
|
|
660
|
+
pipeline="playbook",
|
|
661
|
+
playbook_name=playbook_name,
|
|
662
|
+
agent_version=self.agent_version,
|
|
663
|
+
outcome="should_run",
|
|
664
|
+
)
|
|
665
|
+
logger.info(
|
|
666
|
+
"Running user playbook aggregation for '%s' (agent_version=%s)",
|
|
667
|
+
playbook_name,
|
|
668
|
+
self.agent_version,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# Get existing APPROVED and PENDING playbooks before archiving (to pass to LLM for deduplication).
|
|
672
|
+
# Singleton aggregation pulls the user's whole set — no name filter.
|
|
673
|
+
existing_playbooks = self.storage.get_agent_playbooks( # type: ignore[reportOptionalMemberAccess]
|
|
674
|
+
status_filter=[None], # Current playbooks only
|
|
675
|
+
playbook_status_filter=[PlaybookStatus.APPROVED, PlaybookStatus.PENDING],
|
|
676
|
+
)
|
|
677
|
+
logger.info(
|
|
678
|
+
"Found %s existing playbooks (approved + pending) to preserve",
|
|
679
|
+
len(existing_playbooks),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
# get all user playbooks and generate clusters
|
|
683
|
+
user_playbooks = self.storage.get_user_playbooks( # type: ignore[reportOptionalMemberAccess]
|
|
684
|
+
agent_version=self.agent_version,
|
|
685
|
+
include_embedding=True,
|
|
686
|
+
)
|
|
687
|
+
full_archive_playbook_names = sorted(
|
|
688
|
+
{
|
|
689
|
+
playbook.playbook_name
|
|
690
|
+
for playbook in [*existing_playbooks, *user_playbooks]
|
|
691
|
+
if playbook.playbook_name
|
|
692
|
+
}
|
|
693
|
+
| {playbook_name}
|
|
694
|
+
)
|
|
695
|
+
clusters = self.get_clusters(user_playbooks, playbook_aggregator_config)
|
|
696
|
+
|
|
697
|
+
# Capture all current playbooks before archiving (for change log)
|
|
698
|
+
before_playbooks_by_id: dict[int, AgentPlaybook] = {
|
|
699
|
+
fb.agent_playbook_id: fb for fb in existing_playbooks
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
# Determine which clusters changed (skip for rerun)
|
|
703
|
+
mgr = self._create_state_manager()
|
|
704
|
+
archived_playbook_ids = []
|
|
705
|
+
full_archive = False
|
|
706
|
+
prev_fingerprints: dict = {} # Populated for incremental mode
|
|
707
|
+
|
|
708
|
+
# Deferred-archive flag: full archive is performed AFTER LLM generation,
|
|
709
|
+
# and only when at least one new playbook was produced. Avoids silently
|
|
710
|
+
# dropping existing PENDING/APPROVED playbooks when the LLM returns
|
|
711
|
+
# null (cluster identified as duplicate of existing).
|
|
712
|
+
pending_full_archive = False
|
|
713
|
+
|
|
714
|
+
if playbook_aggregator_request.rerun:
|
|
715
|
+
logger.info("Rerun requested: bypassing cluster change detection")
|
|
716
|
+
changed_clusters = clusters
|
|
717
|
+
full_archive = True
|
|
718
|
+
pending_full_archive = True
|
|
719
|
+
else:
|
|
720
|
+
# Load previous fingerprints and detect changes
|
|
721
|
+
prev_fingerprints = mgr.get_cluster_fingerprints(
|
|
722
|
+
name=playbook_name, version=self.agent_version
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
if not prev_fingerprints:
|
|
726
|
+
logger.info(
|
|
727
|
+
"No previous cluster fingerprints found, treating all clusters as changed"
|
|
728
|
+
)
|
|
729
|
+
changed_clusters = clusters
|
|
730
|
+
full_archive = True
|
|
731
|
+
pending_full_archive = True
|
|
732
|
+
else:
|
|
733
|
+
(
|
|
734
|
+
changed_clusters,
|
|
735
|
+
archived_playbook_ids,
|
|
736
|
+
) = self._determine_cluster_changes(clusters, prev_fingerprints)
|
|
737
|
+
|
|
738
|
+
if not changed_clusters and not archived_playbook_ids:
|
|
739
|
+
logger.info(
|
|
740
|
+
"No cluster changes detected for '%s', skipping LLM calls",
|
|
741
|
+
playbook_name,
|
|
742
|
+
)
|
|
743
|
+
# Still update bookmark
|
|
744
|
+
self._update_operation_state(playbook_name, user_playbooks)
|
|
745
|
+
record_usage_event(
|
|
746
|
+
org_id=self.request_context.org_id,
|
|
747
|
+
event_name="aggregation_succeeded",
|
|
748
|
+
event_category="aggregation",
|
|
749
|
+
pipeline="playbook",
|
|
750
|
+
playbook_name=playbook_name,
|
|
751
|
+
agent_version=self.agent_version,
|
|
752
|
+
outcome="success",
|
|
753
|
+
count_value=0,
|
|
754
|
+
duration_ms=int(
|
|
755
|
+
(time.perf_counter() - aggregation_start) * 1000
|
|
756
|
+
),
|
|
757
|
+
metadata={"skipped": "no cluster changes detected"},
|
|
758
|
+
)
|
|
759
|
+
return {**_empty_stats, "skipped": "no cluster changes detected"}
|
|
760
|
+
|
|
761
|
+
logger.info(
|
|
762
|
+
"Detected %d changed clusters, %d playbooks to archive",
|
|
763
|
+
len(changed_clusters),
|
|
764
|
+
len(archived_playbook_ids),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
# Selectively archive only playbooks from changed/disappeared clusters
|
|
768
|
+
if archived_playbook_ids:
|
|
769
|
+
self.storage.archive_agent_playbooks_by_ids(archived_playbook_ids) # type: ignore[reportOptionalMemberAccess]
|
|
770
|
+
|
|
771
|
+
try:
|
|
772
|
+
# Emit the started event inside the protected block so any failure
|
|
773
|
+
# from here on is paired with an aggregation_failed event.
|
|
774
|
+
record_usage_event(
|
|
775
|
+
org_id=self.request_context.org_id,
|
|
776
|
+
event_name="aggregation_started",
|
|
777
|
+
event_category="aggregation",
|
|
778
|
+
pipeline="playbook",
|
|
779
|
+
playbook_name=playbook_name,
|
|
780
|
+
agent_version=self.agent_version,
|
|
781
|
+
outcome="started",
|
|
782
|
+
)
|
|
783
|
+
# Generate new playbooks only for changed clusters while preserving
|
|
784
|
+
# the exact source cluster for each non-duplicate playbook.
|
|
785
|
+
generated_pairs = self._generate_playbooks_with_source_clusters(
|
|
786
|
+
changed_clusters,
|
|
787
|
+
existing_playbooks,
|
|
788
|
+
direction_overlap_threshold=playbook_aggregator_config.direction_overlap_threshold,
|
|
789
|
+
)
|
|
790
|
+
new_playbooks = [playbook for playbook, _ in generated_pairs]
|
|
791
|
+
|
|
792
|
+
# Lazy archive: only full-archive when the LLM produced replacements.
|
|
793
|
+
# Skipping the archive when new_playbooks is empty preserves existing
|
|
794
|
+
# PENDING/APPROVED playbooks that the LLM identified as duplicates.
|
|
795
|
+
if pending_full_archive:
|
|
796
|
+
if new_playbooks:
|
|
797
|
+
for name in full_archive_playbook_names:
|
|
798
|
+
self.storage.archive_agent_playbooks_by_playbook_name( # type: ignore[reportOptionalMemberAccess]
|
|
799
|
+
name, agent_version=self.agent_version
|
|
800
|
+
)
|
|
801
|
+
else:
|
|
802
|
+
logger.info(
|
|
803
|
+
"Skipping full archive of %s (agent_version=%s): LLM produced 0 new playbooks; existing PENDING/APPROVED playbooks preserved",
|
|
804
|
+
full_archive_playbook_names,
|
|
805
|
+
self.agent_version,
|
|
806
|
+
)
|
|
807
|
+
full_archive = False
|
|
808
|
+
|
|
809
|
+
# Save playbooks (returns playbooks with playbook_id populated)
|
|
810
|
+
saved_playbooks = self.storage.save_agent_playbooks(new_playbooks) # type: ignore[reportOptionalMemberAccess]
|
|
811
|
+
|
|
812
|
+
# Build new fingerprint state
|
|
813
|
+
new_fingerprints = {}
|
|
814
|
+
|
|
815
|
+
if not playbook_aggregator_request.rerun:
|
|
816
|
+
# Carry forward unchanged fingerprints from previous state
|
|
817
|
+
prev_fps = mgr.get_cluster_fingerprints(
|
|
818
|
+
name=playbook_name, version=self.agent_version
|
|
819
|
+
)
|
|
820
|
+
current_fp_set = set()
|
|
821
|
+
for cluster_playbooks in clusters.values():
|
|
822
|
+
fp = self._compute_cluster_fingerprint(cluster_playbooks)
|
|
823
|
+
current_fp_set.add(fp)
|
|
824
|
+
|
|
825
|
+
changed_fp_set = set()
|
|
826
|
+
for cluster_playbooks in changed_clusters.values():
|
|
827
|
+
changed_fp_set.add(
|
|
828
|
+
self._compute_cluster_fingerprint(cluster_playbooks)
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
# Carry forward unchanged clusters (still exist and not changed)
|
|
832
|
+
new_fingerprints.update(
|
|
833
|
+
{
|
|
834
|
+
fp: fp_data
|
|
835
|
+
for fp, fp_data in prev_fps.items()
|
|
836
|
+
if fp in current_fp_set and fp not in changed_fp_set
|
|
837
|
+
}
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
# Initialize changed cluster fingerprints before assigning saved IDs
|
|
841
|
+
# below. ``generated_pairs`` preserves the exact source cluster for
|
|
842
|
+
# every non-duplicate playbook the LLM produced.
|
|
843
|
+
for cluster_playbooks in changed_clusters.values():
|
|
844
|
+
fp = self._compute_cluster_fingerprint(cluster_playbooks)
|
|
845
|
+
raw_ids = sorted(fb.user_playbook_id for fb in cluster_playbooks)
|
|
846
|
+
|
|
847
|
+
new_fingerprints[fp] = {
|
|
848
|
+
"agent_playbook_id": None,
|
|
849
|
+
"user_playbook_ids": raw_ids,
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
saved_playbook_list = list(saved_playbooks)
|
|
853
|
+
|
|
854
|
+
# Assign saved playbook ids to the exact source cluster that generated them.
|
|
855
|
+
for saved_fb, (_, cluster_playbooks) in zip(
|
|
856
|
+
saved_playbook_list, generated_pairs, strict=False
|
|
857
|
+
):
|
|
858
|
+
if saved_fb and saved_fb.agent_playbook_id:
|
|
859
|
+
fp_key = self._compute_cluster_fingerprint(cluster_playbooks)
|
|
860
|
+
raw_ids = sorted(fb.user_playbook_id for fb in cluster_playbooks)
|
|
861
|
+
new_fingerprints[fp_key] = {
|
|
862
|
+
"agent_playbook_id": saved_fb.agent_playbook_id,
|
|
863
|
+
"user_playbook_ids": raw_ids,
|
|
864
|
+
}
|
|
865
|
+
self.storage.set_source_windows_for_agent_playbook( # type: ignore[reportOptionalMemberAccess]
|
|
866
|
+
saved_fb.agent_playbook_id,
|
|
867
|
+
[
|
|
868
|
+
AgentPlaybookSourceWindow(
|
|
869
|
+
user_playbook_id=fb.user_playbook_id,
|
|
870
|
+
source_interaction_ids=list(fb.source_interaction_ids),
|
|
871
|
+
)
|
|
872
|
+
for fb in sorted(
|
|
873
|
+
cluster_playbooks,
|
|
874
|
+
key=lambda item: item.user_playbook_id,
|
|
875
|
+
)
|
|
876
|
+
],
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
# Store fingerprints in operation state
|
|
880
|
+
mgr.update_cluster_fingerprints(
|
|
881
|
+
name=playbook_name,
|
|
882
|
+
version=self.agent_version,
|
|
883
|
+
fingerprints=new_fingerprints,
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
# Update operation state with the highest user_playbook_id processed
|
|
887
|
+
self._update_operation_state(playbook_name, user_playbooks)
|
|
888
|
+
|
|
889
|
+
# Build and save change log
|
|
890
|
+
try:
|
|
891
|
+
change_log = self._build_change_log(
|
|
892
|
+
playbook_name=playbook_name,
|
|
893
|
+
full_archive=full_archive,
|
|
894
|
+
before_playbooks_by_id=before_playbooks_by_id,
|
|
895
|
+
saved_playbooks=saved_playbook_list,
|
|
896
|
+
archived_playbook_ids=archived_playbook_ids,
|
|
897
|
+
prev_fingerprints=(prev_fingerprints if not full_archive else {}),
|
|
898
|
+
)
|
|
899
|
+
self.storage.add_playbook_aggregation_change_log(change_log) # type: ignore[reportOptionalMemberAccess]
|
|
900
|
+
logger.info(
|
|
901
|
+
"User playbook aggregation change log for '%s' (agent_version=%s): %d agent playbooks added, %d removed, %d updated",
|
|
902
|
+
playbook_name,
|
|
903
|
+
self.agent_version,
|
|
904
|
+
len(change_log.added_agent_playbooks),
|
|
905
|
+
len(change_log.removed_agent_playbooks),
|
|
906
|
+
len(change_log.updated_agent_playbooks),
|
|
907
|
+
)
|
|
908
|
+
except Exception:
|
|
909
|
+
logger.exception(
|
|
910
|
+
"Failed to save playbook aggregation change log for '%s', continuing",
|
|
911
|
+
playbook_name,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Delete archived playbooks after successful aggregation
|
|
915
|
+
if full_archive:
|
|
916
|
+
for name in full_archive_playbook_names:
|
|
917
|
+
self.storage.delete_archived_agent_playbooks_by_playbook_name( # type: ignore[reportOptionalMemberAccess]
|
|
918
|
+
name, agent_version=self.agent_version
|
|
919
|
+
)
|
|
920
|
+
elif archived_playbook_ids:
|
|
921
|
+
self.storage.delete_agent_playbooks_by_ids(archived_playbook_ids) # type: ignore[reportOptionalMemberAccess]
|
|
922
|
+
|
|
923
|
+
self._enqueue_playbook_optimization(saved_playbook_list)
|
|
924
|
+
|
|
925
|
+
stats = {
|
|
926
|
+
"clusters_found": len(clusters),
|
|
927
|
+
"user_playbooks_processed": len(user_playbooks),
|
|
928
|
+
"playbooks_generated": len(saved_playbook_list),
|
|
929
|
+
}
|
|
930
|
+
record_usage_event(
|
|
931
|
+
org_id=self.request_context.org_id,
|
|
932
|
+
event_name="aggregation_succeeded",
|
|
933
|
+
event_category="aggregation",
|
|
934
|
+
pipeline="playbook",
|
|
935
|
+
playbook_name=playbook_name,
|
|
936
|
+
agent_version=self.agent_version,
|
|
937
|
+
outcome="success",
|
|
938
|
+
count_value=len(saved_playbook_list),
|
|
939
|
+
duration_ms=int((time.perf_counter() - aggregation_start) * 1000),
|
|
940
|
+
metadata=stats,
|
|
941
|
+
)
|
|
942
|
+
return stats
|
|
943
|
+
|
|
944
|
+
except Exception as e:
|
|
945
|
+
record_usage_event(
|
|
946
|
+
org_id=self.request_context.org_id,
|
|
947
|
+
event_name="aggregation_failed",
|
|
948
|
+
event_category="aggregation",
|
|
949
|
+
pipeline="playbook",
|
|
950
|
+
playbook_name=playbook_name,
|
|
951
|
+
agent_version=self.agent_version,
|
|
952
|
+
outcome="failed",
|
|
953
|
+
duration_ms=int((time.perf_counter() - aggregation_start) * 1000),
|
|
954
|
+
error_kind=type(e).__name__,
|
|
955
|
+
)
|
|
956
|
+
# Restore archived playbooks if any error occurs during aggregation
|
|
957
|
+
logger.error(
|
|
958
|
+
"Error during playbook aggregation for '%s': %s. Restoring archived playbooks.",
|
|
959
|
+
playbook_name,
|
|
960
|
+
str(e),
|
|
961
|
+
)
|
|
962
|
+
if full_archive:
|
|
963
|
+
for name in full_archive_playbook_names:
|
|
964
|
+
self.storage.restore_archived_agent_playbooks_by_playbook_name( # type: ignore[reportOptionalMemberAccess]
|
|
965
|
+
name, agent_version=self.agent_version
|
|
966
|
+
)
|
|
967
|
+
elif archived_playbook_ids:
|
|
968
|
+
self.storage.restore_archived_agent_playbooks_by_ids( # type: ignore[reportOptionalMemberAccess]
|
|
969
|
+
archived_playbook_ids
|
|
970
|
+
)
|
|
971
|
+
# Re-raise the exception after restoring
|
|
972
|
+
raise
|
|
973
|
+
|
|
974
|
+
def get_clusters(
|
|
975
|
+
self,
|
|
976
|
+
user_playbooks: list[UserPlaybook],
|
|
977
|
+
playbook_aggregator_config: PlaybookAggregatorConfig,
|
|
978
|
+
) -> dict[int, list[UserPlaybook]]:
|
|
979
|
+
"""
|
|
980
|
+
Cluster user playbooks based on their embeddings (trigger indexed).
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
user_playbooks: Contains user playbooks to cluster
|
|
984
|
+
playbook_aggregator_config: AgentPlaybook aggregator config
|
|
985
|
+
|
|
986
|
+
Returns:
|
|
987
|
+
dict[int, list[UserPlaybook]]: Dictionary mapping cluster IDs to lists of user playbooks
|
|
988
|
+
"""
|
|
989
|
+
if not playbook_aggregator_config:
|
|
990
|
+
logger.info(
|
|
991
|
+
"No playbook aggregator config found, skipping playbook aggregation"
|
|
992
|
+
)
|
|
993
|
+
return {}
|
|
994
|
+
|
|
995
|
+
min_cluster_size = playbook_aggregator_config.min_cluster_size
|
|
996
|
+
similarity_threshold = playbook_aggregator_config.clustering_similarity
|
|
997
|
+
|
|
998
|
+
if not user_playbooks:
|
|
999
|
+
logger.info("No user playbooks to cluster")
|
|
1000
|
+
return {}
|
|
1001
|
+
|
|
1002
|
+
# Mock mode: cluster by trigger
|
|
1003
|
+
if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
|
|
1004
|
+
logger.info("Mock mode: clustering by trigger")
|
|
1005
|
+
return self._cluster_by_trigger_mock(user_playbooks, min_cluster_size)
|
|
1006
|
+
|
|
1007
|
+
# Extract embeddings from user playbooks
|
|
1008
|
+
import numpy as np
|
|
1009
|
+
from sklearn.metrics.pairwise import cosine_distances
|
|
1010
|
+
|
|
1011
|
+
embeddings = np.array([playbook.embedding for playbook in user_playbooks])
|
|
1012
|
+
|
|
1013
|
+
if len(embeddings) < min_cluster_size:
|
|
1014
|
+
logger.info(
|
|
1015
|
+
"Not enough playbooks to cluster (got %d, need %d)",
|
|
1016
|
+
len(embeddings),
|
|
1017
|
+
min_cluster_size,
|
|
1018
|
+
)
|
|
1019
|
+
return {}
|
|
1020
|
+
|
|
1021
|
+
# Compute cosine distance matrix for better text embedding clustering
|
|
1022
|
+
distance_matrix = cosine_distances(embeddings)
|
|
1023
|
+
|
|
1024
|
+
# Choose algorithm based on dataset size
|
|
1025
|
+
# Convert similarity threshold to distance threshold (distance = 1 - similarity)
|
|
1026
|
+
distance_threshold = 1.0 - similarity_threshold
|
|
1027
|
+
if len(embeddings) < CLUSTERING_ALGORITHM_THRESHOLD:
|
|
1028
|
+
cluster_labels = self._cluster_with_agglomerative(
|
|
1029
|
+
distance_matrix, min_cluster_size, distance_threshold
|
|
1030
|
+
)
|
|
1031
|
+
else:
|
|
1032
|
+
cluster_labels = self._cluster_with_hdbscan(
|
|
1033
|
+
distance_matrix, min_cluster_size, distance_threshold
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
# Group playbooks by cluster
|
|
1037
|
+
clusters: dict[int, list[UserPlaybook]] = {}
|
|
1038
|
+
for idx, label in enumerate(cluster_labels):
|
|
1039
|
+
if label == -1: # Skip noise points from HDBSCAN
|
|
1040
|
+
continue
|
|
1041
|
+
if label not in clusters:
|
|
1042
|
+
clusters[label] = []
|
|
1043
|
+
clusters[label].append(user_playbooks[idx])
|
|
1044
|
+
|
|
1045
|
+
# Filter out clusters smaller than min_cluster_size
|
|
1046
|
+
clusters = {
|
|
1047
|
+
label: playbooks
|
|
1048
|
+
for label, playbooks in clusters.items()
|
|
1049
|
+
if len(playbooks) >= min_cluster_size
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
logger.info(
|
|
1053
|
+
"Found %d clusters from %d playbooks", len(clusters), len(user_playbooks)
|
|
1054
|
+
)
|
|
1055
|
+
for cluster_id, cluster_playbooks in clusters.items():
|
|
1056
|
+
logger.info("Cluster %d: %d playbooks", cluster_id, len(cluster_playbooks))
|
|
1057
|
+
|
|
1058
|
+
return clusters
|
|
1059
|
+
|
|
1060
|
+
def _cluster_by_trigger_mock(
|
|
1061
|
+
self, user_playbooks: list[UserPlaybook], min_cluster_size: int
|
|
1062
|
+
) -> dict[int, list[UserPlaybook]]:
|
|
1063
|
+
"""
|
|
1064
|
+
Simple mock clustering by exact trigger match.
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
user_playbooks: List of user playbooks with trigger field
|
|
1068
|
+
min_cluster_size: Minimum number of playbooks per cluster
|
|
1069
|
+
|
|
1070
|
+
Returns:
|
|
1071
|
+
dict[int, list[UserPlaybook]]: Clusters grouped by trigger
|
|
1072
|
+
"""
|
|
1073
|
+
# Group by trigger
|
|
1074
|
+
condition_groups: dict[str, list[UserPlaybook]] = {}
|
|
1075
|
+
for fb in user_playbooks:
|
|
1076
|
+
condition = fb.trigger or ""
|
|
1077
|
+
if condition not in condition_groups:
|
|
1078
|
+
condition_groups[condition] = []
|
|
1079
|
+
condition_groups[condition].append(fb)
|
|
1080
|
+
|
|
1081
|
+
# Convert to cluster format, filtering by min_cluster_size
|
|
1082
|
+
clusters: dict[int, list[UserPlaybook]] = {}
|
|
1083
|
+
cluster_id = 0
|
|
1084
|
+
for playbooks_group in condition_groups.values():
|
|
1085
|
+
if len(playbooks_group) >= min_cluster_size:
|
|
1086
|
+
clusters[cluster_id] = playbooks_group
|
|
1087
|
+
cluster_id += 1
|
|
1088
|
+
|
|
1089
|
+
logger.info(
|
|
1090
|
+
"Mock mode: created %d trigger clusters from %d playbooks",
|
|
1091
|
+
len(clusters),
|
|
1092
|
+
len(user_playbooks),
|
|
1093
|
+
)
|
|
1094
|
+
return clusters
|
|
1095
|
+
|
|
1096
|
+
def _cluster_with_agglomerative(
|
|
1097
|
+
self,
|
|
1098
|
+
distance_matrix: np.ndarray,
|
|
1099
|
+
min_cluster_size: int, # noqa: ARG002
|
|
1100
|
+
distance_threshold: float,
|
|
1101
|
+
) -> np.ndarray:
|
|
1102
|
+
"""
|
|
1103
|
+
Cluster using Agglomerative Clustering - best for small datasets.
|
|
1104
|
+
|
|
1105
|
+
Args:
|
|
1106
|
+
distance_matrix: Precomputed cosine distance matrix
|
|
1107
|
+
min_cluster_size: Minimum cluster size (used for logging only,
|
|
1108
|
+
filtering happens in get_clusters)
|
|
1109
|
+
distance_threshold: Maximum cosine distance to merge clusters (1 - similarity_threshold)
|
|
1110
|
+
|
|
1111
|
+
Returns:
|
|
1112
|
+
np.ndarray: Cluster labels for each point
|
|
1113
|
+
"""
|
|
1114
|
+
from sklearn.cluster import AgglomerativeClustering
|
|
1115
|
+
|
|
1116
|
+
logger.info(
|
|
1117
|
+
"Using Agglomerative Clustering for %d playbooks (< %d threshold), distance_threshold=%.2f",
|
|
1118
|
+
len(distance_matrix),
|
|
1119
|
+
CLUSTERING_ALGORITHM_THRESHOLD,
|
|
1120
|
+
distance_threshold,
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
clusterer = AgglomerativeClustering(
|
|
1124
|
+
n_clusters=None, # type: ignore[reportArgumentType]
|
|
1125
|
+
distance_threshold=distance_threshold,
|
|
1126
|
+
metric="precomputed",
|
|
1127
|
+
linkage="average",
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
return clusterer.fit_predict(distance_matrix)
|
|
1131
|
+
|
|
1132
|
+
def _cluster_with_hdbscan(
|
|
1133
|
+
self,
|
|
1134
|
+
distance_matrix: np.ndarray,
|
|
1135
|
+
min_cluster_size: int,
|
|
1136
|
+
distance_threshold: float,
|
|
1137
|
+
) -> np.ndarray:
|
|
1138
|
+
"""
|
|
1139
|
+
Cluster using HDBSCAN - best for large datasets with potential noise.
|
|
1140
|
+
|
|
1141
|
+
Args:
|
|
1142
|
+
distance_matrix: Precomputed cosine distance matrix
|
|
1143
|
+
min_cluster_size: Minimum number of points to form a cluster
|
|
1144
|
+
distance_threshold: Maximum cosine distance for cluster merging (1 - similarity_threshold)
|
|
1145
|
+
|
|
1146
|
+
Returns:
|
|
1147
|
+
np.ndarray: Cluster labels for each point (-1 indicates noise)
|
|
1148
|
+
"""
|
|
1149
|
+
import hdbscan
|
|
1150
|
+
|
|
1151
|
+
logger.info(
|
|
1152
|
+
"Using HDBSCAN for %d playbooks (>= %d threshold), distance_threshold=%.2f",
|
|
1153
|
+
len(distance_matrix),
|
|
1154
|
+
CLUSTERING_ALGORITHM_THRESHOLD,
|
|
1155
|
+
distance_threshold,
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
clusterer = hdbscan.HDBSCAN(
|
|
1159
|
+
min_cluster_size=min_cluster_size,
|
|
1160
|
+
min_samples=1,
|
|
1161
|
+
metric="precomputed",
|
|
1162
|
+
cluster_selection_epsilon=distance_threshold,
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
return clusterer.fit_predict(distance_matrix)
|
|
1166
|
+
|
|
1167
|
+
def _generate_playbooks_from_clusters(
|
|
1168
|
+
self,
|
|
1169
|
+
clusters: dict[int, list[UserPlaybook]],
|
|
1170
|
+
existing_approved_playbooks: list[AgentPlaybook],
|
|
1171
|
+
direction_overlap_threshold: float = 0.6,
|
|
1172
|
+
) -> list[AgentPlaybook]:
|
|
1173
|
+
"""
|
|
1174
|
+
Generate playbooks from clusters, considering existing approved playbooks.
|
|
1175
|
+
|
|
1176
|
+
Args:
|
|
1177
|
+
clusters: Dictionary mapping cluster IDs to lists of raw playbooks
|
|
1178
|
+
existing_approved_playbooks: List of existing approved playbooks to avoid duplication
|
|
1179
|
+
direction_overlap_threshold: Token overlap threshold for grouping by direction
|
|
1180
|
+
|
|
1181
|
+
Returns:
|
|
1182
|
+
list[AgentPlaybook]: List of newly generated playbooks (excludes duplicates)
|
|
1183
|
+
"""
|
|
1184
|
+
return [
|
|
1185
|
+
playbook
|
|
1186
|
+
for playbook, _ in self._generate_playbooks_with_source_clusters(
|
|
1187
|
+
clusters,
|
|
1188
|
+
existing_approved_playbooks,
|
|
1189
|
+
direction_overlap_threshold=direction_overlap_threshold,
|
|
1190
|
+
)
|
|
1191
|
+
]
|
|
1192
|
+
|
|
1193
|
+
def _generate_playbooks_with_source_clusters(
|
|
1194
|
+
self,
|
|
1195
|
+
clusters: dict[int, list[UserPlaybook]],
|
|
1196
|
+
existing_approved_playbooks: list[AgentPlaybook],
|
|
1197
|
+
direction_overlap_threshold: float = 0.6,
|
|
1198
|
+
) -> list[tuple[AgentPlaybook, list[UserPlaybook]]]:
|
|
1199
|
+
"""Generate agent playbooks while preserving their exact source cluster."""
|
|
1200
|
+
# Format existing approved playbooks for the prompt
|
|
1201
|
+
approved_playbooks_str = (
|
|
1202
|
+
"\n".join([f"- {fb.content}" for fb in existing_approved_playbooks])
|
|
1203
|
+
if existing_approved_playbooks
|
|
1204
|
+
else "None"
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
new_playbooks: list[tuple[AgentPlaybook, list[UserPlaybook]]] = []
|
|
1208
|
+
for cluster_playbooks in clusters.values():
|
|
1209
|
+
playbook = self._generate_playbook_from_cluster(
|
|
1210
|
+
cluster_playbooks,
|
|
1211
|
+
approved_playbooks_str,
|
|
1212
|
+
direction_overlap_threshold=direction_overlap_threshold,
|
|
1213
|
+
)
|
|
1214
|
+
if playbook is not None:
|
|
1215
|
+
new_playbooks.append((playbook, cluster_playbooks))
|
|
1216
|
+
return new_playbooks
|
|
1217
|
+
|
|
1218
|
+
def _enqueue_playbook_optimization(
|
|
1219
|
+
self, saved_playbooks: Sequence[AgentPlaybook | None]
|
|
1220
|
+
) -> None:
|
|
1221
|
+
config = self.configurator.get_config().playbook_optimizer_config
|
|
1222
|
+
if (
|
|
1223
|
+
getattr(config, "enabled", False) is not True
|
|
1224
|
+
or getattr(config, "optimize_agent_playbooks", False) is not True
|
|
1225
|
+
or not saved_playbooks
|
|
1226
|
+
):
|
|
1227
|
+
return
|
|
1228
|
+
from reflexio.server.services.playbook_optimizer import (
|
|
1229
|
+
PlaybookOptimizationScheduler,
|
|
1230
|
+
PlaybookOptimizationTarget,
|
|
1231
|
+
PlaybookOptimizer,
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
scheduler = PlaybookOptimizationScheduler.get_instance()
|
|
1235
|
+
for playbook in saved_playbooks:
|
|
1236
|
+
if (
|
|
1237
|
+
playbook is None
|
|
1238
|
+
or not playbook.agent_playbook_id
|
|
1239
|
+
or playbook.status is not None
|
|
1240
|
+
or playbook.playbook_status != PlaybookStatus.PENDING
|
|
1241
|
+
):
|
|
1242
|
+
continue
|
|
1243
|
+
target = PlaybookOptimizationTarget(
|
|
1244
|
+
kind="agent_playbook", target_id=playbook.agent_playbook_id
|
|
1245
|
+
)
|
|
1246
|
+
scheduler.enqueue(
|
|
1247
|
+
org_id=self.request_context.org_id,
|
|
1248
|
+
target=target,
|
|
1249
|
+
callback=lambda target=target: PlaybookOptimizer(
|
|
1250
|
+
self.request_context, self.client
|
|
1251
|
+
).optimize(target),
|
|
1252
|
+
jitter_seconds=config.scheduler_jitter_seconds,
|
|
1253
|
+
abort_cooldown_threshold=config.abort_cooldown_threshold,
|
|
1254
|
+
cooldown_after_aborts_seconds=config.cooldown_after_aborts_seconds,
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
def _generate_playbook_from_cluster(
|
|
1258
|
+
self,
|
|
1259
|
+
cluster_playbooks: list[UserPlaybook],
|
|
1260
|
+
existing_approved_playbooks_str: str,
|
|
1261
|
+
direction_overlap_threshold: float = 0.6,
|
|
1262
|
+
) -> AgentPlaybook | None:
|
|
1263
|
+
"""
|
|
1264
|
+
Generate a playbook from a cluster using structured JSON output.
|
|
1265
|
+
|
|
1266
|
+
Args:
|
|
1267
|
+
cluster_playbooks: List of raw playbooks in this cluster
|
|
1268
|
+
existing_approved_playbooks_str: Formatted string of existing approved playbooks
|
|
1269
|
+
direction_overlap_threshold: Token overlap threshold for grouping by direction
|
|
1270
|
+
|
|
1271
|
+
Returns:
|
|
1272
|
+
AgentPlaybook | None: Generated playbook, or None if no new playbook needed
|
|
1273
|
+
"""
|
|
1274
|
+
if not cluster_playbooks:
|
|
1275
|
+
return None
|
|
1276
|
+
|
|
1277
|
+
if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
|
|
1278
|
+
# Extract structured fields directly from cluster
|
|
1279
|
+
triggers = [fb.trigger for fb in cluster_playbooks if fb.trigger]
|
|
1280
|
+
|
|
1281
|
+
trigger = triggers[0] if triggers else "in general"
|
|
1282
|
+
|
|
1283
|
+
# Fall back to using content from first playbook if available
|
|
1284
|
+
first_content = cluster_playbooks[0].content
|
|
1285
|
+
if not first_content:
|
|
1286
|
+
logger.info("No valid content in cluster, skipping")
|
|
1287
|
+
return None
|
|
1288
|
+
|
|
1289
|
+
# Build content directly as a freeform summary
|
|
1290
|
+
content_text = f"When {trigger}, {first_content}."
|
|
1291
|
+
|
|
1292
|
+
return AgentPlaybook(
|
|
1293
|
+
playbook_name=cluster_playbooks[0].playbook_name,
|
|
1294
|
+
agent_version=cluster_playbooks[0].agent_version,
|
|
1295
|
+
content=content_text,
|
|
1296
|
+
trigger=trigger,
|
|
1297
|
+
playbook_status=PlaybookStatus.PENDING,
|
|
1298
|
+
playbook_metadata="mock_generated",
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
# Format raw playbooks for prompt using structured format
|
|
1302
|
+
raw_playbooks_str = self._format_structured_cluster_input(
|
|
1303
|
+
cluster_playbooks,
|
|
1304
|
+
direction_overlap_threshold=direction_overlap_threshold,
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
messages = [
|
|
1308
|
+
{
|
|
1309
|
+
"role": "user",
|
|
1310
|
+
"content": self.request_context.prompt_manager.render_prompt(
|
|
1311
|
+
PlaybookServiceConstants.PLAYBOOK_AGGREGATION_PROMPT_ID,
|
|
1312
|
+
{
|
|
1313
|
+
"user_playbooks": raw_playbooks_str,
|
|
1314
|
+
"existing_approved_playbooks": existing_approved_playbooks_str,
|
|
1315
|
+
},
|
|
1316
|
+
),
|
|
1317
|
+
}
|
|
1318
|
+
]
|
|
1319
|
+
|
|
1320
|
+
try:
|
|
1321
|
+
response = self.client.generate_chat_response(
|
|
1322
|
+
messages=messages,
|
|
1323
|
+
model=self.client.config.model,
|
|
1324
|
+
response_format=PlaybookAggregationOutput,
|
|
1325
|
+
parse_structured_output=True,
|
|
1326
|
+
)
|
|
1327
|
+
log_model_response(logger, "Aggregation structured response", response)
|
|
1328
|
+
|
|
1329
|
+
if not isinstance(response, PlaybookAggregationOutput):
|
|
1330
|
+
logger.warning(
|
|
1331
|
+
"LLM response was not parsed as PlaybookAggregationOutput (got %s), returning None.",
|
|
1332
|
+
type(response).__name__,
|
|
1333
|
+
)
|
|
1334
|
+
return None
|
|
1335
|
+
|
|
1336
|
+
return self._process_aggregation_response(response, cluster_playbooks)
|
|
1337
|
+
except Exception as exc:
|
|
1338
|
+
logger.error(
|
|
1339
|
+
"AgentPlaybook aggregation failed due to %s, returning None.",
|
|
1340
|
+
str(exc),
|
|
1341
|
+
)
|
|
1342
|
+
return None
|
|
1343
|
+
|
|
1344
|
+
def _process_aggregation_response(
|
|
1345
|
+
self, response: PlaybookAggregationOutput, cluster_playbooks: list[UserPlaybook]
|
|
1346
|
+
) -> AgentPlaybook | None:
|
|
1347
|
+
"""
|
|
1348
|
+
Process structured response from LLM into AgentPlaybook.
|
|
1349
|
+
|
|
1350
|
+
Args:
|
|
1351
|
+
response: Parsed PlaybookAggregationOutput from LLM
|
|
1352
|
+
cluster_playbooks: Original cluster playbooks for metadata
|
|
1353
|
+
|
|
1354
|
+
Returns:
|
|
1355
|
+
AgentPlaybook or None if no playbook should be generated
|
|
1356
|
+
"""
|
|
1357
|
+
if not response:
|
|
1358
|
+
return None
|
|
1359
|
+
|
|
1360
|
+
structured = response.playbook
|
|
1361
|
+
if structured is None:
|
|
1362
|
+
logger.info("LLM returned null playbook (duplicate of existing)")
|
|
1363
|
+
return None
|
|
1364
|
+
|
|
1365
|
+
# content is always the LLM's freeform summary;
|
|
1366
|
+
# fall back to formatted structured fields for backward compatibility
|
|
1367
|
+
playbook_content = ensure_playbook_content(structured.content, structured)
|
|
1368
|
+
logger.info(
|
|
1369
|
+
"Aggregated playbook content (freeform): %.200s",
|
|
1370
|
+
playbook_content,
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
return AgentPlaybook(
|
|
1374
|
+
playbook_name=cluster_playbooks[0].playbook_name,
|
|
1375
|
+
agent_version=cluster_playbooks[0].agent_version,
|
|
1376
|
+
content=playbook_content,
|
|
1377
|
+
trigger=structured.trigger,
|
|
1378
|
+
rationale=structured.rationale,
|
|
1379
|
+
playbook_status=PlaybookStatus.PENDING,
|
|
1380
|
+
playbook_metadata="",
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
def _get_playbook_aggregator_config(self) -> PlaybookAggregatorConfig | None:
|
|
1384
|
+
root_config = self.configurator.get_config()
|
|
1385
|
+
playbook_config = getattr(root_config, "user_playbook_extractor_config", None)
|
|
1386
|
+
if not playbook_config:
|
|
1387
|
+
return None
|
|
1388
|
+
return playbook_config.aggregation_config
|