marvisx-cli 0.1.0__py3-none-any.whl
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.
- core/api/__init__.py +0 -0
- core/api/agents/__init__.py +0 -0
- core/api/agents/session_health.py +59 -0
- core/api/agents/session_manager.py +206 -0
- core/api/bin/marvisx-state-hook.py +182 -0
- core/api/config.py +533 -0
- core/api/db.py +1516 -0
- core/api/dependencies/__init__.py +0 -0
- core/api/dependencies/tenant.py +34 -0
- core/api/main.py +1641 -0
- core/api/mcp/__init__.py +8 -0
- core/api/mcp/_adapter.py +184 -0
- core/api/mcp/server.py +58 -0
- core/api/mcp/tools/__init__.py +59 -0
- core/api/mcp/tools/brain.py +599 -0
- core/api/mcp/tools/graph.py +380 -0
- core/api/mcp/tools/handoffs.py +112 -0
- core/api/mcp/tools/ingest.py +326 -0
- core/api/mcp/tools/learnings.py +144 -0
- core/api/mcp/tools/projects.py +99 -0
- core/api/mcp/tools/pull_requests.py +173 -0
- core/api/mcp/tools/safety.py +111 -0
- core/api/mcp/tools/search.py +79 -0
- core/api/mcp/tools/tasks.py +258 -0
- core/api/middleware/__init__.py +0 -0
- core/api/middleware/tool_call_audit.py +111 -0
- core/api/models/__init__.py +346 -0
- core/api/models/auth.py +48 -0
- core/api/models/brain.py +1006 -0
- core/api/models/common.py +76 -0
- core/api/models/costs.py +91 -0
- core/api/models/graph.py +66 -0
- core/api/models/graph_cosmo.py +125 -0
- core/api/models/graph_pr_impact.py +257 -0
- core/api/models/graph_ux.py +141 -0
- core/api/models/inbox.py +230 -0
- core/api/models/ingest_keys.py +108 -0
- core/api/models/kg.py +41 -0
- core/api/models/llm_config.py +56 -0
- core/api/models/monitoring.py +234 -0
- core/api/models/projects.py +161 -0
- core/api/models/search.py +42 -0
- core/api/models/sessions.py +322 -0
- core/api/models/tasks.py +184 -0
- core/api/models/teams.py +63 -0
- core/api/models/users.py +184 -0
- core/api/observability/__init__.py +0 -0
- core/api/observability/tracing.py +92 -0
- core/api/paths.py +26 -0
- core/api/rate_limit.py +24 -0
- core/api/rbac.py +112 -0
- core/api/routers/__init__.py +0 -0
- core/api/routers/_adapter.py +24 -0
- core/api/routers/admin_pr_impact.py +230 -0
- core/api/routers/admin_settings.py +147 -0
- core/api/routers/agent.py +1079 -0
- core/api/routers/agent_tokens.py +276 -0
- core/api/routers/app_settings.py +112 -0
- core/api/routers/audit.py +89 -0
- core/api/routers/auth.py +586 -0
- core/api/routers/bench.py +161 -0
- core/api/routers/brain.py +881 -0
- core/api/routers/brain_directions.py +527 -0
- core/api/routers/ci_checks.py +140 -0
- core/api/routers/comments.py +273 -0
- core/api/routers/costs.py +148 -0
- core/api/routers/docs_coverage.py +217 -0
- core/api/routers/docs_governance.py +63 -0
- core/api/routers/documents.py +318 -0
- core/api/routers/files.py +163 -0
- core/api/routers/finder.py +987 -0
- core/api/routers/graph.py +836 -0
- core/api/routers/handoffs.py +156 -0
- core/api/routers/inbox.py +496 -0
- core/api/routers/ingest_api_keys.py +205 -0
- core/api/routers/ingest_triage.py +1227 -0
- core/api/routers/judge.py +306 -0
- core/api/routers/kg.py +336 -0
- core/api/routers/learnings.py +253 -0
- core/api/routers/llm_config.py +130 -0
- core/api/routers/monitoring.py +347 -0
- core/api/routers/notifications.py +125 -0
- core/api/routers/pr_impact.py +315 -0
- core/api/routers/projects.py +1061 -0
- core/api/routers/pull_requests.py +312 -0
- core/api/routers/push.py +67 -0
- core/api/routers/raci.py +228 -0
- core/api/routers/search.py +125 -0
- core/api/routers/sessions.py +3100 -0
- core/api/routers/settings.py +90 -0
- core/api/routers/share_repo.py +68 -0
- core/api/routers/status_updates.py +96 -0
- core/api/routers/tags.py +45 -0
- core/api/routers/tasks.py +526 -0
- core/api/routers/teams.py +425 -0
- core/api/routers/terminal.py +105 -0
- core/api/routers/users.py +331 -0
- core/api/routers/webhooks.py +330 -0
- core/api/runtime_settings.py +84 -0
- core/api/security.py +652 -0
- core/api/services/__init__.py +0 -0
- core/api/services/audit.py +58 -0
- core/api/services/auto_approval.py +82 -0
- core/api/services/brain/__init__.py +52 -0
- core/api/services/brain/baseline.py +230 -0
- core/api/services/brain/capabilities.py +75 -0
- core/api/services/brain/cascade_rollup.py +388 -0
- core/api/services/brain/compound_bridge.py +215 -0
- core/api/services/brain/cycle.py +1242 -0
- core/api/services/brain/cycle_snapshot.py +371 -0
- core/api/services/brain/digest_collector.py +147 -0
- core/api/services/brain/direction.py +421 -0
- core/api/services/brain/drift.py +356 -0
- core/api/services/brain/drift_router.py +409 -0
- core/api/services/brain/edge_metrics.py +79 -0
- core/api/services/brain/events_reader.py +222 -0
- core/api/services/brain/findings.py +1379 -0
- core/api/services/brain/findings_reader.py +1006 -0
- core/api/services/brain/jobs.py +733 -0
- core/api/services/brain/journal.py +206 -0
- core/api/services/brain/knowledge_forms.py +92 -0
- core/api/services/brain/llm/__init__.py +37 -0
- core/api/services/brain/llm/_runner.py +62 -0
- core/api/services/brain/llm/base.py +70 -0
- core/api/services/brain/llm/cache.py +99 -0
- core/api/services/brain/llm/constants.py +46 -0
- core/api/services/brain/llm/direction_alignment.py +289 -0
- core/api/services/brain/llm/factory.py +132 -0
- core/api/services/brain/llm/finding_reasoning.py +98 -0
- core/api/services/brain/llm/finding_summary.py +92 -0
- core/api/services/brain/llm/grounding.py +46 -0
- core/api/services/brain/llm/journal_polish.py +96 -0
- core/api/services/brain/llm/local_gateway.py +426 -0
- core/api/services/brain/llm/parsers.py +71 -0
- core/api/services/brain/llm/router_glue.py +422 -0
- core/api/services/brain/memory_ops.py +1677 -0
- core/api/services/brain/models.py +140 -0
- core/api/services/brain/owner_hint.py +211 -0
- core/api/services/brain/recap.py +307 -0
- core/api/services/brain/rules/__init__.py +65 -0
- core/api/services/brain/rules/_signals.py +205 -0
- core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
- core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
- core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
- core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
- core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
- core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
- core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
- core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
- core/api/services/brain/runs_reader.py +485 -0
- core/api/services/brain/scope.py +79 -0
- core/api/services/brain/sources/__init__.py +46 -0
- core/api/services/brain/sources/base.py +86 -0
- core/api/services/brain/sources/git_kg.py +393 -0
- core/api/services/brain/sources/handoffs.py +157 -0
- core/api/services/brain/sources/ingestor.py +130 -0
- core/api/services/brain/sources/learnings.py +121 -0
- core/api/services/brain/sources/pir_tasks.py +245 -0
- core/api/services/brain/watermarks.py +147 -0
- core/api/services/brain/ws_emitter.py +170 -0
- core/api/services/cc_tasks_reader.py +76 -0
- core/api/services/ci_service.py +263 -0
- core/api/services/claude_metrics.py +796 -0
- core/api/services/codex_metrics.py +364 -0
- core/api/services/conversation_reader.py +102 -0
- core/api/services/cost_service.py +243 -0
- core/api/services/crypto.py +147 -0
- core/api/services/docs_governance/__init__.py +1 -0
- core/api/services/docs_governance/confidence.py +230 -0
- core/api/services/docs_governance/config.py +87 -0
- core/api/services/docs_governance/enrichment.py +65 -0
- core/api/services/docs_governance/frontmatter_validator.py +83 -0
- core/api/services/docs_governance/hard_gates.py +221 -0
- core/api/services/docs_governance/triage_orchestrator.py +98 -0
- core/api/services/embedding_internal.py +395 -0
- core/api/services/embedding_service.py +832 -0
- core/api/services/event_dispatcher.py +167 -0
- core/api/services/events.py +70 -0
- core/api/services/git_ops.py +621 -0
- core/api/services/graph_cosmo_service.py +440 -0
- core/api/services/graph_ranker.py +306 -0
- core/api/services/graph_service.py +1589 -0
- core/api/services/inbox.py +800 -0
- core/api/services/inbox_digest.py +221 -0
- core/api/services/inbox_digest_deep_research.py +80 -0
- core/api/services/inbox_digest_jobs.py +595 -0
- core/api/services/inbox_gmail_sync.py +167 -0
- core/api/services/inbox_llm_classifier.py +906 -0
- core/api/services/inbox_source_identity.py +116 -0
- core/api/services/inbox_sources.py +456 -0
- core/api/services/inbox_taxonomy.py +195 -0
- core/api/services/inbox_tldr.py +1079 -0
- core/api/services/inbox_triage.py +899 -0
- core/api/services/ingest/__init__.py +13 -0
- core/api/services/ingest/api_key_auth.py +136 -0
- core/api/services/ingest/auto_approve.py +120 -0
- core/api/services/ingest/classifier.py +138 -0
- core/api/services/ingest/confidence.py +173 -0
- core/api/services/ingest/dispatch.py +88 -0
- core/api/services/ingest/embedding_router.py +272 -0
- core/api/services/ingest/events.py +33 -0
- core/api/services/ingest/ignore_patterns.py +79 -0
- core/api/services/ingest/image_probe.py +218 -0
- core/api/services/ingest/ingress.py +263 -0
- core/api/services/ingest/insert_saga.py +793 -0
- core/api/services/ingest/llm/__init__.py +13 -0
- core/api/services/ingest/llm/anthropic_haiku.py +23 -0
- core/api/services/ingest/llm/base.py +59 -0
- core/api/services/ingest/llm/byok_provider.py +130 -0
- core/api/services/ingest/llm/classification_context.py +301 -0
- core/api/services/ingest/llm/config_store.py +246 -0
- core/api/services/ingest/llm/factory.py +24 -0
- core/api/services/ingest/llm/kg_enricher.py +306 -0
- core/api/services/ingest/llm/local_gateway.py +821 -0
- core/api/services/ingest/llm/local_vllm.py +23 -0
- core/api/services/ingest/llm/openai_nano.py +349 -0
- core/api/services/ingest/lock_advisory.py +57 -0
- core/api/services/ingest/parser_router.py +1756 -0
- core/api/services/ingest/parsers/__init__.py +1 -0
- core/api/services/ingest/parsers/docling_parser.py +142 -0
- core/api/services/ingest/parsers/docparse_gateway.py +178 -0
- core/api/services/ingest/parsers/docx_parser.py +127 -0
- core/api/services/ingest/parsers/folder_unpacker.py +85 -0
- core/api/services/ingest/parsers/gateway_aux.py +147 -0
- core/api/services/ingest/parsers/image_parser.py +251 -0
- core/api/services/ingest/parsers/internal_markdown.py +89 -0
- core/api/services/ingest/parsers/ocr_gateway.py +117 -0
- core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
- core/api/services/ingest/parsers/pdf_types.py +13 -0
- core/api/services/ingest/parsers/transcript_parser.py +445 -0
- core/api/services/ingest/parsers/vision_gateway.py +186 -0
- core/api/services/ingest/parsers/xlsx_parser.py +91 -0
- core/api/services/ingest/parsers/zip_unpacker.py +126 -0
- core/api/services/ingest/preflight.py +393 -0
- core/api/services/ingest/retry_voyage.py +88 -0
- core/api/services/ingest/routing_policy.py +307 -0
- core/api/services/ingest/serializers/__init__.py +1 -0
- core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
- core/api/services/ingest/skip_log.py +74 -0
- core/api/services/ingest/watcher.py +637 -0
- core/api/services/kg/__init__.py +0 -0
- core/api/services/kg/audit.py +49 -0
- core/api/services/kg/hybrid_search.py +691 -0
- core/api/services/kg/lens.py +339 -0
- core/api/services/kg/pr_impact.py +770 -0
- core/api/services/kg/queries.py +152 -0
- core/api/services/kg/ranking.py +89 -0
- core/api/services/kg/rrf.py +143 -0
- core/api/services/kg_watcher_control.py +161 -0
- core/api/services/local_llm/__init__.py +19 -0
- core/api/services/local_llm/async_client.py +385 -0
- core/api/services/local_llm/client.py +173 -0
- core/api/services/local_llm/url_validator.py +44 -0
- core/api/services/metrics_collector.py +646 -0
- core/api/services/metrics_providers.py +65 -0
- core/api/services/model_registry.py +266 -0
- core/api/services/model_router.py +137 -0
- core/api/services/n8n_client.py +77 -0
- core/api/services/newsletter_llm_gateway.py +66 -0
- core/api/services/notification_service.py +134 -0
- core/api/services/openai_responses.py +55 -0
- core/api/services/opencode_metrics.py +375 -0
- core/api/services/opencode_sessions.py +173 -0
- core/api/services/pii_redactor.py +138 -0
- core/api/services/pr_impact_pipeline/__init__.py +21 -0
- core/api/services/pr_impact_pipeline/differ.py +421 -0
- core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
- core/api/services/pr_impact_pipeline/gc.py +93 -0
- core/api/services/pr_impact_pipeline/languages.py +192 -0
- core/api/services/pr_impact_pipeline/parser.py +178 -0
- core/api/services/pr_impact_pipeline/writer.py +394 -0
- core/api/services/pr_service.py +1393 -0
- core/api/services/project_paths.py +70 -0
- core/api/services/project_status_updates.py +265 -0
- core/api/services/providers.py +276 -0
- core/api/services/push_service.py +170 -0
- core/api/services/reminder_service.py +89 -0
- core/api/services/runas.py +41 -0
- core/api/services/salience_service.py +69 -0
- core/api/services/security_collector.py +281 -0
- core/api/services/session_catalog.py +385 -0
- core/api/services/session_metrics_service.py +301 -0
- core/api/services/session_ops.py +272 -0
- core/api/services/session_state.py +173 -0
- core/api/services/share_links.py +222 -0
- core/api/services/task_transitions.py +146 -0
- core/api/services/terminal_metrics.py +462 -0
- core/api/services/terminal_metrics_dump.py +203 -0
- core/api/services/tmux.py +1205 -0
- core/api/services/webhook_service.py +422 -0
- core/api/services/workspace_sync.py +164 -0
- core/api/templates/__init__.py +1 -0
- core/api/templates/markdown_share.py +164 -0
- core/api/terminal.py +1031 -0
- core/api/tests/__init__.py +0 -0
- core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
- core/api/tests/test_audit_permissions.py +133 -0
- core/api/tests/test_backfill_session_conversations.py +90 -0
- core/api/tests/test_backfill_working_seconds_msg.py +129 -0
- core/api/tests/test_claude_metrics.py +326 -0
- core/api/tests/test_codex_metrics.py +189 -0
- core/api/tests/test_finder_paths.py +74 -0
- core/api/tests/test_git_ops_merge.py +155 -0
- core/api/tests/test_learnings_check_search.py +81 -0
- core/api/tests/test_metrics_providers.py +133 -0
- core/api/tests/test_migration_087.py +164 -0
- core/api/tests/test_migration_088.py +94 -0
- core/api/tests/test_migration_089.py +116 -0
- core/api/tests/test_openai_responses.py +24 -0
- core/api/tests/test_opencode_metrics.py +740 -0
- core/api/tests/test_opencode_sessions.py +321 -0
- core/api/tests/test_pr_workflow_e2e.py +457 -0
- core/api/tests/test_projects_handoffs.py +31 -0
- core/api/tests/test_providers.py +138 -0
- core/api/tests/test_safety_bridge.py +347 -0
- core/api/tests/test_session_catalog.py +142 -0
- core/api/tests/test_session_conversations.py +512 -0
- core/api/tests/test_session_metrics_service.py +270 -0
- core/api/tests/test_session_resume_paths.py +548 -0
- core/api/tests/test_session_theme_mode_migration.py +56 -0
- core/api/tests/test_sessions_rbac.py +131 -0
- core/api/tests/test_share_edit.py +398 -0
- core/api/tests/test_share_repo.py +200 -0
- core/api/tests/test_terminal_session_manager.py +98 -0
- core/api/tests/test_terminal_upload.py +34 -0
- core/api/tests/test_tmux.py +272 -0
- core/api/tests/test_workspace_sync.py +186 -0
- core/api/tests/test_ws_ticket_in_memory.py +73 -0
- core/api/use_cases/__init__.py +11 -0
- core/api/use_cases/_context.py +89 -0
- core/api/use_cases/_errors.py +62 -0
- core/api/use_cases/_roles.py +16 -0
- core/api/use_cases/audit.py +171 -0
- core/api/use_cases/brain.py +1232 -0
- core/api/use_cases/costs.py +249 -0
- core/api/use_cases/graph.py +1153 -0
- core/api/use_cases/handoffs.py +506 -0
- core/api/use_cases/ingest_triage.py +1229 -0
- core/api/use_cases/learnings.py +538 -0
- core/api/use_cases/projects.py +705 -0
- core/api/use_cases/pull_requests.py +415 -0
- core/api/use_cases/search.py +926 -0
- core/api/use_cases/tasks.py +1495 -0
- core/api/visibility.py +141 -0
- core/cli/__init__.py +5 -0
- core/cli/_index_source.py +632 -0
- core/cli/_runtime_ctx.py +160 -0
- core/cli/_transmute.py +241 -0
- core/cli/marvis_doctor.py +704 -0
- core/cli/marvis_feedback.py +396 -0
- core/cli/marvis_governance.py +315 -0
- core/cli/marvis_hooks.py +515 -0
- core/cli/marvis_init.py +757 -0
- core/cli/marvis_mcp.py +401 -0
- core/cli/marvis_runtime.py +855 -0
- core/cli/marvis_telemetry.py +228 -0
- core/scripts/_drift_check.py +716 -0
- core/scripts/_frontmatter.py +66 -0
- core/scripts/_graph_writer.py +189 -0
- core/scripts/ast_parser.py +1553 -0
- core/scripts/install_hooks/__init__.py +1 -0
- core/scripts/install_hooks/_config.sh +109 -0
- core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
- core/scripts/install_hooks/block-db-direct-write.sh +23 -0
- core/scripts/install_hooks/block-push-no-task.sh +23 -0
- core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
- core/scripts/install_hooks/block-subtree-push.sh +23 -0
- core/scripts/install_hooks/config.json +53 -0
- core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
- core/scripts/install_hooks/enforce-worktree.sh +23 -0
- core/scripts/install_hooks/quality-gate.sh +170 -0
- core/scripts/install_hooks/safety_bridge.py +968 -0
- core/scripts/install_hooks/secret-scan.sh +23 -0
- core/scripts/migrate_spike_node_ids.py +122 -0
- core/scripts/populate_artifacts.py +2198 -0
- core/scripts/populate_cross_project.py +2457 -0
- core/scripts/populate_inbox_nodes.py +357 -0
- core/scripts/populate_pr_impact.py +267 -0
- core/scripts/populate_project_nodes.py +603 -0
- core/scripts/populate_touch_counter.py +337 -0
- core/scripts/reparse_failed.py +57 -0
- core/scripts/safety_bridge.py +968 -0
- core/telemetry/__init__.py +9 -0
- core/telemetry/client.py +405 -0
- core/telemetry/schema.py +122 -0
- core/wizard/__init__.py +65 -0
- core/wizard/byok_vault.py +147 -0
- core/wizard/defaults.py +58 -0
- core/wizard/state.py +117 -0
- core/wizard/steps.py +70 -0
- core/wizard/validation.py +136 -0
- marvisx_cli-0.1.0.dist-info/METADATA +201 -0
- marvisx_cli-0.1.0.dist-info/RECORD +587 -0
- marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
- marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
- marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
- migrations/001_initial.sql +33 -0
- migrations/002_tasks.sql +30 -0
- migrations/003_session_management.sql +7 -0
- migrations/004_projects_comments.sql +65 -0
- migrations/005_session_intelligence.sql +15 -0
- migrations/006_settings.sql +12 -0
- migrations/007_task_scoring.sql +12 -0
- migrations/008_cost_tracking.sql +31 -0
- migrations/009_session_card_metrics.sql +3 -0
- migrations/010_monitoring.sql +55 -0
- migrations/012_agent_api.sql +21 -0
- migrations/013_session_complete.sql +8 -0
- migrations/015_pull_requests.sql +43 -0
- migrations/015_pull_requests_down.sql +5 -0
- migrations/016_users_raci.sql +116 -0
- migrations/017_task_cost_entries.sql +87 -0
- migrations/018_agents.sql +73 -0
- migrations/018_agents_down.sql +13 -0
- migrations/019_review_feedback.sql +18 -0
- migrations/020_pr_commit_sha.sql +4 -0
- migrations/021_webhook_events.sql +18 -0
- migrations/022_devx_agent_managed.sql +11 -0
- migrations/022_devx_agent_managed_down.sql +6 -0
- migrations/023_devx_p1_gate.sql +7 -0
- migrations/023_devx_p1_gate_down.sql +3 -0
- migrations/024_chat_messages.sql +16 -0
- migrations/024_pr_conversation_id.sql +8 -0
- migrations/024_task_indexes.sql +21 -0
- migrations/024_task_indexes_down.sql +7 -0
- migrations/025_audit_log.sql +17 -0
- migrations/026_agent_tokens.sql +20 -0
- migrations/027_teams_auth_phase_b.sql +35 -0
- migrations/028_learnings.sql +23 -0
- migrations/029_team_roles.sql +14 -0
- migrations/030_finder_pins.sql +10 -0
- migrations/031_pr_deploy_status.sql +9 -0
- migrations/032_task_reminders.sql +7 -0
- migrations/033_events_retry_count.sql +6 -0
- migrations/033_session_owner.sql +9 -0
- migrations/034_notifications.sql +38 -0
- migrations/035_shared_links.sql +15 -0
- migrations/036_session_index_upgrade.sql +29 -0
- migrations/037_pr_approval.sql +15 -0
- migrations/038_pr_submitted_by.sql +6 -0
- migrations/039_push_subscriptions.sql +17 -0
- migrations/040_semantic_search.sql +16 -0
- migrations/041_workspaces.sql +63 -0
- migrations/042_oidc_providers.sql +24 -0
- migrations/043_ci_checks.sql +31 -0
- migrations/044_agent_metrics.sql +30 -0
- migrations/045_documents_doc_type.sql +5 -0
- migrations/046_salience.sql +13 -0
- migrations/047_seed_missing_agents.sql +6 -0
- migrations/048_fix_agent_paths_roles.sql +5 -0
- migrations/049_agent_role_and_learnings_schema.sql +3 -0
- migrations/050_session_provider.sql +2 -0
- migrations/051_session_launch_profile.sql +4 -0
- migrations/052_session_theme_mode.sql +2 -0
- migrations/052_task_kind.sql +4 -0
- migrations/053_inbox_items.sql +31 -0
- migrations/054_inbox_triage_contract.sql +30 -0
- migrations/055_inbox_topic_treatment.sql +12 -0
- migrations/056_inbox_treatment_read_save.sql +57 -0
- migrations/057_session_theme_mode_backfill.sql +4 -0
- migrations/058_inbox_item_status_lifecycle.sql +13 -0
- migrations/059_inbox_tldr_and_source_scores.sql +18 -0
- migrations/060_newsletter.sql +16 -0
- migrations/061_inbox_redesign.sql +69 -0
- migrations/062_fix_inbox_sources_backfill.sql +37 -0
- migrations/063_task_completion_mode.sql +23 -0
- migrations/064_judge_mode_setting.sql +4 -0
- migrations/065_knowledge_graph_spike.sql +40 -0
- migrations/066_digest_ranking_inputs.sql +10 -0
- migrations/066_kg_artifact_nodes.sql +129 -0
- migrations/067_inbox_digest_selections.sql +28 -0
- migrations/067_kg_temporal.sql +53 -0
- migrations/068_inbox_digest_app_settings.sql +9 -0
- migrations/068_kg_touch_counter.sql +52 -0
- migrations/069_kg_doc_types.sql +117 -0
- migrations/070_digest_ranking_inputs_recovery.sql +3 -0
- migrations/071_inbox_digest_selections_recovery.sql +3 -0
- migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
- migrations/073_kg_cross_project.sql +216 -0
- migrations/073_kg_cross_project_down.sql +77 -0
- migrations/074_kg_infra_types.sql +208 -0
- migrations/074_kg_infra_types_down.sql +80 -0
- migrations/075_kg_file_state_recovery.sql +35 -0
- migrations/075_kg_file_state_recovery_down.sql +5 -0
- migrations/076_kg_watcher_state.sql +33 -0
- migrations/076_kg_watcher_state_down.sql +3 -0
- migrations/077_kg_doc_types_extend.sql +226 -0
- migrations/077_kg_doc_types_extend_down.sql +80 -0
- migrations/078_kg_fts5.sql +102 -0
- migrations/078_kg_fts5_down.sql +14 -0
- migrations/079_kg_missing_indexes.sql +31 -0
- migrations/079_kg_missing_indexes_down.sql +10 -0
- migrations/080_kg_fts5_extended.sql +232 -0
- migrations/080_kg_fts5_extended_down.sql +25 -0
- migrations/081_kg_lens_indexes.sql +9 -0
- migrations/081_kg_lens_indexes_down.sql +3 -0
- migrations/082_kg_pins.sql +26 -0
- migrations/082_kg_pins_down.sql +14 -0
- migrations/083_kg_graph_nodes_degree.sql +20 -0
- migrations/083_kg_graph_nodes_degree_down.sql +15 -0
- migrations/084_drop_legacy_scheduler_tables.sql +58 -0
- migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
- migrations/085_kg_edge_resolves_to.sql +142 -0
- migrations/085_kg_edge_resolves_to_down.sql +66 -0
- migrations/086_project_status_updates_feed.sql +20 -0
- migrations/086_project_status_updates_feed_down.sql +36 -0
- migrations/087_session_metrics_dual.sql +50 -0
- migrations/087_session_metrics_dual_down.sql +21 -0
- migrations/088_rename_context_pct_legacy.sql +23 -0
- migrations/088_rename_context_pct_legacy_down.sql +8 -0
- migrations/089_session_metrics_equivalent_cost.sql +26 -0
- migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
- migrations/090_kg_inbox_node_type.sql +26 -0
- migrations/090_kg_inbox_node_type_down.sql +20 -0
- migrations/091_kg_inbox_node_type_check.sql +265 -0
- migrations/091_kg_inbox_node_type_check_down.sql +129 -0
- migrations/092_sessions_activity_state_ts.sql +29 -0
- migrations/092_sessions_activity_state_ts_down.sql +14 -0
- migrations/093_sessions_activity_state_column.sql +29 -0
- migrations/093_sessions_activity_state_column_down.sql +10 -0
- migrations/094_ingest_pending.sql +55 -0
- migrations/094_ingest_pending_down.sql +15 -0
- migrations/095_kg_intent_first.sql +77 -0
- migrations/095_kg_intent_first_down.sql +25 -0
- migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
- migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
- migrations/097_ingest_change_history.sql +37 -0
- migrations/097_ingest_change_history_down.sql +13 -0
- migrations/098_kg_node_type_business.sql +254 -0
- migrations/098_kg_node_type_business_down.sql +195 -0
- migrations/099_kg_edges_restore_weight.sql +58 -0
- migrations/099_kg_edges_restore_weight_down.sql +12 -0
- migrations/100_kg_enriched_at.sql +25 -0
- migrations/100_kg_enriched_at_down.sql +12 -0
- migrations/101_local_llm_shadow_comparisons.sql +66 -0
- migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
- migrations/102_promote_llm_costs.sql +69 -0
- migrations/102_promote_llm_costs_down.sql +19 -0
- migrations/103_ingest_skipped_log.sql +46 -0
- migrations/103_ingest_skipped_log_down.sql +15 -0
- migrations/120_docs_governance.sql +50 -0
- migrations/120_docs_governance_down.sql +11 -0
- migrations/121_notification_event_fk_cleanup.sql +21 -0
- migrations/121_notification_event_fk_cleanup_down.sql +10 -0
- migrations/122_docs_drift_history.sql +34 -0
- migrations/122_docs_drift_history_down.sql +15 -0
- migrations/123_ingest_parser_waiting_status.sql +69 -0
- migrations/123_ingest_parser_waiting_status_down.sql +69 -0
- migrations/124_heypocket_recordings.sql +63 -0
- migrations/124_heypocket_recordings_down.sql +13 -0
- migrations/125_kg_node_type_record.sql +219 -0
- migrations/125_kg_node_type_record_down.sql +205 -0
- migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
- migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
- migrations/127_brain_v1_substrate.sql +200 -0
- migrations/127_brain_v1_substrate_down.sql +32 -0
- migrations/128_brain_drift_signals.sql +157 -0
- migrations/128_brain_drift_signals_down.sql +23 -0
- migrations/129_brain_memory_operations.sql +232 -0
- migrations/129_brain_memory_operations_down.sql +27 -0
- migrations/130_brain_findings.sql +258 -0
- migrations/130_brain_findings_down.sql +29 -0
- migrations/132_kg_pr_modifies.sql +242 -0
- migrations/132_kg_pr_modifies_down.sql +99 -0
- migrations/133_brain_v1_2_direction_schema.sql +476 -0
- migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
- migrations/134_brain_journal_narrative_polished.sql +8 -0
- migrations/134_brain_journal_narrative_polished_down.sql +6 -0
- migrations/135_kg_edges_provider.sql +21 -0
- migrations/136_documents_fts.sql +56 -0
- migrations/137_promote_llm_costs.sql +59 -0
- migrations/137_promote_llm_costs_down.sql +19 -0
- migrations/138_ingest_api_keys.sql +39 -0
- migrations/138_ingest_api_keys_down.sql +8 -0
- migrations/139_ingest_pending_ingress.sql +91 -0
- migrations/139_ingest_pending_ingress_down.sql +73 -0
- migrations/140_ingest_idempotency_quota.sql +45 -0
- migrations/140_ingest_idempotency_quota_down.sql +9 -0
- migrations/141_ingest_pending_metadata.sql +16 -0
- migrations/141_ingest_pending_metadata_down.sql +7 -0
- migrations/142_llm_function_config.sql +36 -0
- migrations/142_llm_function_config_down.sql +8 -0
- migrations/143_kg_code_embeddings.sql +25 -0
- migrations/143_kg_code_embeddings_down.sql +5 -0
- migrations/__init__.py +4 -0
- projects/_template/project.yaml +46 -0
|
@@ -0,0 +1,1379 @@
|
|
|
1
|
+
# Brain v1 — Learn Findings service (sub-04 L5 — §4 / §5 / §10).
|
|
2
|
+
#
|
|
3
|
+
# Findings is the FINAL phase of brain_runs (after Memory-Ops). The
|
|
4
|
+
# orchestrator:
|
|
5
|
+
# 1. Builds a FindingSnapshot (read-only projection — digest events +
|
|
6
|
+
# journal entries + drift signals + memory operations of the current
|
|
7
|
+
# run_id).
|
|
8
|
+
# 2. Invokes each F-rule (F1-F6) with a 15s timeout; per-rule failures
|
|
9
|
+
# isolated and reported via partial_failures_json.
|
|
10
|
+
# 3. Persists findings via INSERT OR IGNORE (BLAKE2b stable id).
|
|
11
|
+
# 4. Updates supersede chain across prior open findings sharing the same
|
|
12
|
+
# proposal_fingerprint AND bumps recurrence_count on continuing rows.
|
|
13
|
+
#
|
|
14
|
+
# Layering invariants (parent §9, sub-04 §7 / §10.Z):
|
|
15
|
+
# * NO LLM imports (parent §9.3). AST-grep test enforces.
|
|
16
|
+
# * NO raw SQL on substrate (tasks/PR/handoffs/learnings/kg_edges).
|
|
17
|
+
# * NO mutation of substrate from this module.
|
|
18
|
+
# * Findings NEVER re-reads substrate — only L2 events + journal entries
|
|
19
|
+
# + L3 drift signals + L4 memory operations scoped to the current
|
|
20
|
+
# run_id (sub-04 §10.Y — same cycle envelope).
|
|
21
|
+
# * Stable BLAKE2b 16-byte finding_id. EXCLUDES: severity, confidence,
|
|
22
|
+
# summary, title, approval_state, owner_hint, suggested_artifact
|
|
23
|
+
# (sub-04 §7.2).
|
|
24
|
+
# * confidence is a CATEGORICAL TIER (low|medium|high), NEVER float.
|
|
25
|
+
# * Apply guidance ONLY — no auto-write to substrate (sub-04 F1 binding).
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import uuid
|
|
33
|
+
from collections import defaultdict
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from datetime import datetime, timedelta, timezone
|
|
36
|
+
from typing import Any, Iterable
|
|
37
|
+
|
|
38
|
+
import aiosqlite
|
|
39
|
+
|
|
40
|
+
from core.api.db import acquire_db, write_db
|
|
41
|
+
from core.api.models.brain import (
|
|
42
|
+
ArtifactSelector,
|
|
43
|
+
ClosureArtifactExists,
|
|
44
|
+
ClosureCondition,
|
|
45
|
+
ClosureDriftSignalClears,
|
|
46
|
+
ClosureManualAttest,
|
|
47
|
+
ClosureMemoryOpApplied,
|
|
48
|
+
ConfidenceTier,
|
|
49
|
+
Finding,
|
|
50
|
+
FindingApprovalState,
|
|
51
|
+
FindingType,
|
|
52
|
+
OwnerHint,
|
|
53
|
+
ScopeTypeL4,
|
|
54
|
+
Severity,
|
|
55
|
+
SuggestedArtifact,
|
|
56
|
+
)
|
|
57
|
+
from core.api.services.brain.owner_hint import compute_owner_hint
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
DEFAULT_RULE_TIMEOUT_S = 15
|
|
62
|
+
DEFAULT_OPEN_TTL_DAYS = 60
|
|
63
|
+
PER_TYPE_CAP_DEFAULT = 100
|
|
64
|
+
|
|
65
|
+
_SEVERITY_RANK_INT: dict[Severity, int] = {
|
|
66
|
+
"low": 1,
|
|
67
|
+
"medium": 2,
|
|
68
|
+
"high": 3,
|
|
69
|
+
"critical": 4,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_CONFIDENCE_RANK_INT: dict[ConfidenceTier, int] = {
|
|
73
|
+
"low": 1,
|
|
74
|
+
"medium": 2,
|
|
75
|
+
"high": 3,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _utc_iso(dt: datetime) -> str:
|
|
80
|
+
return dt.astimezone(timezone.utc).isoformat()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_iso(value: str | None) -> datetime | None:
|
|
84
|
+
if value is None:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
if dt.tzinfo is None:
|
|
91
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
92
|
+
return dt
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _canonical_evidence(evidence: Iterable[str]) -> str:
|
|
96
|
+
"""Stable JSON for hash. Mirror sub-02 / sub-03 helper (intentionally
|
|
97
|
+
duplicated to avoid importing a private helper across layers)."""
|
|
98
|
+
norm = sorted(str(item) for item in evidence)
|
|
99
|
+
return json.dumps(norm, sort_keys=False, ensure_ascii=False, separators=(",", ":"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def evidence_hash(evidence: Iterable[str]) -> str:
|
|
103
|
+
"""sha256 64-char hex per sub-04 §7 contract."""
|
|
104
|
+
return hashlib.sha256(_canonical_evidence(evidence).encode("utf-8")).hexdigest()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _closure_condition_hash(closure: ClosureCondition) -> str:
|
|
108
|
+
"""BLAKE2b-8 of canonical JSON serialization (kind + sorted params).
|
|
109
|
+
|
|
110
|
+
Used as a salt in the finding_id payload so two findings with identical
|
|
111
|
+
natural keys but different closure conditions get distinct stable IDs.
|
|
112
|
+
"""
|
|
113
|
+
if hasattr(closure, "model_dump"):
|
|
114
|
+
payload = closure.model_dump(mode="json")
|
|
115
|
+
else:
|
|
116
|
+
payload = closure
|
|
117
|
+
return hashlib.blake2b(
|
|
118
|
+
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8"),
|
|
119
|
+
digest_size=8,
|
|
120
|
+
).hexdigest()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def make_finding_id(
|
|
124
|
+
*,
|
|
125
|
+
cycle_key: str,
|
|
126
|
+
finding_type: FindingType,
|
|
127
|
+
scope_type: ScopeTypeL4,
|
|
128
|
+
scope_key: str,
|
|
129
|
+
evidence_source_refs_sorted: list[str],
|
|
130
|
+
closure_condition_hash_hex: str,
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Stable BLAKE2b-16 hex (sub-04 §7.2).
|
|
133
|
+
|
|
134
|
+
EXCLUDES: severity, confidence, summary, title, approval_state,
|
|
135
|
+
owner_hint, suggested_artifact. Recompute idempotent across interpreter
|
|
136
|
+
runs given identical natural-key inputs.
|
|
137
|
+
"""
|
|
138
|
+
payload = (
|
|
139
|
+
f"{cycle_key}|{finding_type}|{scope_type}|{scope_key}|"
|
|
140
|
+
f"{'|'.join(evidence_source_refs_sorted)}|{closure_condition_hash_hex}"
|
|
141
|
+
).encode("utf-8")
|
|
142
|
+
return hashlib.blake2b(payload, digest_size=16).hexdigest()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def make_proposal_fingerprint(
|
|
146
|
+
*,
|
|
147
|
+
finding_type: FindingType,
|
|
148
|
+
scope_type: ScopeTypeL4,
|
|
149
|
+
scope_key: str,
|
|
150
|
+
evidence_source_refs_sorted: list[str],
|
|
151
|
+
closure_condition_hash_hex: str,
|
|
152
|
+
) -> str:
|
|
153
|
+
"""BLAKE2b-16 hex without cycle_key — groups same finding across cycles."""
|
|
154
|
+
payload = (
|
|
155
|
+
f"{finding_type}|{scope_type}|{scope_key}|"
|
|
156
|
+
f"{'|'.join(evidence_source_refs_sorted)}|{closure_condition_hash_hex}"
|
|
157
|
+
).encode("utf-8")
|
|
158
|
+
return hashlib.blake2b(payload, digest_size=16).hexdigest()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Snapshot (read-only projection consumed by all F-rules)
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(slots=True, frozen=True)
|
|
167
|
+
class DigestEventRow:
|
|
168
|
+
event_id: str
|
|
169
|
+
cycle_key: str
|
|
170
|
+
event_type: str
|
|
171
|
+
source_system: str
|
|
172
|
+
source_project: str | None
|
|
173
|
+
target_project: str | None
|
|
174
|
+
program_key: str | None
|
|
175
|
+
source_ref: str
|
|
176
|
+
title: str
|
|
177
|
+
summary: str
|
|
178
|
+
observed_at: datetime
|
|
179
|
+
evidence: dict[str, Any]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(slots=True, frozen=True)
|
|
183
|
+
class JournalEntryRow:
|
|
184
|
+
entry_id: str
|
|
185
|
+
cycle_key: str
|
|
186
|
+
scope_type: str
|
|
187
|
+
scope_key: str
|
|
188
|
+
program_key: str | None
|
|
189
|
+
body: dict[str, Any]
|
|
190
|
+
is_empty: bool
|
|
191
|
+
published_at: datetime
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(slots=True, frozen=True)
|
|
195
|
+
class DriftSignalRow:
|
|
196
|
+
signal_id: str
|
|
197
|
+
cycle_key: str
|
|
198
|
+
rule_id: str
|
|
199
|
+
signal_type: str
|
|
200
|
+
knowledge_form: str
|
|
201
|
+
scope_type: str
|
|
202
|
+
scope_key: str
|
|
203
|
+
program_key: str | None
|
|
204
|
+
observed_direction_ref: str
|
|
205
|
+
severity: str
|
|
206
|
+
confidence: float
|
|
207
|
+
recurrence_key: str
|
|
208
|
+
involved_projects: list[str]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(slots=True, frozen=True)
|
|
212
|
+
class MemoryOpRow:
|
|
213
|
+
operation_id: str
|
|
214
|
+
cycle_key: str
|
|
215
|
+
operation_type: str
|
|
216
|
+
scope_type: str
|
|
217
|
+
scope_key: str
|
|
218
|
+
program_key: str | None
|
|
219
|
+
source_ref: str
|
|
220
|
+
target_ref: str
|
|
221
|
+
approval_state: str
|
|
222
|
+
score: float
|
|
223
|
+
recurrence_count: int
|
|
224
|
+
involved_projects: list[str]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(slots=True, frozen=True)
|
|
228
|
+
class FindingSnapshot:
|
|
229
|
+
"""Read-only L2/L3/L4 projection consumed by all F-rules."""
|
|
230
|
+
|
|
231
|
+
cycle_key: str
|
|
232
|
+
run_id: str
|
|
233
|
+
workspace_id: str
|
|
234
|
+
as_of: datetime
|
|
235
|
+
events: tuple[DigestEventRow, ...]
|
|
236
|
+
journal_entries: tuple[JournalEntryRow, ...]
|
|
237
|
+
drift_signals: tuple[DriftSignalRow, ...]
|
|
238
|
+
memory_ops: tuple[MemoryOpRow, ...]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(slots=True)
|
|
242
|
+
class FindingsReport:
|
|
243
|
+
"""Return envelope for findings.run_phase()."""
|
|
244
|
+
|
|
245
|
+
run_id: str
|
|
246
|
+
cycle_key: str
|
|
247
|
+
finding_count: int = 0
|
|
248
|
+
partial_failures: list[dict[str, str]] = field(default_factory=list)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _parse_json_dict(raw: str | None) -> dict[str, Any]:
|
|
252
|
+
if not raw:
|
|
253
|
+
return {}
|
|
254
|
+
try:
|
|
255
|
+
decoded = json.loads(raw)
|
|
256
|
+
except (TypeError, json.JSONDecodeError):
|
|
257
|
+
return {}
|
|
258
|
+
return decoded if isinstance(decoded, dict) else {}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _parse_json_list(raw: str | None) -> list[Any]:
|
|
262
|
+
if not raw:
|
|
263
|
+
return []
|
|
264
|
+
try:
|
|
265
|
+
decoded = json.loads(raw)
|
|
266
|
+
except (TypeError, json.JSONDecodeError):
|
|
267
|
+
return []
|
|
268
|
+
return decoded if isinstance(decoded, list) else []
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def build_snapshot(
|
|
272
|
+
*,
|
|
273
|
+
run_id: str,
|
|
274
|
+
cycle_key: str,
|
|
275
|
+
workspace_id: str = "ws_default",
|
|
276
|
+
as_of: datetime | None = None,
|
|
277
|
+
) -> FindingSnapshot:
|
|
278
|
+
"""Read-only projection: digest events + journal entries + drift signals
|
|
279
|
+
+ memory operations for the current run_id. Findings NEVER re-reads
|
|
280
|
+
substrate (sub-04 §10.Y / §10.Z invariant 4).
|
|
281
|
+
"""
|
|
282
|
+
now = as_of or datetime.now(timezone.utc)
|
|
283
|
+
async with acquire_db() as db:
|
|
284
|
+
db.row_factory = aiosqlite.Row
|
|
285
|
+
ev_rows = await (
|
|
286
|
+
await db.execute(
|
|
287
|
+
"SELECT event_id, cycle_key, event_type, source_system, source_project,"
|
|
288
|
+
" target_project, program_key, source_ref, title, summary, observed_at,"
|
|
289
|
+
" evidence_json FROM brain_digest_events WHERE run_id = ?",
|
|
290
|
+
(run_id,),
|
|
291
|
+
)
|
|
292
|
+
).fetchall()
|
|
293
|
+
jn_rows = await (
|
|
294
|
+
await db.execute(
|
|
295
|
+
"SELECT entry_id, cycle_key, scope_type, scope_key, program_key,"
|
|
296
|
+
" body_json, is_empty, published_at"
|
|
297
|
+
" FROM brain_journal_entries WHERE run_id = ?",
|
|
298
|
+
(run_id,),
|
|
299
|
+
)
|
|
300
|
+
).fetchall()
|
|
301
|
+
dr_rows = await (
|
|
302
|
+
await db.execute(
|
|
303
|
+
"SELECT signal_id, cycle_key, rule_id, signal_type, knowledge_form,"
|
|
304
|
+
" scope_type, scope_key, program_key, observed_direction_ref,"
|
|
305
|
+
" severity, confidence, recurrence_key, involved_projects_json"
|
|
306
|
+
" FROM brain_drift_signals WHERE run_id = ?",
|
|
307
|
+
(run_id,),
|
|
308
|
+
)
|
|
309
|
+
).fetchall()
|
|
310
|
+
mo_rows = await (
|
|
311
|
+
await db.execute(
|
|
312
|
+
"SELECT operation_id, cycle_key, operation_type, scope_type, scope_key,"
|
|
313
|
+
" program_key, source_ref, target_ref, approval_state, score,"
|
|
314
|
+
" recurrence_count, involved_projects_json"
|
|
315
|
+
" FROM brain_memory_operations WHERE run_id = ?",
|
|
316
|
+
(run_id,),
|
|
317
|
+
)
|
|
318
|
+
).fetchall()
|
|
319
|
+
|
|
320
|
+
events = tuple(
|
|
321
|
+
DigestEventRow(
|
|
322
|
+
event_id=r["event_id"],
|
|
323
|
+
cycle_key=r["cycle_key"],
|
|
324
|
+
event_type=r["event_type"],
|
|
325
|
+
source_system=r["source_system"],
|
|
326
|
+
source_project=r["source_project"],
|
|
327
|
+
target_project=r["target_project"],
|
|
328
|
+
program_key=r["program_key"],
|
|
329
|
+
source_ref=r["source_ref"],
|
|
330
|
+
title=r["title"] or "",
|
|
331
|
+
summary=r["summary"] or "",
|
|
332
|
+
observed_at=_parse_iso(r["observed_at"]) or now,
|
|
333
|
+
evidence=_parse_json_dict(r["evidence_json"]),
|
|
334
|
+
)
|
|
335
|
+
for r in ev_rows
|
|
336
|
+
)
|
|
337
|
+
journals = tuple(
|
|
338
|
+
JournalEntryRow(
|
|
339
|
+
entry_id=r["entry_id"],
|
|
340
|
+
cycle_key=r["cycle_key"],
|
|
341
|
+
scope_type=r["scope_type"],
|
|
342
|
+
scope_key=r["scope_key"],
|
|
343
|
+
program_key=r["program_key"],
|
|
344
|
+
body=_parse_json_dict(r["body_json"]),
|
|
345
|
+
is_empty=bool(r["is_empty"]),
|
|
346
|
+
published_at=_parse_iso(r["published_at"]) or now,
|
|
347
|
+
)
|
|
348
|
+
for r in jn_rows
|
|
349
|
+
)
|
|
350
|
+
signals = tuple(
|
|
351
|
+
DriftSignalRow(
|
|
352
|
+
signal_id=r["signal_id"],
|
|
353
|
+
cycle_key=r["cycle_key"],
|
|
354
|
+
rule_id=r["rule_id"],
|
|
355
|
+
signal_type=r["signal_type"],
|
|
356
|
+
knowledge_form=r["knowledge_form"],
|
|
357
|
+
scope_type=r["scope_type"],
|
|
358
|
+
scope_key=r["scope_key"],
|
|
359
|
+
program_key=r["program_key"],
|
|
360
|
+
observed_direction_ref=r["observed_direction_ref"],
|
|
361
|
+
severity=r["severity"],
|
|
362
|
+
confidence=float(r["confidence"] or 0.0),
|
|
363
|
+
recurrence_key=r["recurrence_key"],
|
|
364
|
+
involved_projects=[
|
|
365
|
+
str(p) for p in _parse_json_list(r["involved_projects_json"])
|
|
366
|
+
],
|
|
367
|
+
)
|
|
368
|
+
for r in dr_rows
|
|
369
|
+
)
|
|
370
|
+
memory_ops = tuple(
|
|
371
|
+
MemoryOpRow(
|
|
372
|
+
operation_id=r["operation_id"],
|
|
373
|
+
cycle_key=r["cycle_key"],
|
|
374
|
+
operation_type=r["operation_type"],
|
|
375
|
+
scope_type=r["scope_type"],
|
|
376
|
+
scope_key=r["scope_key"],
|
|
377
|
+
program_key=r["program_key"],
|
|
378
|
+
source_ref=r["source_ref"],
|
|
379
|
+
target_ref=r["target_ref"] or "",
|
|
380
|
+
approval_state=r["approval_state"],
|
|
381
|
+
score=float(r["score"] or 0.0),
|
|
382
|
+
recurrence_count=int(r["recurrence_count"] or 1),
|
|
383
|
+
involved_projects=[
|
|
384
|
+
str(p) for p in _parse_json_list(r["involved_projects_json"])
|
|
385
|
+
],
|
|
386
|
+
)
|
|
387
|
+
for r in mo_rows
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
return FindingSnapshot(
|
|
391
|
+
cycle_key=cycle_key,
|
|
392
|
+
run_id=run_id,
|
|
393
|
+
workspace_id=workspace_id,
|
|
394
|
+
as_of=now,
|
|
395
|
+
events=events,
|
|
396
|
+
journal_entries=journals,
|
|
397
|
+
drift_signals=signals,
|
|
398
|
+
memory_ops=memory_ops,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
# Builder
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@dataclass(slots=True)
|
|
408
|
+
class FindingDraft:
|
|
409
|
+
"""Raw rule output before stable-id derivation + persistence."""
|
|
410
|
+
|
|
411
|
+
finding_type: FindingType
|
|
412
|
+
scope_type: ScopeTypeL4
|
|
413
|
+
scope_key: str
|
|
414
|
+
program_key: str | None
|
|
415
|
+
title: str
|
|
416
|
+
summary: str
|
|
417
|
+
why_now: str
|
|
418
|
+
evidence: list[str]
|
|
419
|
+
suggested_artifact: SuggestedArtifact
|
|
420
|
+
closure_condition: ClosureCondition
|
|
421
|
+
severity: Severity
|
|
422
|
+
confidence: ConfidenceTier
|
|
423
|
+
involved_projects: list[str] = field(default_factory=list)
|
|
424
|
+
owner_hint: OwnerHint | None = None
|
|
425
|
+
closure_condition_human: str | None = None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _derive_confidence(
|
|
429
|
+
*,
|
|
430
|
+
drift_refs: int,
|
|
431
|
+
memory_op_refs: int,
|
|
432
|
+
recurrence_count: int,
|
|
433
|
+
) -> ConfidenceTier:
|
|
434
|
+
"""Sub-04 §4 / §7.4 prose:
|
|
435
|
+
High: deterministic evidence (drift + memory op) + recurrence >= 3.
|
|
436
|
+
Medium: deterministic drift OR memory op + single-cycle evidence.
|
|
437
|
+
Low: weak/noisy source (no drift, no memory op).
|
|
438
|
+
|
|
439
|
+
Drift signals and memory ops are BOTH "deterministic" evidence sources
|
|
440
|
+
(both are produced by deterministic rules upstream of Findings). Recurrence
|
|
441
|
+
over 3 cycles bumps to high regardless of which path supplied the signal.
|
|
442
|
+
"""
|
|
443
|
+
deterministic = drift_refs + memory_op_refs
|
|
444
|
+
if deterministic >= 1 and recurrence_count >= 3:
|
|
445
|
+
return "high"
|
|
446
|
+
if deterministic >= 1:
|
|
447
|
+
return "medium"
|
|
448
|
+
return "low"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def finalize_finding(
|
|
452
|
+
*,
|
|
453
|
+
draft: FindingDraft,
|
|
454
|
+
run_id: str,
|
|
455
|
+
cycle_key: str,
|
|
456
|
+
now: datetime,
|
|
457
|
+
recurrence_count: int = 1,
|
|
458
|
+
first_seen_cycle_key: str | None = None,
|
|
459
|
+
open_ttl_days: int = DEFAULT_OPEN_TTL_DAYS,
|
|
460
|
+
db: aiosqlite.Connection | None = None,
|
|
461
|
+
) -> Finding:
|
|
462
|
+
"""Derive id/hash/owner_hint and construct a Finding Pydantic instance.
|
|
463
|
+
|
|
464
|
+
`db` is optional — if supplied, the owner_hint lookup reuses the
|
|
465
|
+
existing connection; otherwise a short-lived read pool connection is
|
|
466
|
+
acquired. Owner hint failure degrades to None (sub-04 §7.5).
|
|
467
|
+
"""
|
|
468
|
+
evidence_sorted = sorted(set(draft.evidence))
|
|
469
|
+
ev_hash = evidence_hash(evidence_sorted)
|
|
470
|
+
cc_hash = _closure_condition_hash(draft.closure_condition)
|
|
471
|
+
finding_id = make_finding_id(
|
|
472
|
+
cycle_key=cycle_key,
|
|
473
|
+
finding_type=draft.finding_type,
|
|
474
|
+
scope_type=draft.scope_type,
|
|
475
|
+
scope_key=draft.scope_key,
|
|
476
|
+
evidence_source_refs_sorted=evidence_sorted,
|
|
477
|
+
closure_condition_hash_hex=cc_hash,
|
|
478
|
+
)
|
|
479
|
+
proposal_fp = make_proposal_fingerprint(
|
|
480
|
+
finding_type=draft.finding_type,
|
|
481
|
+
scope_type=draft.scope_type,
|
|
482
|
+
scope_key=draft.scope_key,
|
|
483
|
+
evidence_source_refs_sorted=evidence_sorted,
|
|
484
|
+
closure_condition_hash_hex=cc_hash,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
owner_hint = draft.owner_hint
|
|
488
|
+
if owner_hint is None:
|
|
489
|
+
try:
|
|
490
|
+
owner_hint = await compute_owner_hint(
|
|
491
|
+
scope_type=draft.scope_type, scope_key=draft.scope_key, db=db
|
|
492
|
+
)
|
|
493
|
+
except Exception:
|
|
494
|
+
owner_hint = None
|
|
495
|
+
|
|
496
|
+
return Finding(
|
|
497
|
+
finding_id=finding_id,
|
|
498
|
+
run_id=run_id,
|
|
499
|
+
cycle_key=cycle_key,
|
|
500
|
+
detected_at=now.astimezone(timezone.utc),
|
|
501
|
+
finding_type=draft.finding_type,
|
|
502
|
+
schema_version=1,
|
|
503
|
+
scope_type=draft.scope_type,
|
|
504
|
+
scope_key=draft.scope_key,
|
|
505
|
+
program_key=draft.program_key,
|
|
506
|
+
title=draft.title[:200],
|
|
507
|
+
summary=draft.summary[:2000],
|
|
508
|
+
why_now=draft.why_now[:500],
|
|
509
|
+
evidence=evidence_sorted,
|
|
510
|
+
evidence_hash=ev_hash,
|
|
511
|
+
involved_projects=sorted(set(draft.involved_projects)),
|
|
512
|
+
suggested_artifact=draft.suggested_artifact,
|
|
513
|
+
owner_hint=owner_hint,
|
|
514
|
+
closure_condition=draft.closure_condition,
|
|
515
|
+
closure_condition_human=draft.closure_condition_human,
|
|
516
|
+
severity=draft.severity,
|
|
517
|
+
confidence=draft.confidence,
|
|
518
|
+
approval_state="open",
|
|
519
|
+
regression_of_finding_id=None,
|
|
520
|
+
proposal_fingerprint=proposal_fp,
|
|
521
|
+
recurrence_count=recurrence_count,
|
|
522
|
+
first_seen_cycle_key=first_seen_cycle_key or cycle_key,
|
|
523
|
+
last_seen_cycle_key=cycle_key,
|
|
524
|
+
expires_at=(now + timedelta(days=open_ttl_days)).astimezone(timezone.utc),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
# Rule builders — F1-F6 (sub-04 §3 mapping rules)
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
#
|
|
532
|
+
# Each rule consumes ONLY the snapshot. Drift signals are the dominant
|
|
533
|
+
# producer (5/6 mappings); memory operations contribute through F6
|
|
534
|
+
# promotion_candidate. F-rules NEVER touch substrate (parent §9 invariant).
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _project_scope(slug: str | None) -> tuple[ScopeTypeL4, str]:
|
|
538
|
+
return ("project", slug) if slug else ("company", "__company__")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _coerce_scope_type(value: str) -> ScopeTypeL4:
|
|
542
|
+
if value in ("company", "program", "project", "artifact"):
|
|
543
|
+
return value # type: ignore[return-value]
|
|
544
|
+
return "company"
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _drift_signal_evidence(signal: DriftSignalRow) -> list[str]:
|
|
548
|
+
return [f"drift_signal:{signal.signal_id}"]
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _memory_op_evidence(op: MemoryOpRow) -> list[str]:
|
|
552
|
+
return [f"memory_op:{op.operation_id}"]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _truncate(s: str, *, limit: int) -> str:
|
|
556
|
+
s = (s or "").strip()
|
|
557
|
+
if len(s) <= limit:
|
|
558
|
+
return s
|
|
559
|
+
return s[: limit - 1] + "…"
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
async def _f1_decision_without_adr(
|
|
563
|
+
snapshot: FindingSnapshot, *, run_id: str, now: datetime
|
|
564
|
+
) -> list[FindingDraft]:
|
|
565
|
+
"""DR2 decision_without_adr → open_question OR task_candidate (sub-04 §3)."""
|
|
566
|
+
drafts: list[FindingDraft] = []
|
|
567
|
+
for sig in snapshot.drift_signals:
|
|
568
|
+
if sig.signal_type != "decision_without_adr":
|
|
569
|
+
continue
|
|
570
|
+
scope_type = _coerce_scope_type(sig.scope_type)
|
|
571
|
+
scope_key = sig.scope_key if scope_type != "company" else "__company__"
|
|
572
|
+
evidence = _drift_signal_evidence(sig)
|
|
573
|
+
severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
|
|
574
|
+
confidence = _derive_confidence(drift_refs=1, memory_op_refs=0, recurrence_count=1)
|
|
575
|
+
is_task = sig.severity in ("high", "critical")
|
|
576
|
+
finding_type: FindingType = "task_candidate" if is_task else "open_question"
|
|
577
|
+
suggested: SuggestedArtifact = "adr"
|
|
578
|
+
title = _truncate(
|
|
579
|
+
f"Decision without ADR on {sig.observed_direction_ref}",
|
|
580
|
+
limit=200,
|
|
581
|
+
)
|
|
582
|
+
summary = _truncate(
|
|
583
|
+
f"DR2 {sig.signal_id}: scope={scope_type}/{scope_key} flags "
|
|
584
|
+
f"{sig.observed_direction_ref} as decision_without_adr "
|
|
585
|
+
f"(knowledge_form={sig.knowledge_form}).",
|
|
586
|
+
limit=2000,
|
|
587
|
+
)
|
|
588
|
+
why_now = _truncate(
|
|
589
|
+
f"Drift rule DR2 fired this cycle for {sig.observed_direction_ref}.",
|
|
590
|
+
limit=500,
|
|
591
|
+
)
|
|
592
|
+
closure: ClosureCondition = ClosureArtifactExists(
|
|
593
|
+
artifact_kind="adr",
|
|
594
|
+
selector=ArtifactSelector(
|
|
595
|
+
tag_match="brain_finding:{finding_id}",
|
|
596
|
+
),
|
|
597
|
+
)
|
|
598
|
+
drafts.append(
|
|
599
|
+
FindingDraft(
|
|
600
|
+
finding_type=finding_type,
|
|
601
|
+
scope_type=scope_type,
|
|
602
|
+
scope_key=scope_key,
|
|
603
|
+
program_key=sig.program_key,
|
|
604
|
+
title=title,
|
|
605
|
+
summary=summary,
|
|
606
|
+
why_now=why_now,
|
|
607
|
+
evidence=evidence,
|
|
608
|
+
suggested_artifact=suggested,
|
|
609
|
+
closure_condition=closure,
|
|
610
|
+
severity=severity,
|
|
611
|
+
confidence=confidence,
|
|
612
|
+
involved_projects=list(sig.involved_projects),
|
|
613
|
+
)
|
|
614
|
+
)
|
|
615
|
+
return drafts
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
async def _f2_playbook_changed(
|
|
619
|
+
snapshot: FindingSnapshot, *, run_id: str, now: datetime
|
|
620
|
+
) -> list[FindingDraft]:
|
|
621
|
+
"""playbook_changed drift → procedure_change finding (sub-04 §3)."""
|
|
622
|
+
drafts: list[FindingDraft] = []
|
|
623
|
+
for sig in snapshot.drift_signals:
|
|
624
|
+
if sig.signal_type != "playbook_changed":
|
|
625
|
+
continue
|
|
626
|
+
scope_type = _coerce_scope_type(sig.scope_type)
|
|
627
|
+
scope_key = sig.scope_key if scope_type != "company" else "__company__"
|
|
628
|
+
severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
|
|
629
|
+
evidence = _drift_signal_evidence(sig)
|
|
630
|
+
title = _truncate(
|
|
631
|
+
f"Playbook change observed for {sig.observed_direction_ref}",
|
|
632
|
+
limit=200,
|
|
633
|
+
)
|
|
634
|
+
summary = _truncate(
|
|
635
|
+
f"DR3 / playbook_changed: {sig.observed_direction_ref} "
|
|
636
|
+
f"(knowledge_form={sig.knowledge_form}) changed without a guide update.",
|
|
637
|
+
limit=2000,
|
|
638
|
+
)
|
|
639
|
+
why_now = _truncate(
|
|
640
|
+
f"Drift signal {sig.signal_id} flags playbook drift this cycle.",
|
|
641
|
+
limit=500,
|
|
642
|
+
)
|
|
643
|
+
closure: ClosureCondition = ClosureArtifactExists(
|
|
644
|
+
artifact_kind="guide",
|
|
645
|
+
selector=ArtifactSelector(
|
|
646
|
+
tag_match="brain_finding:{finding_id}",
|
|
647
|
+
),
|
|
648
|
+
)
|
|
649
|
+
drafts.append(
|
|
650
|
+
FindingDraft(
|
|
651
|
+
finding_type="procedure_change",
|
|
652
|
+
scope_type=scope_type,
|
|
653
|
+
scope_key=scope_key,
|
|
654
|
+
program_key=sig.program_key,
|
|
655
|
+
title=title,
|
|
656
|
+
summary=summary,
|
|
657
|
+
why_now=why_now,
|
|
658
|
+
evidence=evidence,
|
|
659
|
+
suggested_artifact="guide",
|
|
660
|
+
closure_condition=closure,
|
|
661
|
+
severity=severity,
|
|
662
|
+
confidence=_derive_confidence(
|
|
663
|
+
drift_refs=1, memory_op_refs=0, recurrence_count=1
|
|
664
|
+
),
|
|
665
|
+
involved_projects=list(sig.involved_projects),
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
return drafts
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
async def _f3_stale_open_loop(
|
|
672
|
+
snapshot: FindingSnapshot, *, run_id: str, now: datetime
|
|
673
|
+
) -> list[FindingDraft]:
|
|
674
|
+
"""stale_open_loop drift → task_candidate (sub-04 §3)."""
|
|
675
|
+
drafts: list[FindingDraft] = []
|
|
676
|
+
for sig in snapshot.drift_signals:
|
|
677
|
+
if sig.signal_type != "stale_open_loop":
|
|
678
|
+
continue
|
|
679
|
+
scope_type = _coerce_scope_type(sig.scope_type)
|
|
680
|
+
scope_key = sig.scope_key if scope_type != "company" else "__company__"
|
|
681
|
+
severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
|
|
682
|
+
evidence = _drift_signal_evidence(sig)
|
|
683
|
+
title = _truncate(
|
|
684
|
+
f"Stale open loop on {sig.observed_direction_ref}",
|
|
685
|
+
limit=200,
|
|
686
|
+
)
|
|
687
|
+
summary = _truncate(
|
|
688
|
+
f"DR4 / stale_open_loop: {sig.observed_direction_ref} has had no "
|
|
689
|
+
f"observable progress (knowledge_form={sig.knowledge_form}).",
|
|
690
|
+
limit=2000,
|
|
691
|
+
)
|
|
692
|
+
why_now = _truncate(
|
|
693
|
+
f"Drift signal {sig.signal_id} flags stale loop this cycle.",
|
|
694
|
+
limit=500,
|
|
695
|
+
)
|
|
696
|
+
closure: ClosureCondition = ClosureDriftSignalClears(
|
|
697
|
+
drift_signal_id=sig.signal_id,
|
|
698
|
+
consecutive_clear_cycles=2,
|
|
699
|
+
)
|
|
700
|
+
drafts.append(
|
|
701
|
+
FindingDraft(
|
|
702
|
+
finding_type="task_candidate",
|
|
703
|
+
scope_type=scope_type,
|
|
704
|
+
scope_key=scope_key,
|
|
705
|
+
program_key=sig.program_key,
|
|
706
|
+
title=title,
|
|
707
|
+
summary=summary,
|
|
708
|
+
why_now=why_now,
|
|
709
|
+
evidence=evidence,
|
|
710
|
+
suggested_artifact="task",
|
|
711
|
+
closure_condition=closure,
|
|
712
|
+
severity=severity,
|
|
713
|
+
confidence=_derive_confidence(
|
|
714
|
+
drift_refs=1, memory_op_refs=0, recurrence_count=1
|
|
715
|
+
),
|
|
716
|
+
involved_projects=list(sig.involved_projects),
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
return drafts
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
async def _f4_external_update_unpropagated(
|
|
723
|
+
snapshot: FindingSnapshot, *, run_id: str, now: datetime
|
|
724
|
+
) -> list[FindingDraft]:
|
|
725
|
+
"""external_update_unpropagated → task_candidate (sub-04 §3)."""
|
|
726
|
+
drafts: list[FindingDraft] = []
|
|
727
|
+
for sig in snapshot.drift_signals:
|
|
728
|
+
if sig.signal_type != "external_update_unpropagated":
|
|
729
|
+
continue
|
|
730
|
+
scope_type = _coerce_scope_type(sig.scope_type)
|
|
731
|
+
scope_key = sig.scope_key if scope_type != "company" else "__company__"
|
|
732
|
+
severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
|
|
733
|
+
evidence = _drift_signal_evidence(sig)
|
|
734
|
+
title = _truncate(
|
|
735
|
+
f"External update needs propagation: {sig.observed_direction_ref}",
|
|
736
|
+
limit=200,
|
|
737
|
+
)
|
|
738
|
+
summary = _truncate(
|
|
739
|
+
f"DR6 / external_update_unpropagated: {sig.observed_direction_ref} "
|
|
740
|
+
"carries upstream changes not yet reflected in our context.",
|
|
741
|
+
limit=2000,
|
|
742
|
+
)
|
|
743
|
+
why_now = _truncate(
|
|
744
|
+
f"Drift signal {sig.signal_id} flags unpropagated update this cycle.",
|
|
745
|
+
limit=500,
|
|
746
|
+
)
|
|
747
|
+
closure: ClosureCondition = ClosureArtifactExists(
|
|
748
|
+
artifact_kind="task",
|
|
749
|
+
selector=ArtifactSelector(
|
|
750
|
+
tag_match="brain_finding:{finding_id}",
|
|
751
|
+
),
|
|
752
|
+
)
|
|
753
|
+
drafts.append(
|
|
754
|
+
FindingDraft(
|
|
755
|
+
finding_type="task_candidate",
|
|
756
|
+
scope_type=scope_type,
|
|
757
|
+
scope_key=scope_key,
|
|
758
|
+
program_key=sig.program_key,
|
|
759
|
+
title=title,
|
|
760
|
+
summary=summary,
|
|
761
|
+
why_now=why_now,
|
|
762
|
+
evidence=evidence,
|
|
763
|
+
suggested_artifact="task",
|
|
764
|
+
closure_condition=closure,
|
|
765
|
+
severity=severity,
|
|
766
|
+
confidence=_derive_confidence(
|
|
767
|
+
drift_refs=1, memory_op_refs=0, recurrence_count=1
|
|
768
|
+
),
|
|
769
|
+
involved_projects=list(sig.involved_projects),
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
return drafts
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
async def _f5_claimed_decision_gap(
|
|
776
|
+
snapshot: FindingSnapshot, *, run_id: str, now: datetime
|
|
777
|
+
) -> list[FindingDraft]:
|
|
778
|
+
"""claimed_decision_gap → scope_gap (sub-04 §3)."""
|
|
779
|
+
drafts: list[FindingDraft] = []
|
|
780
|
+
for sig in snapshot.drift_signals:
|
|
781
|
+
if sig.signal_type != "claimed_decision_gap":
|
|
782
|
+
continue
|
|
783
|
+
scope_type = _coerce_scope_type(sig.scope_type)
|
|
784
|
+
scope_key = sig.scope_key if scope_type != "company" else "__company__"
|
|
785
|
+
severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
|
|
786
|
+
evidence = _drift_signal_evidence(sig)
|
|
787
|
+
title = _truncate(
|
|
788
|
+
f"Claimed decision lacks evidence: {sig.observed_direction_ref}",
|
|
789
|
+
limit=200,
|
|
790
|
+
)
|
|
791
|
+
summary = _truncate(
|
|
792
|
+
f"DR7 / claimed_decision_gap: {sig.observed_direction_ref} surfaces "
|
|
793
|
+
"as a claimed decision without the substrate to back it.",
|
|
794
|
+
limit=2000,
|
|
795
|
+
)
|
|
796
|
+
why_now = _truncate(
|
|
797
|
+
f"Drift signal {sig.signal_id} flags decision gap this cycle.",
|
|
798
|
+
limit=500,
|
|
799
|
+
)
|
|
800
|
+
closure: ClosureCondition = ClosureManualAttest(
|
|
801
|
+
instruction="Audit follow-up: verify the claimed decision and record evidence.",
|
|
802
|
+
)
|
|
803
|
+
drafts.append(
|
|
804
|
+
FindingDraft(
|
|
805
|
+
finding_type="scope_gap",
|
|
806
|
+
scope_type=scope_type,
|
|
807
|
+
scope_key=scope_key,
|
|
808
|
+
program_key=sig.program_key,
|
|
809
|
+
title=title,
|
|
810
|
+
summary=summary,
|
|
811
|
+
why_now=why_now,
|
|
812
|
+
evidence=evidence,
|
|
813
|
+
suggested_artifact="status_update",
|
|
814
|
+
closure_condition=closure,
|
|
815
|
+
severity=severity,
|
|
816
|
+
confidence=_derive_confidence(
|
|
817
|
+
drift_refs=1, memory_op_refs=0, recurrence_count=1
|
|
818
|
+
),
|
|
819
|
+
involved_projects=list(sig.involved_projects),
|
|
820
|
+
)
|
|
821
|
+
)
|
|
822
|
+
return drafts
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
async def _f6_contradiction(
|
|
826
|
+
snapshot: FindingSnapshot, *, run_id: str, now: datetime
|
|
827
|
+
) -> list[FindingDraft]:
|
|
828
|
+
"""contradiction_detected memory ops → contradiction finding (sub-04 §3).
|
|
829
|
+
|
|
830
|
+
Sources:
|
|
831
|
+
* Memory ops with operation_type='contradiction_detected' (M7 output).
|
|
832
|
+
* Promotion candidates (M-rule promotion_candidate) emit idea findings —
|
|
833
|
+
handled inline here as the secondary trigger so we keep F-count to 6.
|
|
834
|
+
"""
|
|
835
|
+
drafts: list[FindingDraft] = []
|
|
836
|
+
for op in snapshot.memory_ops:
|
|
837
|
+
if op.operation_type == "contradiction_detected":
|
|
838
|
+
scope_type = _coerce_scope_type(op.scope_type)
|
|
839
|
+
scope_key = op.scope_key if scope_type != "company" else "__company__"
|
|
840
|
+
evidence = _memory_op_evidence(op)
|
|
841
|
+
title = _truncate(
|
|
842
|
+
f"Contradiction between {op.source_ref} and {op.target_ref}",
|
|
843
|
+
limit=200,
|
|
844
|
+
)
|
|
845
|
+
summary = _truncate(
|
|
846
|
+
f"Memory op {op.operation_id} surfaces two refs in tension. "
|
|
847
|
+
f"Manual resolution required.",
|
|
848
|
+
limit=2000,
|
|
849
|
+
)
|
|
850
|
+
why_now = _truncate(
|
|
851
|
+
f"Memory operation {op.operation_id} fired this cycle.",
|
|
852
|
+
limit=500,
|
|
853
|
+
)
|
|
854
|
+
closure: ClosureCondition = ClosureMemoryOpApplied(
|
|
855
|
+
memory_operation_id=op.operation_id,
|
|
856
|
+
)
|
|
857
|
+
drafts.append(
|
|
858
|
+
FindingDraft(
|
|
859
|
+
finding_type="contradiction",
|
|
860
|
+
scope_type=scope_type,
|
|
861
|
+
scope_key=scope_key,
|
|
862
|
+
program_key=op.program_key,
|
|
863
|
+
title=title,
|
|
864
|
+
summary=summary,
|
|
865
|
+
why_now=why_now,
|
|
866
|
+
evidence=evidence,
|
|
867
|
+
suggested_artifact="task",
|
|
868
|
+
closure_condition=closure,
|
|
869
|
+
severity="high",
|
|
870
|
+
confidence=_derive_confidence(
|
|
871
|
+
drift_refs=0,
|
|
872
|
+
memory_op_refs=1,
|
|
873
|
+
recurrence_count=op.recurrence_count,
|
|
874
|
+
),
|
|
875
|
+
involved_projects=list(op.involved_projects),
|
|
876
|
+
)
|
|
877
|
+
)
|
|
878
|
+
continue
|
|
879
|
+
if op.operation_type == "promotion_candidate":
|
|
880
|
+
scope_type = _coerce_scope_type(op.scope_type)
|
|
881
|
+
scope_key = op.scope_key if scope_type != "company" else "__company__"
|
|
882
|
+
evidence = _memory_op_evidence(op)
|
|
883
|
+
title = _truncate(
|
|
884
|
+
f"Promotion candidate: {op.source_ref}",
|
|
885
|
+
limit=200,
|
|
886
|
+
)
|
|
887
|
+
summary = _truncate(
|
|
888
|
+
f"Memory op {op.operation_id} signals {op.source_ref} as a "
|
|
889
|
+
"stable pattern worth promoting to a learning or guide.",
|
|
890
|
+
limit=2000,
|
|
891
|
+
)
|
|
892
|
+
why_now = _truncate(
|
|
893
|
+
f"Memory operation {op.operation_id} reached promotion threshold this cycle.",
|
|
894
|
+
limit=500,
|
|
895
|
+
)
|
|
896
|
+
closure_artifact: ClosureCondition = ClosureArtifactExists(
|
|
897
|
+
artifact_kind="learning",
|
|
898
|
+
selector=ArtifactSelector(
|
|
899
|
+
tag_match="brain_finding:{finding_id}",
|
|
900
|
+
),
|
|
901
|
+
)
|
|
902
|
+
drafts.append(
|
|
903
|
+
FindingDraft(
|
|
904
|
+
finding_type="idea",
|
|
905
|
+
scope_type=scope_type,
|
|
906
|
+
scope_key=scope_key,
|
|
907
|
+
program_key=op.program_key,
|
|
908
|
+
title=title,
|
|
909
|
+
summary=summary,
|
|
910
|
+
why_now=why_now,
|
|
911
|
+
evidence=evidence,
|
|
912
|
+
suggested_artifact="learning",
|
|
913
|
+
closure_condition=closure_artifact,
|
|
914
|
+
severity="low",
|
|
915
|
+
confidence=_derive_confidence(
|
|
916
|
+
drift_refs=0,
|
|
917
|
+
memory_op_refs=1,
|
|
918
|
+
recurrence_count=op.recurrence_count,
|
|
919
|
+
),
|
|
920
|
+
involved_projects=list(op.involved_projects),
|
|
921
|
+
)
|
|
922
|
+
)
|
|
923
|
+
return drafts
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
REGISTERED_RULES: tuple[tuple[str, Any], ...] = (
|
|
927
|
+
("F1", _f1_decision_without_adr),
|
|
928
|
+
("F2", _f2_playbook_changed),
|
|
929
|
+
("F3", _f3_stale_open_loop),
|
|
930
|
+
("F4", _f4_external_update_unpropagated),
|
|
931
|
+
("F5", _f5_claimed_decision_gap),
|
|
932
|
+
("F6", _f6_contradiction),
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
# ---------------------------------------------------------------------------
|
|
937
|
+
# Persistence
|
|
938
|
+
# ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _serialize_owner_hint(owner_hint: OwnerHint | None) -> str:
|
|
942
|
+
if owner_hint is None:
|
|
943
|
+
return "{}"
|
|
944
|
+
return owner_hint.model_dump_json()
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _serialize_closure(closure: ClosureCondition) -> tuple[str, str]:
|
|
948
|
+
"""Return (kind, json_str)."""
|
|
949
|
+
if hasattr(closure, "model_dump_json"):
|
|
950
|
+
return (closure.kind, closure.model_dump_json()) # type: ignore[union-attr]
|
|
951
|
+
raise TypeError(f"closure must be a Pydantic model, got {type(closure)!r}")
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _evidence_kind_for(ref: str) -> tuple[str, str]:
|
|
955
|
+
if ":" in ref:
|
|
956
|
+
prefix, rest = ref.split(":", 1)
|
|
957
|
+
else:
|
|
958
|
+
prefix, rest = "kg_node", ref
|
|
959
|
+
mapping = {
|
|
960
|
+
"event": "digest_event",
|
|
961
|
+
"digest_event": "digest_event",
|
|
962
|
+
"drift": "drift_signal",
|
|
963
|
+
"drift_signal": "drift_signal",
|
|
964
|
+
"journal": "journal_entry",
|
|
965
|
+
"journal_entry": "journal_entry",
|
|
966
|
+
"memory_op": "memory_op",
|
|
967
|
+
"handoff": "handoff",
|
|
968
|
+
"learning": "learning",
|
|
969
|
+
"kg": "kg_node",
|
|
970
|
+
"kg_node": "kg_node",
|
|
971
|
+
"audit": "audit_log",
|
|
972
|
+
"audit_log": "audit_log",
|
|
973
|
+
"task": "task",
|
|
974
|
+
"pr": "pr",
|
|
975
|
+
"commit": "commit",
|
|
976
|
+
}
|
|
977
|
+
return (mapping.get(prefix, "kg_node"), rest)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
async def _fetch_existing_finding(
|
|
981
|
+
db: aiosqlite.Connection, finding_id: str
|
|
982
|
+
) -> aiosqlite.Row | None:
|
|
983
|
+
return await (
|
|
984
|
+
await db.execute(
|
|
985
|
+
"SELECT finding_id, recurrence_count, first_seen_cycle_key,"
|
|
986
|
+
" approval_state FROM brain_findings WHERE finding_id = ?",
|
|
987
|
+
(finding_id,),
|
|
988
|
+
)
|
|
989
|
+
).fetchone()
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
async def _persist_findings(
|
|
993
|
+
*, run_id: str, findings: list[Finding]
|
|
994
|
+
) -> tuple[int, list[str]]:
|
|
995
|
+
if not findings:
|
|
996
|
+
return (0, [])
|
|
997
|
+
persisted = 0
|
|
998
|
+
fingerprints: list[str] = []
|
|
999
|
+
async with write_db() as db:
|
|
1000
|
+
for f in findings:
|
|
1001
|
+
existing = await _fetch_existing_finding(db, f.finding_id)
|
|
1002
|
+
closure_kind, closure_json = _serialize_closure(f.closure_condition)
|
|
1003
|
+
if existing is not None:
|
|
1004
|
+
old_state = existing["approval_state"] if hasattr(existing, "keys") else existing[3]
|
|
1005
|
+
if old_state == "open":
|
|
1006
|
+
await db.execute(
|
|
1007
|
+
"UPDATE brain_findings SET"
|
|
1008
|
+
" last_seen_cycle_key = ?,"
|
|
1009
|
+
" recurrence_count = recurrence_count + 1"
|
|
1010
|
+
" WHERE finding_id = ?",
|
|
1011
|
+
(f.cycle_key, f.finding_id),
|
|
1012
|
+
)
|
|
1013
|
+
continue
|
|
1014
|
+
await db.execute(
|
|
1015
|
+
"INSERT INTO brain_findings ("
|
|
1016
|
+
" finding_id, run_id, cycle_key, detected_at, finding_type,"
|
|
1017
|
+
" schema_version, scope_type, scope_key, program_key,"
|
|
1018
|
+
" title, summary, why_now, evidence_hash, involved_projects_json,"
|
|
1019
|
+
" suggested_artifact, owner_hint_json, closure_condition_kind,"
|
|
1020
|
+
" closure_condition_json, closure_condition_human,"
|
|
1021
|
+
" severity, confidence, approval_state, regression_of_finding_id,"
|
|
1022
|
+
" proposal_fingerprint, recurrence_count, first_seen_cycle_key,"
|
|
1023
|
+
" last_seen_cycle_key, expires_at"
|
|
1024
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,"
|
|
1025
|
+
" ?, ?, 'open', ?, ?, ?, ?, ?, ?)",
|
|
1026
|
+
(
|
|
1027
|
+
f.finding_id,
|
|
1028
|
+
f.run_id,
|
|
1029
|
+
f.cycle_key,
|
|
1030
|
+
_utc_iso(f.detected_at),
|
|
1031
|
+
f.finding_type,
|
|
1032
|
+
f.schema_version,
|
|
1033
|
+
f.scope_type,
|
|
1034
|
+
f.scope_key,
|
|
1035
|
+
f.program_key,
|
|
1036
|
+
f.title,
|
|
1037
|
+
f.summary,
|
|
1038
|
+
f.why_now,
|
|
1039
|
+
f.evidence_hash,
|
|
1040
|
+
json.dumps(f.involved_projects, sort_keys=True, ensure_ascii=False),
|
|
1041
|
+
f.suggested_artifact,
|
|
1042
|
+
_serialize_owner_hint(f.owner_hint),
|
|
1043
|
+
closure_kind,
|
|
1044
|
+
closure_json,
|
|
1045
|
+
f.closure_condition_human,
|
|
1046
|
+
f.severity,
|
|
1047
|
+
f.confidence,
|
|
1048
|
+
f.regression_of_finding_id,
|
|
1049
|
+
f.proposal_fingerprint,
|
|
1050
|
+
f.recurrence_count,
|
|
1051
|
+
f.first_seen_cycle_key,
|
|
1052
|
+
f.last_seen_cycle_key,
|
|
1053
|
+
_utc_iso(f.expires_at),
|
|
1054
|
+
),
|
|
1055
|
+
)
|
|
1056
|
+
await db.execute(
|
|
1057
|
+
"INSERT INTO brain_finding_states ("
|
|
1058
|
+
" state_id, finding_id, from_state, to_state, actor_user_id, reason"
|
|
1059
|
+
") VALUES (?, ?, NULL, 'open', NULL, NULL)",
|
|
1060
|
+
(uuid.uuid4().hex, f.finding_id),
|
|
1061
|
+
)
|
|
1062
|
+
for pos, ev in enumerate(f.evidence):
|
|
1063
|
+
kind, ref = _evidence_kind_for(ev)
|
|
1064
|
+
await db.execute(
|
|
1065
|
+
"INSERT OR IGNORE INTO brain_finding_evidence ("
|
|
1066
|
+
" finding_id, position, evidence_kind, evidence_ref,"
|
|
1067
|
+
" weight, cycle_key"
|
|
1068
|
+
") VALUES (?, ?, ?, ?, 1.0, ?)",
|
|
1069
|
+
(f.finding_id, pos, kind, ref, f.cycle_key),
|
|
1070
|
+
)
|
|
1071
|
+
persisted += 1
|
|
1072
|
+
fingerprints.append(f.proposal_fingerprint)
|
|
1073
|
+
return (persisted, fingerprints)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
async def _supersede_prior(*, run_id: str, fingerprints: list[str]) -> int:
|
|
1077
|
+
"""Mark prior `open` findings with the same fingerprint as `superseded`.
|
|
1078
|
+
|
|
1079
|
+
Approved/dismissed/resolved rows are NEVER auto-superseded (human decision
|
|
1080
|
+
preserved — sub-04 §8 invariant). Only one new row per fingerprint per
|
|
1081
|
+
cycle exists by natural UK; we point predecessors at that new row.
|
|
1082
|
+
"""
|
|
1083
|
+
if not fingerprints:
|
|
1084
|
+
return 0
|
|
1085
|
+
superseded = 0
|
|
1086
|
+
async with write_db() as db:
|
|
1087
|
+
for fp in set(fingerprints):
|
|
1088
|
+
new_row = await (
|
|
1089
|
+
await db.execute(
|
|
1090
|
+
"SELECT finding_id FROM brain_findings"
|
|
1091
|
+
" WHERE run_id = ? AND proposal_fingerprint = ?",
|
|
1092
|
+
(run_id, fp),
|
|
1093
|
+
)
|
|
1094
|
+
).fetchone()
|
|
1095
|
+
if new_row is None:
|
|
1096
|
+
continue
|
|
1097
|
+
new_id = new_row[0] if not hasattr(new_row, "keys") else new_row["finding_id"]
|
|
1098
|
+
await db.execute(
|
|
1099
|
+
"UPDATE brain_findings SET"
|
|
1100
|
+
" approval_state = 'superseded',"
|
|
1101
|
+
" superseded_by_finding_id = ?"
|
|
1102
|
+
" WHERE proposal_fingerprint = ?"
|
|
1103
|
+
" AND approval_state = 'open'"
|
|
1104
|
+
" AND finding_id <> ?",
|
|
1105
|
+
(new_id, fp, new_id),
|
|
1106
|
+
)
|
|
1107
|
+
superseded += 1
|
|
1108
|
+
return superseded
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
async def _run_one_rule(
|
|
1112
|
+
rule_id: str,
|
|
1113
|
+
builder,
|
|
1114
|
+
*,
|
|
1115
|
+
snapshot: FindingSnapshot,
|
|
1116
|
+
run_id: str,
|
|
1117
|
+
now: datetime,
|
|
1118
|
+
timeout_s: int,
|
|
1119
|
+
) -> list[FindingDraft]:
|
|
1120
|
+
async with asyncio.timeout(timeout_s):
|
|
1121
|
+
result = await builder(snapshot, run_id=run_id, now=now)
|
|
1122
|
+
return list(result)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
async def run_phase(
|
|
1126
|
+
*,
|
|
1127
|
+
run_id: str,
|
|
1128
|
+
cycle_key: str,
|
|
1129
|
+
workspace_id: str = "ws_default",
|
|
1130
|
+
now: datetime | None = None,
|
|
1131
|
+
rule_timeout_s: int = DEFAULT_RULE_TIMEOUT_S,
|
|
1132
|
+
) -> FindingsReport:
|
|
1133
|
+
"""Findings phase entry. Caller (jobs._execute_cycle) invokes AFTER the
|
|
1134
|
+
Memory-Ops phase has produced operations for this run_id.
|
|
1135
|
+
|
|
1136
|
+
Per-rule isolation: a rule raising or hitting its 15s timeout appends to
|
|
1137
|
+
`partial_failures` and lets the cycle continue with the other rules. The
|
|
1138
|
+
cycle envelope flips to `partial` if any failure surfaces (jobs.py).
|
|
1139
|
+
"""
|
|
1140
|
+
started = datetime.now(timezone.utc)
|
|
1141
|
+
now = (now or started).astimezone(timezone.utc)
|
|
1142
|
+
snapshot = await build_snapshot(
|
|
1143
|
+
run_id=run_id, cycle_key=cycle_key, workspace_id=workspace_id, as_of=now,
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
all_findings: list[Finding] = []
|
|
1147
|
+
partial_failures: list[dict[str, str]] = []
|
|
1148
|
+
|
|
1149
|
+
for rule_id, builder in REGISTERED_RULES:
|
|
1150
|
+
try:
|
|
1151
|
+
drafts = await _run_one_rule(
|
|
1152
|
+
rule_id,
|
|
1153
|
+
builder,
|
|
1154
|
+
snapshot=snapshot,
|
|
1155
|
+
run_id=run_id,
|
|
1156
|
+
now=now,
|
|
1157
|
+
timeout_s=rule_timeout_s,
|
|
1158
|
+
)
|
|
1159
|
+
except asyncio.TimeoutError:
|
|
1160
|
+
partial_failures.append(
|
|
1161
|
+
{
|
|
1162
|
+
"kind": "finding_rule_failed",
|
|
1163
|
+
"rule_id": rule_id,
|
|
1164
|
+
"error": "timeout",
|
|
1165
|
+
}
|
|
1166
|
+
)
|
|
1167
|
+
continue
|
|
1168
|
+
except Exception as exc: # noqa: BLE001 — per-rule isolation
|
|
1169
|
+
logger.exception("findings: rule %s raised", rule_id)
|
|
1170
|
+
partial_failures.append(
|
|
1171
|
+
{
|
|
1172
|
+
"kind": "finding_rule_failed",
|
|
1173
|
+
"rule_id": rule_id,
|
|
1174
|
+
"error": str(exc)[:500],
|
|
1175
|
+
}
|
|
1176
|
+
)
|
|
1177
|
+
continue
|
|
1178
|
+
for draft in drafts:
|
|
1179
|
+
finding = await finalize_finding(
|
|
1180
|
+
draft=draft, run_id=run_id, cycle_key=cycle_key, now=now,
|
|
1181
|
+
)
|
|
1182
|
+
all_findings.append(finding)
|
|
1183
|
+
|
|
1184
|
+
persisted, fingerprints = await _persist_findings(
|
|
1185
|
+
run_id=run_id, findings=all_findings
|
|
1186
|
+
)
|
|
1187
|
+
await _supersede_prior(run_id=run_id, fingerprints=fingerprints)
|
|
1188
|
+
|
|
1189
|
+
return FindingsReport(
|
|
1190
|
+
run_id=run_id,
|
|
1191
|
+
cycle_key=cycle_key,
|
|
1192
|
+
finding_count=persisted,
|
|
1193
|
+
partial_failures=partial_failures,
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
# ---------------------------------------------------------------------------
|
|
1198
|
+
# Brain v1.2 — Direction integration helpers
|
|
1199
|
+
# ---------------------------------------------------------------------------
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
# Confidence numeric -> tier mapping (decisione 2026-05-18, Emilio).
|
|
1203
|
+
# Threshold to emit finding remains numeric (>= 0.85, high tier).
|
|
1204
|
+
_CONFIDENCE_TIER_LOW_UPPER = 0.5
|
|
1205
|
+
_CONFIDENCE_TIER_HIGH_LOWER = 0.85
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def map_confidence_to_tier(numeric: float) -> ConfidenceTier:
|
|
1209
|
+
"""Map a numeric confidence in [0,1] to a categorical tier.
|
|
1210
|
+
|
|
1211
|
+
Mapping (decisione 2026-05-18):
|
|
1212
|
+
x < 0.5 -> 'low'
|
|
1213
|
+
0.5 <= x < 0.85 -> 'medium'
|
|
1214
|
+
x >= 0.85 -> 'high'
|
|
1215
|
+
|
|
1216
|
+
Values outside [0,1] are clamped before mapping. NaN is rejected (raises
|
|
1217
|
+
ValueError) to avoid silently surfacing a 'low' tier when the upstream
|
|
1218
|
+
LLM returned a bogus value.
|
|
1219
|
+
"""
|
|
1220
|
+
if numeric != numeric: # NaN guard
|
|
1221
|
+
raise ValueError("confidence must not be NaN")
|
|
1222
|
+
if numeric < 0.0:
|
|
1223
|
+
numeric = 0.0
|
|
1224
|
+
elif numeric > 1.0:
|
|
1225
|
+
numeric = 1.0
|
|
1226
|
+
if numeric < _CONFIDENCE_TIER_LOW_UPPER:
|
|
1227
|
+
return "low"
|
|
1228
|
+
if numeric < _CONFIDENCE_TIER_HIGH_LOWER:
|
|
1229
|
+
return "medium"
|
|
1230
|
+
return "high"
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
async def emit_finding_dedup(
|
|
1234
|
+
*,
|
|
1235
|
+
finding_type: FindingType,
|
|
1236
|
+
entity_ref: str,
|
|
1237
|
+
payload: dict[str, Any],
|
|
1238
|
+
confidence_numeric: float,
|
|
1239
|
+
scope_type: ScopeTypeL4,
|
|
1240
|
+
scope_key: str,
|
|
1241
|
+
cycle_key: str,
|
|
1242
|
+
run_id: str,
|
|
1243
|
+
title: str,
|
|
1244
|
+
summary: str,
|
|
1245
|
+
why_now: str,
|
|
1246
|
+
severity: Severity = "medium",
|
|
1247
|
+
suggested_artifact: SuggestedArtifact = "none",
|
|
1248
|
+
evidence_hash_hex: str | None = None,
|
|
1249
|
+
proposal_fingerprint: str | None = None,
|
|
1250
|
+
program_key: str | None = None,
|
|
1251
|
+
approval_state_new: FindingApprovalState = "open",
|
|
1252
|
+
expires_at: datetime | None = None,
|
|
1253
|
+
) -> tuple[str, bool]:
|
|
1254
|
+
"""Check-then-boost dedup helper for direction_* findings.
|
|
1255
|
+
|
|
1256
|
+
Semantics (decisione brainstorm §7 / decisione 2026-05-18):
|
|
1257
|
+
* Lookup existing finding by (finding_type, entity_ref) where
|
|
1258
|
+
approval_state IN ('open', 'pending_bootstrap').
|
|
1259
|
+
* If found: UPDATE urgency_score += 1, recurrence_count += 1,
|
|
1260
|
+
last_seen_cycle_key = cycle_key, proposed_payload_json = new payload.
|
|
1261
|
+
* If not found: INSERT new row with urgency_score=1, recurrence_count=1,
|
|
1262
|
+
confidence = map_confidence_to_tier(confidence_numeric).
|
|
1263
|
+
|
|
1264
|
+
Returns (finding_id, was_created).
|
|
1265
|
+
"""
|
|
1266
|
+
if not entity_ref:
|
|
1267
|
+
raise ValueError("entity_ref required for dedup emit")
|
|
1268
|
+
|
|
1269
|
+
confidence_tier: ConfidenceTier = map_confidence_to_tier(confidence_numeric)
|
|
1270
|
+
payload_json = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
1271
|
+
|
|
1272
|
+
async with write_db() as db:
|
|
1273
|
+
cur = await db.execute(
|
|
1274
|
+
"SELECT finding_id, urgency_score, recurrence_count, approval_state"
|
|
1275
|
+
" FROM brain_findings"
|
|
1276
|
+
" WHERE finding_type = ? AND entity_ref = ?"
|
|
1277
|
+
" AND approval_state IN ('open', 'pending_bootstrap')"
|
|
1278
|
+
" ORDER BY created_at DESC"
|
|
1279
|
+
" LIMIT 1",
|
|
1280
|
+
(finding_type, entity_ref),
|
|
1281
|
+
)
|
|
1282
|
+
existing = await cur.fetchone()
|
|
1283
|
+
await cur.close()
|
|
1284
|
+
|
|
1285
|
+
if existing is not None:
|
|
1286
|
+
finding_id = existing[0]
|
|
1287
|
+
await db.execute(
|
|
1288
|
+
"UPDATE brain_findings SET"
|
|
1289
|
+
" urgency_score = urgency_score + 1,"
|
|
1290
|
+
" recurrence_count = recurrence_count + 1,"
|
|
1291
|
+
" last_seen_cycle_key = ?,"
|
|
1292
|
+
" proposed_payload_json = ?,"
|
|
1293
|
+
" confidence = ?,"
|
|
1294
|
+
" summary = ?,"
|
|
1295
|
+
" why_now = ?"
|
|
1296
|
+
" WHERE finding_id = ?",
|
|
1297
|
+
(cycle_key, payload_json, confidence_tier, summary, why_now, finding_id),
|
|
1298
|
+
)
|
|
1299
|
+
return (finding_id, False)
|
|
1300
|
+
|
|
1301
|
+
# INSERT path
|
|
1302
|
+
finding_id = f"fnd_{uuid.uuid4().hex[:24]}"
|
|
1303
|
+
now_iso = _utc_iso(datetime.now(timezone.utc))
|
|
1304
|
+
if expires_at is None:
|
|
1305
|
+
expires_iso = _utc_iso(
|
|
1306
|
+
datetime.now(timezone.utc) + timedelta(days=DEFAULT_OPEN_TTL_DAYS)
|
|
1307
|
+
)
|
|
1308
|
+
else:
|
|
1309
|
+
expires_iso = _utc_iso(expires_at)
|
|
1310
|
+
ev_hash = evidence_hash_hex or hashlib.blake2b(
|
|
1311
|
+
entity_ref.encode("utf-8"), digest_size=32
|
|
1312
|
+
).hexdigest()
|
|
1313
|
+
fp = proposal_fingerprint or hashlib.blake2b(
|
|
1314
|
+
f"{finding_type}:{entity_ref}:{cycle_key}".encode("utf-8"),
|
|
1315
|
+
digest_size=16,
|
|
1316
|
+
).hexdigest()
|
|
1317
|
+
await db.execute(
|
|
1318
|
+
"INSERT INTO brain_findings ("
|
|
1319
|
+
" finding_id, run_id, cycle_key, detected_at, finding_type,"
|
|
1320
|
+
" scope_type, scope_key, program_key, title, summary, why_now,"
|
|
1321
|
+
" evidence_hash, suggested_artifact, closure_condition_kind,"
|
|
1322
|
+
" severity, confidence, approval_state,"
|
|
1323
|
+
" proposal_fingerprint, recurrence_count, first_seen_cycle_key,"
|
|
1324
|
+
" last_seen_cycle_key, expires_at,"
|
|
1325
|
+
" urgency_score, entity_ref, proposed_payload_json"
|
|
1326
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'manual_attest',"
|
|
1327
|
+
" ?, ?, ?, ?, 1, ?, ?, ?, 1, ?, ?)",
|
|
1328
|
+
(
|
|
1329
|
+
finding_id,
|
|
1330
|
+
run_id,
|
|
1331
|
+
cycle_key,
|
|
1332
|
+
now_iso,
|
|
1333
|
+
finding_type,
|
|
1334
|
+
scope_type,
|
|
1335
|
+
scope_key,
|
|
1336
|
+
program_key,
|
|
1337
|
+
title[:200],
|
|
1338
|
+
summary[:2000],
|
|
1339
|
+
why_now[:500],
|
|
1340
|
+
ev_hash,
|
|
1341
|
+
suggested_artifact,
|
|
1342
|
+
severity,
|
|
1343
|
+
confidence_tier,
|
|
1344
|
+
approval_state_new,
|
|
1345
|
+
fp,
|
|
1346
|
+
cycle_key,
|
|
1347
|
+
cycle_key,
|
|
1348
|
+
expires_iso,
|
|
1349
|
+
entity_ref,
|
|
1350
|
+
payload_json,
|
|
1351
|
+
),
|
|
1352
|
+
)
|
|
1353
|
+
await db.execute(
|
|
1354
|
+
"INSERT INTO brain_finding_states ("
|
|
1355
|
+
" state_id, finding_id, from_state, to_state, actor_user_id, reason"
|
|
1356
|
+
") VALUES (?, ?, NULL, ?, NULL, NULL)",
|
|
1357
|
+
(uuid.uuid4().hex, finding_id, approval_state_new),
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
return (finding_id, True)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
__all__ = [
|
|
1364
|
+
"DEFAULT_OPEN_TTL_DAYS",
|
|
1365
|
+
"DEFAULT_RULE_TIMEOUT_S",
|
|
1366
|
+
"PER_TYPE_CAP_DEFAULT",
|
|
1367
|
+
"FindingDraft",
|
|
1368
|
+
"FindingSnapshot",
|
|
1369
|
+
"FindingsReport",
|
|
1370
|
+
"REGISTERED_RULES",
|
|
1371
|
+
"build_snapshot",
|
|
1372
|
+
"emit_finding_dedup",
|
|
1373
|
+
"evidence_hash",
|
|
1374
|
+
"finalize_finding",
|
|
1375
|
+
"make_finding_id",
|
|
1376
|
+
"make_proposal_fingerprint",
|
|
1377
|
+
"map_confidence_to_tier",
|
|
1378
|
+
"run_phase",
|
|
1379
|
+
]
|